mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
Merge main into fix/issue-99-library-lock
# Conflicts: # crates/catalog/src/lib.rs
This commit is contained in:
@@ -10,22 +10,21 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, cmd, has_active, has_selection};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A free `<stem>.dng` (then `<stem>-2.dng`…) next to `path`.
|
||||
fn dng_path(path: &str) -> String {
|
||||
/// `<stem>.dng`, then `<stem>-2.dng`, `<stem>-3.dng`… next to `path`.
|
||||
fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
let p = Path::new(path);
|
||||
let dir = p.parent().unwrap_or(Path::new(""));
|
||||
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let mut out = dir.join(format!("{stem}.dng"));
|
||||
let mut n = 2;
|
||||
while out.exists() {
|
||||
out = dir.join(format!("{stem}-{n}.dng"));
|
||||
n += 1;
|
||||
}
|
||||
out.to_string_lossy().to_string()
|
||||
(1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) })
|
||||
}
|
||||
|
||||
/// Write the DNG for raw file `path` (develop settings in `packet`); returns the new path.
|
||||
/// An empty `packet` writes no XMP.
|
||||
/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name;
|
||||
/// returns the new path. An empty `packet` writes no XMP.
|
||||
///
|
||||
/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as
|
||||
/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is
|
||||
/// written to a temp file, synced, read back identical and only then given its name — never
|
||||
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
|
||||
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match &s.media.file_bytes {
|
||||
Some(r) => r(path)?,
|
||||
@@ -35,9 +34,21 @@ pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::res
|
||||
drop(bytes);
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out = dng_path(path);
|
||||
std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?;
|
||||
Ok(out)
|
||||
let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?;
|
||||
let same = match (&back.data, &raw.data) {
|
||||
(lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b,
|
||||
(lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same {
|
||||
return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept"));
|
||||
}
|
||||
drop((raw, back));
|
||||
let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng)
|
||||
.map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?;
|
||||
Ok(out.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -133,7 +144,7 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mut written = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
crate::export::write_file(path, bytes)?;
|
||||
written.push(path.to_string());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
@@ -96,5 +96,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
save
|
||||
),
|
||||
cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete),
|
||||
cmd!(
|
||||
query "export.checkTarget",
|
||||
"Check Output Path",
|
||||
[],
|
||||
None,
|
||||
"{path} — fails when writing `path` would replace a photo's original (or its XMP sidecar) in the library; front ends call it before saving a render or screenshot to a user-given path → {path}",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("export.checkTarget", "missing `path`"))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.checkTarget", e))?;
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) {
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| bad(id, e.to_string()))?;
|
||||
std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
s.check_write_target(path).map_err(|e| bad(id, e))?;
|
||||
crate::export::write_file(path, &png).map_err(|e| bad(id, e))?;
|
||||
info["previewPath"] = json!(path);
|
||||
}
|
||||
if let Some(img) = &out.preview {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Missing originals: photos whose file is no longer where the library expects it (moved,
|
||||
//! renamed, on an unplugged drive), and relinking them — one at a time (`photo.relink`) or by
|
||||
//! searching a folder for files with the same name and size (`library.findMissing`).
|
||||
//! searching a folder for files with the same name and size, or the same content
|
||||
//! (`library.findMissing`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
|
||||
@@ -83,8 +84,29 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "path": abs}))
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file name — and its size when the
|
||||
/// library knows it — and relink every match in one undoable step.
|
||||
/// The stored content hash of a photo, without the `:dupN` suffix duplicates get.
|
||||
fn stored_hash(p: &Photo) -> Option<&str> {
|
||||
p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
/// The content hash of the file at `path` (as import computes it), if it can be read.
|
||||
fn file_hash(path: &str) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
|
||||
/// undoable step.
|
||||
///
|
||||
/// Matching, per photo:
|
||||
/// 1. a file with the same name (any letter case), the same size and the same content hash;
|
||||
/// 2. renamed: a file of the same size with the same content hash (only same-size files are
|
||||
/// read, so this stays cheap);
|
||||
/// 3. without a hash to confirm (or the file was edited in place): the one file with the same
|
||||
/// name and size. Several such files are ambiguous: the photo is skipped and reported in
|
||||
/// `ambiguous`, never guessed (two cameras' `IMG_0001.CR3` of one size are different photos).
|
||||
///
|
||||
/// Files that are already some photo's original, or that another photo was just matched with,
|
||||
/// are not used.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
@@ -93,27 +115,72 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let lost = missing(s);
|
||||
if lost.is_empty() {
|
||||
return Ok(json!({"found": [], "missing": 0}));
|
||||
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
|
||||
}
|
||||
// file name (lower case) → candidate paths
|
||||
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
|
||||
// file name (lower case) → candidate paths; size → paths (filled lazily)
|
||||
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for f in crate::import::expand(&[folder.to_string()], None) {
|
||||
if let Some(n) = Path::new(&f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f);
|
||||
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
|
||||
for f in &files {
|
||||
if let Some(n) = Path::new(f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
|
||||
}
|
||||
}
|
||||
let mut by_size: Option<HashMap<u64, Vec<String>>> = None;
|
||||
let mut hashes: HashMap<String, Option<String>> = HashMap::new();
|
||||
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
|
||||
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
let mut used: HashSet<String> = HashSet::new();
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
let mut ambiguous = Vec::new();
|
||||
for (id, old) in &lost {
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = s.catalog.photo(*id).map(|p| p.file_size).unwrap_or(0);
|
||||
let hit = by_name.get(&name).and_then(|c| {
|
||||
c.iter().find(|f| size == 0 || std::fs::metadata(f).is_ok_and(|m| m.len() == size)).or_else(|| (c.len() == 1 && size == 0).then(|| &c[0]))
|
||||
});
|
||||
if let Some(path) = hit {
|
||||
ops.push(relink_op(*id, path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path}));
|
||||
let size = ph.file_size;
|
||||
let hash = stored_hash(ph).map(str::to_string);
|
||||
let named: Vec<&String> = by_name
|
||||
.get(&name)
|
||||
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
|
||||
.unwrap_or_default();
|
||||
// 1. same name and size, confirmed by the content hash
|
||||
let mut hit: Option<(String, &str)> =
|
||||
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
|
||||
// 2. renamed: same size and content
|
||||
if hit.is_none()
|
||||
&& let Some(h) = &hash
|
||||
&& size > 0
|
||||
{
|
||||
let sizes = by_size.get_or_insert_with(|| {
|
||||
let mut m: HashMap<u64, Vec<String>> = HashMap::new();
|
||||
for f in &files {
|
||||
if let Some(n) = size_of(f) {
|
||||
m.entry(n).or_default().push(f.clone());
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
hit = sizes
|
||||
.get(&size)
|
||||
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
|
||||
.map(|f| (f.clone(), "content"));
|
||||
}
|
||||
// 3. same name and size only (no hash, or the file was edited in place): one candidate
|
||||
// is taken, several are ambiguous and skipped rather than guessed
|
||||
if hit.is_none() {
|
||||
match named.len() {
|
||||
1 => hit = Some((named[0].clone(), "name")),
|
||||
0 => {}
|
||||
_ => {
|
||||
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((path, by)) = hit else { continue };
|
||||
ops.push(relink_op(*id, &path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
|
||||
used.insert(path);
|
||||
}
|
||||
let still = lost.len() - found.len();
|
||||
if !ops.is_empty() {
|
||||
@@ -124,7 +191,7 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"found": found, "missing": still}))
|
||||
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -146,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Missing Photos",
|
||||
[],
|
||||
None,
|
||||
"{folder} — relink every missing photo whose file (same name and size) is somewhere in the folder → {found: [{id, from, to}], missing}",
|
||||
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
|
||||
always,
|
||||
find_missing
|
||||
),
|
||||
|
||||
@@ -102,7 +102,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let (mut built, mut removed, mut failed) = (0usize, 0usize, Vec::new());
|
||||
let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new());
|
||||
if !discard {
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if s.smart_previews_dir.is_some() {
|
||||
@@ -123,20 +123,27 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if path.exists() {
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
let damaged = path.exists();
|
||||
if damaged && crate::smart::is_valid(&path) {
|
||||
built += 1;
|
||||
continue;
|
||||
}
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one
|
||||
let r = s
|
||||
.source_now(id, crate::media::SourceLevel::Preview)
|
||||
.and_then(|src| crate::smart::encode(&src))
|
||||
.and_then(|b| std::fs::write(&path, b).map_err(|e| e.to_string()));
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
match r {
|
||||
Ok(()) => built += 1,
|
||||
Ok(()) => {
|
||||
built += 1;
|
||||
repaired += usize::from(damaged);
|
||||
}
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
}
|
||||
Ok(json!({"built": built, "removed": removed, "failed": failed}))
|
||||
Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed}))
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
@@ -205,7 +212,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Whether photo `id` has a smart preview.
|
||||
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
|
||||
match (&s.media.smart_dir, s.catalog.photo(id)) {
|
||||
(Some(dir), Some(p)) => dir.join(crate::smart::file_name(p)).exists(),
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -218,7 +225,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Build Smart Previews",
|
||||
[],
|
||||
None,
|
||||
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline → {built, removed, failed}",
|
||||
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}",
|
||||
always,
|
||||
smart
|
||||
),
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
|
||||
.collect();
|
||||
// XMP: the sidecar if there is one, else the file's own packet
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.xmp.naming).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let xmp: Vec<Value> = packet
|
||||
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
|
||||
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
|
||||
|
||||
@@ -9,14 +9,26 @@ use crate::{Result, Session};
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut written = Vec::new();
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let owners = crate::sidecar::StemOwners::of(&s.catalog);
|
||||
for id in s.targets(p) {
|
||||
match s.save_sidecar(id) {
|
||||
Ok(path) => written.push(path.display().to_string()),
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
Ok(r) => {
|
||||
let path = r.path.display().to_string();
|
||||
if r.merged {
|
||||
merged.push(path.clone());
|
||||
}
|
||||
if let Some(b) = r.backup {
|
||||
backups.push(json!({"path": path, "backup": b.display().to_string()}));
|
||||
}
|
||||
written.push(path);
|
||||
}
|
||||
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
Ok(json!({"written": written, "failed": failed}))
|
||||
Ok(json!({"written": written, "merged": merged, "backups": backups, "failed": failed}))
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -63,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Metadata to File",
|
||||
["Photo"],
|
||||
Some("Cmd+S"),
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original, merged into an existing one (other apps' data kept; an unreadable one is backed up first) → {written: [path], merged: [path], backups: [{path, backup}], failed}",
|
||||
has_selection,
|
||||
save
|
||||
),
|
||||
|
||||
+77
-27
@@ -987,6 +987,8 @@ pub struct PreparedExport {
|
||||
pub photo: lightcraft_catalog::PhotoId,
|
||||
pub file_name: String,
|
||||
work: Work,
|
||||
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
|
||||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||||
}
|
||||
|
||||
struct RenderWork {
|
||||
@@ -1009,12 +1011,30 @@ enum Work {
|
||||
},
|
||||
}
|
||||
|
||||
/// Set up the export of photo `id` at 1-based position `seq` of a batch.
|
||||
/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use
|
||||
/// [`prepare_batch`]: it looks at the library's originals once.)
|
||||
pub fn prepare_export(
|
||||
session: &mut crate::Session,
|
||||
id: lightcraft_catalog::PhotoId,
|
||||
o: &ExportOptions,
|
||||
seq: usize,
|
||||
) -> Result<PreparedExport, String> {
|
||||
let guard = std::sync::Arc::new(session.original_guard());
|
||||
prepare_guarded(session, id, o, seq, guard)
|
||||
}
|
||||
|
||||
/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1).
|
||||
pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result<Vec<PreparedExport>, String> {
|
||||
let guard = std::sync::Arc::new(session.original_guard());
|
||||
ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect()
|
||||
}
|
||||
|
||||
fn prepare_guarded(
|
||||
session: &mut crate::Session,
|
||||
id: lightcraft_catalog::PhotoId,
|
||||
o: &ExportOptions,
|
||||
seq: usize,
|
||||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
@@ -1036,7 +1056,7 @@ pub fn prepare_export(
|
||||
size: (p.width as usize, p.height as usize),
|
||||
}
|
||||
};
|
||||
Ok(PreparedExport { photo: id, file_name, work })
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard })
|
||||
}
|
||||
|
||||
impl PreparedExport {
|
||||
@@ -1088,14 +1108,16 @@ pub enum DngCompression {
|
||||
}
|
||||
|
||||
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
|
||||
/// applied), or one exact file path (single photo; overwritten).
|
||||
/// applied to each file together with its sidecars), or one exact file path (single photo; an
|
||||
/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar)
|
||||
/// is never written over: see [`crate::originals`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Destination {
|
||||
pub dir: String,
|
||||
pub exact: Option<String>,
|
||||
}
|
||||
|
||||
/// Export `ids` in order ([`prepare_export`] + [`run_batch`]), stopping at the first error.
|
||||
/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error.
|
||||
pub fn export_batch(
|
||||
session: &mut crate::Session,
|
||||
ids: &[lightcraft_catalog::PhotoId],
|
||||
@@ -1104,15 +1126,34 @@ pub fn export_batch(
|
||||
write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
|
||||
exists: &dyn Fn(&str) -> bool,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let items = ids.iter().enumerate().map(|(i, id)| prepare_export(session, *id, o, i + 1)).collect::<Result<Vec<_>, _>>()?;
|
||||
let items = prepare_batch(session, ids, o)?;
|
||||
run_batch(items, o, to, write, exists, true, &mut |_, _| true)
|
||||
}
|
||||
|
||||
/// Write an exported or rendered file on disk: its folder is created if needed, and the file is
|
||||
/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic`]: a temp file, synced, then
|
||||
/// renamed), so a failure part-way leaves any previous file intact and no truncated one. The
|
||||
/// native writer behind exports, renders and screenshots (app, CLI, MCP).
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let p = std::path::Path::new(path);
|
||||
if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_atomic(p, bytes).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// The path of the sidecar with extension `ext` of the exported file `main`.
|
||||
fn sidecar_path(main: &str, ext: &str) -> String {
|
||||
std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to
|
||||
/// `write`. `exists` tells whether a path is taken (conflict policy). `progress(done, next file)`
|
||||
/// is called before each photo; returning false cancels the rest. Returns one JSON object per
|
||||
/// photo: `{path, width, height, bytes, sidecars}`, `{skipped: path}` or (unless
|
||||
/// `stop_on_error`) `{photo, file, error}`.
|
||||
/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its
|
||||
/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when
|
||||
/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the
|
||||
/// policy. `progress(done, next file)` is called before each photo; returning false cancels the
|
||||
/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`,
|
||||
/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`.
|
||||
pub fn run_batch(
|
||||
items: Vec<PreparedExport>,
|
||||
o: &ExportOptions,
|
||||
@@ -1132,7 +1173,7 @@ pub fn run_batch(
|
||||
if !progress(i, &item.file_name) {
|
||||
break;
|
||||
}
|
||||
let (photo, name) = (item.photo, item.file_name.clone());
|
||||
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
|
||||
let e = match item.run() {
|
||||
Ok(e) => e,
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
@@ -1141,14 +1182,18 @@ pub fn run_batch(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// the exported file and its sidecars
|
||||
let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>();
|
||||
let path = match to.exact.as_deref().filter(|_| single) {
|
||||
Some(p) => p.to_string(),
|
||||
Some(p) => Ok(p.to_string()),
|
||||
None => {
|
||||
let mut path = join(&dir, &e.file_name);
|
||||
let busy = |p: &str, taken: &std::collections::HashSet<String>| taken.contains(p) || exists(p);
|
||||
if busy(&path, &taken) {
|
||||
let path = join(&dir, &e.file_name);
|
||||
let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p));
|
||||
if !busy(&path) {
|
||||
Ok(path)
|
||||
} else {
|
||||
match o.conflict {
|
||||
Conflict::Overwrite => {}
|
||||
Conflict::Overwrite => Ok(path),
|
||||
Conflict::Skip => {
|
||||
out.push(json!({"skipped": path}));
|
||||
continue;
|
||||
@@ -1156,29 +1201,34 @@ pub fn run_batch(
|
||||
Conflict::Unique => {
|
||||
let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b)));
|
||||
let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
|
||||
path = (2..).map(name).find(|p| !busy(p, &taken)).expect("a free name");
|
||||
(2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
};
|
||||
let written = write(&path, &e.bytes).and_then(|()| {
|
||||
let mut sidecars = Vec::new();
|
||||
for (ext, bytes) in &e.sidecars {
|
||||
let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string();
|
||||
write(&sc, bytes)?;
|
||||
sidecars.push(sc);
|
||||
let file = path.clone().unwrap_or_else(|_| name.clone());
|
||||
let written = path.and_then(|path| {
|
||||
let files = group(&path);
|
||||
// never over an original, whatever the conflict policy or the exact path said
|
||||
for f in &files {
|
||||
guard.check(std::path::Path::new(f))?;
|
||||
}
|
||||
Ok(sidecars)
|
||||
write(&path, &e.bytes)?;
|
||||
let mut sidecars = Vec::new();
|
||||
for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) {
|
||||
write(sc, bytes)?;
|
||||
sidecars.push(sc.clone());
|
||||
}
|
||||
Ok((path, files, sidecars))
|
||||
});
|
||||
match written {
|
||||
Ok(sidecars) => {
|
||||
taken.insert(path.clone());
|
||||
Ok((path, files, sidecars)) => {
|
||||
taken.extend(files);
|
||||
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
|
||||
}
|
||||
Err(err) if stop_on_error => return Err(err),
|
||||
Err(err) => out.push(json!({"photo": photo.0, "file": path, "error": err})),
|
||||
Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -374,25 +374,14 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
}
|
||||
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path.
|
||||
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
|
||||
/// synced and compared byte for byte); a bad one is removed and the photo reported as failed.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| 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())
|
||||
crate::import_move::copy_new(Path::new(src), &dir, &name).map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
|
||||
@@ -169,6 +169,27 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// Import → **Copy**: copy `src` into `dir` as `name` (-1, -2… when taken) with the same checks as
|
||||
/// a move's copy — a new file (never overwriting one, even one that appears meanwhile), synced to
|
||||
/// disk and compared byte for byte with the source; a bad copy is removed and reported, so a card
|
||||
/// is never wiped on the strength of it. Returns the new path.
|
||||
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, 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()),
|
||||
};
|
||||
for i in 0..100_000u32 {
|
||||
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
|
||||
match copy_verified(src, &d) {
|
||||
Ok(()) => return Ok(d),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(format!("copy {}: {e} (nothing was imported from it; the original is untouched)", src.display())),
|
||||
}
|
||||
}
|
||||
Err(format!("copy {}: no free name in {}", src.display(), dir.display()))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -213,7 +234,7 @@ pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
|
||||
}
|
||||
|
||||
/// A photo (by extension) still beside a stem-named sidecar, sharing its stem.
|
||||
fn sibling_photo(sidecar: &Path) -> Option<String> {
|
||||
pub(crate) 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| {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod library;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
pub mod originals;
|
||||
pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
@@ -381,15 +382,22 @@ impl Session {
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing).
|
||||
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing). Files
|
||||
/// that can't be moved back after a failure are reported and the library follows them (a
|
||||
/// logged change outside the undo history), so none goes missing.
|
||||
fn apply_with_files(&mut self, op: &Op) -> Result<Op> {
|
||||
let fs = rename::RealFs;
|
||||
let moves = self.file_moves(op);
|
||||
Session::move_files(&moves)?;
|
||||
if let Err(e) = rename::move_all(&fs, &moves) {
|
||||
return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false))));
|
||||
}
|
||||
match self.catalog.apply(op.clone()) {
|
||||
Ok(inv) => Ok(inv),
|
||||
Err(e) => {
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
let _ = Session::move_files(&back);
|
||||
if let Err(be) = rename::move_all(&fs, &back) {
|
||||
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
|
||||
}
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,16 +411,35 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
|
||||
///
|
||||
/// The snapshot is written even when appending the queued ops fails: it is a fresh file
|
||||
/// holding everything in memory, so it saves those ops too (the log handle may be the only
|
||||
/// thing that's broken). Fails only if nothing could be saved; the ops then stay queued.
|
||||
pub fn close_library(&mut self) -> Result<()> {
|
||||
if self.library.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = self.end_interaction();
|
||||
self.persist()?;
|
||||
let persisted = self.persist();
|
||||
self.save_view();
|
||||
let unlogged = if persisted.is_err() { self.pending_log.len() as u64 } else { 0 };
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
lib.journal.snapshot(&self.catalog)?;
|
||||
Ok(())
|
||||
let before = lib.journal.seq();
|
||||
let snapshot = lib.journal.snapshot_with_unlogged(&self.catalog, unlogged);
|
||||
if unlogged > 0 && lib.journal.seq() == before + unlogged {
|
||||
// the snapshot holds the queued ops: they are saved, never append them again
|
||||
log::info!("library: {unlogged} queued change(s) saved in the closing snapshot");
|
||||
self.pending_log.clear();
|
||||
lib.unsaved_error = None;
|
||||
lib.retry_at = None;
|
||||
lib.last_error = None;
|
||||
}
|
||||
match (persisted, snapshot) {
|
||||
(_, Ok(())) => Ok(()),
|
||||
// neither the log nor the snapshot took the queued ops
|
||||
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
|
||||
(_, Err(s)) => Err(s.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
|
||||
@@ -290,7 +290,7 @@ impl MediaCache {
|
||||
&& !std::path::Path::new(path).exists()
|
||||
{
|
||||
let sp = dir.join(crate::smart::file_name(p));
|
||||
if sp.exists() {
|
||||
if crate::smart::is_valid(&sp) {
|
||||
return SourceRef::Smart { path: sp };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,15 +304,13 @@ fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) {
|
||||
s.color.saturation = a.saturation;
|
||||
}
|
||||
|
||||
/// A free file name `dir/<stem>-<suffix>.dng` (`-2`, `-3`… when taken).
|
||||
fn output_path(first: &str, suffix: &str) -> String {
|
||||
/// `dir/<stem>-<suffix>.dng`, then `-2`, `-3`… (the candidates for a merge's output).
|
||||
fn output_names(first: &str, suffix: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
let p = std::path::Path::new(first);
|
||||
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into());
|
||||
let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") });
|
||||
// (`u64::MAX` taken names can't exist; the last candidate is the fallback)
|
||||
let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX));
|
||||
free.to_string_lossy().to_string()
|
||||
let suffix = suffix.to_string();
|
||||
(1u64..).map(move |k| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }))
|
||||
}
|
||||
|
||||
/// Parse `merge.*` command parameters into a kind and finishing options.
|
||||
@@ -367,8 +365,11 @@ impl Session {
|
||||
/// apply Auto Settings / the auto crop. Returns `{id, path, …info}`.
|
||||
pub fn finish_merge(&mut self, job: &MergeJob, out: MergeOutput) -> Result<Value> {
|
||||
let first = &job.sources.first().ok_or_else(|| EngineError::Other("nothing merged".into()))?.1;
|
||||
let path = output_path(first, job.kind.suffix());
|
||||
std::fs::write(&path, &out.dng).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// a new file under a free name (never replacing one), complete and synced before it appears
|
||||
let path = lightcraft_catalog::safe_file::write_new_unique(&mut output_names(first, job.kind.suffix()), &out.dng)
|
||||
.map_err(|e| EngineError::Other(format!("could not write the merged DNG next to {first}: {e}")))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let report = crate::import::import(self, std::slice::from_ref(&path), crate::import::ImportMode::Add)?;
|
||||
let id = report
|
||||
.imported
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Never write over an original: exports, renders and other files LightCraft writes for the user
|
||||
//! must not land on a catalogued photo's own file (or its XMP sidecar), whatever the conflict
|
||||
//! setting or exact path asked for. [`OriginalGuard`] is a snapshot of the library's originals
|
||||
//! that answers "would writing here replace one?" by file identity (the same file under another
|
||||
//! spelling, through `..`, a link or a case-insensitive volume counts), not by comparing strings.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::safe_file::same_file;
|
||||
use lightcraft_catalog::{Catalog, Source};
|
||||
|
||||
/// The library's originals and their sidecars, looked up by file name.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OriginalGuard {
|
||||
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
|
||||
by_name: HashMap<String, Vec<(PathBuf, String)>>,
|
||||
}
|
||||
|
||||
impl OriginalGuard {
|
||||
/// The originals of every photo in `catalog`.
|
||||
pub fn new(catalog: &Catalog) -> Self {
|
||||
let mut g = Self::default();
|
||||
for p in catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
g.add(Path::new(path), &p.file_name);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
|
||||
pub fn add(&mut self, path: &Path, label: &str) {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
|
||||
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
|
||||
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
put(format!("{stem}.xmp"), path.with_extension("xmp"), format!("the XMP sidecar of {label}"));
|
||||
put(format!("{name}.xmp"), PathBuf::from(format!("{}.xmp", path.to_string_lossy())), format!("the XMP sidecar of {label}"));
|
||||
}
|
||||
|
||||
/// `Err` (a message for the user) when writing `target` would replace a protected file.
|
||||
pub fn check(&self, target: &Path) -> Result<(), String> {
|
||||
if std::fs::symlink_metadata(target).is_err() {
|
||||
return Ok(()); // nothing there to replace
|
||||
}
|
||||
let mut names: Vec<String> = target.file_name().map(|n| n.to_string_lossy().to_lowercase()).into_iter().collect();
|
||||
if let Some(n) = std::fs::canonicalize(target).ok().and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_lowercase())) {
|
||||
names.push(n);
|
||||
}
|
||||
for n in &names {
|
||||
for (p, what) in self.by_name.get(n).into_iter().flatten() {
|
||||
if same_file(p, target) {
|
||||
return Err(format!(
|
||||
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// A guard over this library's originals (see [`OriginalGuard`]).
|
||||
pub fn original_guard(&self) -> OriginalGuard {
|
||||
OriginalGuard::new(&self.catalog)
|
||||
}
|
||||
|
||||
/// `Err` when writing `path` would replace a catalogued photo's original or sidecar.
|
||||
pub fn check_write_target(&self, path: &str) -> Result<(), String> {
|
||||
self.original_guard().check(Path::new(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId};
|
||||
|
||||
#[test]
|
||||
fn originals_and_their_sidecars_are_protected_by_identity() {
|
||||
let d = std::env::temp_dir().join(format!("lc-originals-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("sub")).unwrap();
|
||||
let orig = d.join("IMG_1.jpg");
|
||||
std::fs::write(&orig, b"original").unwrap();
|
||||
std::fs::write(d.join("IMG_1.xmp"), b"<x/>").unwrap();
|
||||
std::fs::write(d.join("other.jpg"), b"other").unwrap();
|
||||
let mut c = Catalog::default();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: orig.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
let g = OriginalGuard::new(&c);
|
||||
assert!(g.check(&orig).unwrap_err().contains("original of IMG_1.jpg"));
|
||||
assert!(g.check(&d.join("sub/../IMG_1.jpg")).is_err(), "another spelling of the same file");
|
||||
assert!(g.check(&d.join("IMG_1.xmp")).unwrap_err().contains("sidecar"));
|
||||
assert!(g.check(&d.join("other.jpg")).is_ok(), "an ordinary existing file");
|
||||
assert!(g.check(&d.join("new.jpg")).is_ok(), "a free name");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
+522
-65
@@ -11,7 +11,11 @@
|
||||
//!
|
||||
//! Safety:
|
||||
//! - a target that exists on disk, or that another photo in the batch gets, receives a `-1`, `-2`…
|
||||
//! suffix — no file is ever overwritten;
|
||||
//! suffix — no file is ever overwritten. Whether an existing target is the photo itself (a
|
||||
//! change of letter case on a case-insensitive volume) is decided by file identity, not by
|
||||
//! comparing names, so on a case-sensitive volume `img_1.jpg` next to `IMG_1.JPG` is a collision
|
||||
//! (issue #95); moves never replace a file even if one appears after the check (hard link +
|
||||
//! unlink where the volume supports it);
|
||||
//! - the moves happen before the catalog changes; if one fails, the ones already done are moved
|
||||
//! back and nothing is committed;
|
||||
//! - the catalog op ([`Op::SetFile`]) is undoable: undo/redo move the files back and forth (see
|
||||
@@ -297,58 +301,251 @@ fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Move `from` to `to` (never over an existing file) together with its sidecars. On error nothing
|
||||
/// is left moved.
|
||||
pub fn move_file(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of
|
||||
/// a case-insensitive or case-sensitive volume (and inject failures).
|
||||
pub(crate) trait MoveFs {
|
||||
/// Something (file, folder, link) answers to `p`.
|
||||
fn exists(&self, p: &Path) -> bool;
|
||||
fn is_file(&self, p: &Path) -> bool;
|
||||
/// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive
|
||||
/// volume). Decided by file identity, never by comparing names: on a case-sensitive volume
|
||||
/// they are two different photos. When in doubt: `false`.
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool;
|
||||
/// Rename `a` to `b`, failing (never replacing) when `b` exists.
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
|
||||
/// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists.
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()>;
|
||||
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
|
||||
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
|
||||
}
|
||||
|
||||
/// The real file system.
|
||||
pub(crate) struct RealFs;
|
||||
|
||||
impl MoveFs for RealFs {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
std::fs::symlink_metadata(p).is_ok()
|
||||
}
|
||||
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
p.is_file()
|
||||
}
|
||||
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false };
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
ma.dev() == mb.dev() && ma.ino() == mb.ino()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// no stable file id in std here: `b` is `a` under another spelling when they look
|
||||
// alike and the folder has no entry spelled exactly like `b` (on a case-sensitive
|
||||
// folder holding both `IMG.JPG` and `img.jpg`, both entries are listed)
|
||||
let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false };
|
||||
if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
|
||||
return false;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return false };
|
||||
!entries.flatten().any(|e| e.file_name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
|
||||
}
|
||||
// A hard link fails when `b` exists — even if it appeared since the check above — so
|
||||
// nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back
|
||||
// to a plain rename after the check.
|
||||
match std::fs::hard_link(a, b) {
|
||||
Ok(()) => std::fs::remove_file(a).inspect_err(|_| {
|
||||
// both names are the same file: dropping the new one loses nothing
|
||||
let _ = std::fs::remove_file(b);
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
|
||||
Err(_) => std::fs::rename(a, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?;
|
||||
f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(b);
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
std::fs::remove_file(p)
|
||||
}
|
||||
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
crate::import_move::sibling_photo(sidecar)
|
||||
}
|
||||
}
|
||||
|
||||
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
|
||||
/// volume): the rename must go through a temporary name.
|
||||
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
|
||||
from != to && from.to_string_lossy().to_lowercase() == to.to_string_lossy().to_lowercase() && fs.exists(to) && fs.same_file(from, to)
|
||||
}
|
||||
|
||||
/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a
|
||||
/// unique temporary name so case-insensitive volumes see a real change.
|
||||
fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if !is_respelling(fs, a, b) {
|
||||
return fs.rename_no_replace(a, b);
|
||||
}
|
||||
let tmp = (0..1000u32)
|
||||
.map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id())))
|
||||
.find(|t| !fs.exists(t))
|
||||
.ok_or_else(|| std::io::Error::other("no free temporary name"))?;
|
||||
fs.rename_no_replace(a, &tmp)?;
|
||||
fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) {
|
||||
Ok(()) => e,
|
||||
Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Why [`move_file`] failed, and what it left behind.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MoveError {
|
||||
/// What went wrong, including every step that could not be undone and where that file is now.
|
||||
pub message: String,
|
||||
/// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file
|
||||
/// back failed too. Its sidecars that did move stay with it.
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MoveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the
|
||||
/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]).
|
||||
pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
move_file_with(&RealFs, from, to)
|
||||
}
|
||||
|
||||
/// [`move_file`] on the file system `fs`.
|
||||
pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
let fail = |message: String| MoveError { message, moved: false };
|
||||
let (f, t) = (Path::new(from), Path::new(to));
|
||||
// a case-only change on a case-insensitive file system "exists" already: that is this file
|
||||
let case_only = from.to_lowercase() == to.to_lowercase();
|
||||
if t.exists() && !case_only {
|
||||
return Err(format!("{to} already exists"));
|
||||
// `to` may be this very file spelled differently (case-insensitive volume); any other file
|
||||
// there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced
|
||||
if fs.exists(t) && !is_respelling(fs, f, t) {
|
||||
return Err(fail(format!("{to} already exists")));
|
||||
}
|
||||
let mut done: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||
let mv = |a: &Path, b: &Path| -> std::io::Result<()> {
|
||||
if case_only {
|
||||
// via a temporary name, so case-insensitive file systems see a real change
|
||||
let tmp = a.with_file_name(format!(".lc-rename-{}", std::process::id()));
|
||||
std::fs::rename(a, &tmp)?;
|
||||
std::fs::rename(&tmp, b).inspect_err(|_| {
|
||||
let _ = std::fs::rename(&tmp, a);
|
||||
})
|
||||
} else {
|
||||
std::fs::rename(a, b)
|
||||
}
|
||||
};
|
||||
mv(f, t).map_err(|e| format!("rename {from}: {e}"))?;
|
||||
done.push((f.to_path_buf(), t.to_path_buf()));
|
||||
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
|
||||
// sidecars moved (or copied: `true`)
|
||||
let mut done: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
|
||||
for (sc, naming, upper) in sidecars(from) {
|
||||
if !sc.is_file() {
|
||||
if !fs.is_file(&sc) {
|
||||
continue;
|
||||
}
|
||||
let mut dst = crate::sidecar::sidecar_path(to, naming);
|
||||
if upper {
|
||||
dst = dst.with_extension("XMP");
|
||||
}
|
||||
if dst.exists() && !case_only {
|
||||
// a stem sidecar another file still uses (`IMG_1.xmp` of `IMG_1.CR3` and `IMG_1.JPG`)
|
||||
// is copied, not taken away from it (issue #92)
|
||||
let shared = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
|
||||
if fs.exists(&dst) && (shared || !is_respelling(fs, &sc, &dst)) {
|
||||
continue; // never overwrite: the old sidecar stays where it was
|
||||
}
|
||||
if let Err(e) = mv(&sc, &dst) {
|
||||
for (a, b) in done.iter().rev() {
|
||||
let _ = std::fs::rename(b, a);
|
||||
let r = if shared { fs.copy_no_replace(&sc, &dst) } else { move_one(fs, &sc, &dst) };
|
||||
if let Err(e) = r {
|
||||
let mut message = format!("rename {}: {e}", sc.display());
|
||||
// the file first: if it can't go back, the sidecars that moved stay with it
|
||||
if let Err(e) = move_one(fs, t, f) {
|
||||
message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}"));
|
||||
if !done.is_empty() {
|
||||
let names: Vec<String> = done.iter().map(|(_, b, _)| b.display().to_string()).collect();
|
||||
message.push_str(&format!(" with its sidecar {}", names.join(", ")));
|
||||
}
|
||||
return Err(MoveError { message, moved: true });
|
||||
}
|
||||
return Err(format!("rename {}: {e}", sc.display()));
|
||||
for (a, b, copied) in done.iter().rev() {
|
||||
if *copied {
|
||||
// the original is still there: the copy just goes (a leftover copy loses nothing)
|
||||
let _ = fs.remove_file(b);
|
||||
} else if let Err(e) = move_one(fs, b, a) {
|
||||
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", a.display(), b.display()));
|
||||
}
|
||||
}
|
||||
return Err(fail(message));
|
||||
}
|
||||
done.push((sc, dst));
|
||||
done.push((sc, dst, shared));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a failed batch of moves ([`move_all`]) left behind.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BatchMoveError {
|
||||
/// The first failure and every move that could not be undone.
|
||||
pub message: String,
|
||||
/// Moves that stayed done, (from, to): those files are at `to` now.
|
||||
pub stuck: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone.
|
||||
/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored.
|
||||
pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> {
|
||||
let mut done: Vec<&(String, String)> = Vec::new();
|
||||
for m in moves {
|
||||
let Err(e) = move_file_with(fs, &m.0, &m.1) else {
|
||||
done.push(m);
|
||||
continue;
|
||||
};
|
||||
let mut message = e.message;
|
||||
let mut stuck = Vec::new();
|
||||
if e.moved {
|
||||
stuck.push(m.clone());
|
||||
}
|
||||
for d in done.iter().rev() {
|
||||
if let Err(e) = move_file_with(fs, &d.1, &d.0) {
|
||||
message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message));
|
||||
if !e.moved {
|
||||
stuck.push((*d).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(BatchMoveError { message, stuck });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `SetFile` ops in `op` (nested batches included) that point a photo at one of `to`.
|
||||
fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
|
||||
match op {
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| set_file_ops_to(o, to, out)),
|
||||
Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str()) => out.push(op.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
|
||||
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
self.plan_rename_with(&RealFs, ids, template, start)
|
||||
}
|
||||
|
||||
/// [`Session::plan_rename`] on the file system `fs`.
|
||||
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
// photos sharing one file (virtual copies) rename once
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
@@ -382,10 +579,12 @@ impl Session {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
let same = key == path.to_lowercase();
|
||||
// this very file, maybe spelled differently (case-insensitive volume)? By
|
||||
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
|
||||
let same = tp == *path || is_respelling(fs, Path::new(path), Path::new(&tp));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
let on_disk = Path::new(&tp).exists() && !same;
|
||||
let on_disk = fs.exists(Path::new(&tp)) && !same;
|
||||
if !taken.contains(&key) && !on_disk {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
@@ -415,21 +614,16 @@ impl Session {
|
||||
|
||||
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
|
||||
/// that updates the photos (and their virtual copies).
|
||||
///
|
||||
/// When a failure can't be fully rolled back, the files that kept their new names are
|
||||
/// committed as a smaller undoable rename, so the library points at them (nothing goes
|
||||
/// missing), and the error lists them (old → new).
|
||||
pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result<usize> {
|
||||
let mut moved: Vec<(String, String)> = Vec::new();
|
||||
for pl in plans {
|
||||
if let (Some(a), Some(b)) = (&pl.from_path, &pl.to_path)
|
||||
&& a != b
|
||||
{
|
||||
if let Err(e) = move_file(a, b) {
|
||||
for (a, b) in moved.iter().rev() {
|
||||
let _ = move_file(b, a);
|
||||
}
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
moved.push((a.clone(), b.clone()));
|
||||
}
|
||||
}
|
||||
self.apply_rename_with(&RealFs, plans)
|
||||
}
|
||||
|
||||
/// [`Session::apply_rename`] on the file system `fs`.
|
||||
pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result<usize> {
|
||||
let mut ops = Vec::new();
|
||||
let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect();
|
||||
for p in self.catalog.photos() {
|
||||
@@ -447,17 +641,66 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
let op = Op::Batch { ops };
|
||||
let moves: Vec<(String, String)> = plans
|
||||
.iter()
|
||||
.filter_map(|pl| match (&pl.from_path, &pl.to_path) {
|
||||
(Some(a), Some(b)) if a != b => Some((a.clone(), b.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = move_all(fs, &moves) {
|
||||
return Err(EngineError::Other(self.follow_stuck(&op, e, true)));
|
||||
}
|
||||
if n > 0
|
||||
&& let Err(e) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
&& let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op)
|
||||
{
|
||||
for (a, b) in moved.iter().rev() {
|
||||
let _ = move_file(b, a);
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
if let Err(e) = move_all(fs, &back) {
|
||||
return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message)));
|
||||
}
|
||||
return Err(e);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// After a failed batch of moves for `op`: point the photos whose files stayed moved at
|
||||
/// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged
|
||||
/// change), so the library matches the disk. Returns the message for the user.
|
||||
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> String {
|
||||
let mut msg = e.message;
|
||||
if e.stuck.is_empty() {
|
||||
return msg;
|
||||
}
|
||||
let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect();
|
||||
let mut keep = Vec::new();
|
||||
set_file_ops_to(op, &to, &mut keep);
|
||||
let list: Vec<String> = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect();
|
||||
let n = e.stuck.len();
|
||||
let s = if n == 1 { "" } else { "s" };
|
||||
let fix = Op::Batch { ops: keep };
|
||||
let applied = if undoable {
|
||||
self.commit(&format!("Rename {n} Photo{s} (partly)"), fix)
|
||||
} else {
|
||||
match self.catalog.apply(fix.clone()) {
|
||||
Ok(_) => {
|
||||
self.pending_log.push(fix);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
};
|
||||
match applied {
|
||||
Ok(()) => msg
|
||||
.push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))),
|
||||
Err(err) => msg.push_str(&format!(
|
||||
". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}",
|
||||
list.join(", ")
|
||||
)),
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// File moves an undo/redo op implies: `SetFile` ops whose source path differs from the photo's
|
||||
/// current one.
|
||||
pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> {
|
||||
@@ -479,26 +722,240 @@ impl Session {
|
||||
rec(self, op, &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Move the files `moves` names; all or nothing.
|
||||
pub(crate) fn move_files(moves: &[(String, String)]) -> Result<()> {
|
||||
let mut done: Vec<&(String, String)> = Vec::new();
|
||||
for m in moves {
|
||||
if let Err(e) = move_file(&m.0, &m.1) {
|
||||
for (a, b) in done.iter().rev().map(|m| (&m.0, &m.1)) {
|
||||
let _ = move_file(b, a);
|
||||
}
|
||||
return Err(EngineError::Other(format!("can't undo the rename: {e}")));
|
||||
}
|
||||
done.push(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or
|
||||
/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail.
|
||||
pub(crate) struct FakeFs {
|
||||
pub ci: bool,
|
||||
pub files: RefCell<Vec<(String, Vec<u8>)>>,
|
||||
pub fail: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
pub fn new(ci: bool, files: &[(&str, &str)]) -> FakeFs {
|
||||
FakeFs {
|
||||
ci,
|
||||
files: RefCell::new(files.iter().map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())).collect()),
|
||||
fail: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn idx(&self, p: &Path) -> Option<usize> {
|
||||
let p = p.to_string_lossy();
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { f.to_lowercase() == p.to_lowercase() } else { *f == p })
|
||||
}
|
||||
/// The listing: (exact path, contents), sorted.
|
||||
pub fn listing(&self) -> Vec<(String, String)> {
|
||||
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.clone(), String::from_utf8_lossy(c).to_string())).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl MoveFs for FakeFs {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
self.idx(p).is_some()
|
||||
}
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
self.exists(p)
|
||||
}
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
self.idx(a).is_some() && self.idx(a) == self.idx(b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| a.to_string_lossy().contains(f.as_str())) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut()[i].0 = b.to_string_lossy().to_string();
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
let c = self.files.borrow()[i].1.clone();
|
||||
self.files.borrow_mut().push((b.to_string_lossy().to_string(), c));
|
||||
Ok(())
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
let i = self.idx(p).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut().remove(i);
|
||||
Ok(())
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = sidecar.with_extension("").to_string_lossy().to_string();
|
||||
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
|
||||
let p = Path::new(f);
|
||||
p.with_extension("").to_string_lossy() == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_photo(id: u64, path: &str) -> Photo {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never
|
||||
/// replaces a different file that differs only by case on a case-sensitive one.
|
||||
#[test]
|
||||
fn case_only_renames_never_replace_another_file() {
|
||||
// case-sensitive: IMG_1.JPG and img_1.JPG are two photos
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_1.JPG", "first"), ("/p/img_1.JPG", "second"), ("/p/IMG_1.xmp", "sc1"), ("/p/img_1.xmp", "sc2")]);
|
||||
assert!(move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap_err().message.contains("already exists"));
|
||||
assert_eq!(fs.listing().len(), 4);
|
||||
assert!(fs.listing().contains(&("/p/img_1.JPG".into(), "second".into())));
|
||||
// the planner picks a free name instead
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1);
|
||||
assert_eq!(plans[0].to, "img_1-1.JPG");
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", plans[0].to_path.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/img_1-1.JPG".into(), "first".into()),
|
||||
("/p/img_1-1.xmp".into(), "sc1".into()),
|
||||
("/p/img_1.JPG".into(), "second".into()),
|
||||
("/p/img_1.xmp".into(), "sc2".into())
|
||||
]
|
||||
);
|
||||
// case-sensitive, target free: a plain rename
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_2.JPG", "x")]);
|
||||
move_file_with(&fs, "/p/IMG_2.JPG", "/p/img_2.JPG").unwrap();
|
||||
assert_eq!(fs.listing(), vec![("/p/img_2.JPG".into(), "x".into())]);
|
||||
|
||||
// case-insensitive: the "existing" target is the photo itself → renamed via a temp name
|
||||
let fs = FakeFs::new(true, &[("/p/IMG_1.JPG", "first"), ("/p/IMG_1.xmp", "sc1")]);
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1);
|
||||
assert_eq!(plans[0].to, "img_1.JPG", "not a collision with itself");
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap();
|
||||
assert_eq!(fs.listing(), vec![("/p/img_1.JPG".into(), "first".into()), ("/p/img_1.xmp".into(), "sc1".into())]);
|
||||
// case-insensitive, another file under another name: still a collision
|
||||
let fs = FakeFs::new(true, &[("/p/A.JPG", "a"), ("/p/b.JPG", "b")]);
|
||||
assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err());
|
||||
}
|
||||
|
||||
/// Issue #105: a rollback that fails is reported, and the library follows the files that kept
|
||||
/// their new names (an undoable partial rename), so nothing goes missing.
|
||||
#[test]
|
||||
fn failed_rollbacks_are_reported_and_followed() {
|
||||
let fs = FakeFs::new(false, &[("/p/a.jpg", "A"), ("/p/a.xmp", "As"), ("/p/b.jpg", "B"), ("/p/c.jpg", "C")]);
|
||||
let mut s = Session::new();
|
||||
for (i, f) in ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg"].iter().enumerate() {
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(i as u64 + 1, f)) }).unwrap();
|
||||
}
|
||||
let ids = [PhotoId(1), PhotoId(2), PhotoId(3)];
|
||||
let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1);
|
||||
// c can't be renamed (the share went away), and neither can Trip-1 be moved back
|
||||
fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]);
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string();
|
||||
assert!(err.contains("rename /p/c.jpg"), "{err}");
|
||||
assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}");
|
||||
assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}");
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/Trip-1.jpg".into(), "A".into()),
|
||||
("/p/Trip-1.xmp".into(), "As".into()),
|
||||
("/p/b.jpg".into(), "B".into()),
|
||||
("/p/c.jpg".into(), "C".into())
|
||||
]
|
||||
);
|
||||
// the catalog matches the disk
|
||||
let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source {
|
||||
Source::File { path } => path.clone(),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
assert_eq!(path(&s, 1), "/p/Trip-1.jpg");
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().file_name, "Trip-1.jpg");
|
||||
assert_eq!((path(&s, 2), path(&s, 3)), ("/p/b.jpg".into(), "/p/c.jpg".into()));
|
||||
assert_eq!(s.undo.last().map(|e| e.label.as_str()), Some("Rename 1 Photo (partly)"));
|
||||
|
||||
// a sidecar that won't move, and a file that won't go back: the file keeps its new name
|
||||
// with the sidecars that did move, and says so
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]);
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(e.moved, "{e}");
|
||||
assert!(e.message.contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}");
|
||||
assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]);
|
||||
// … and when the file does go back, its sidecars follow it
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
fs.fail.borrow_mut().push("d.jpg.xmp".to_string());
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(!e.moved);
|
||||
assert_eq!(fs.listing().len(), 3);
|
||||
assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d.")));
|
||||
}
|
||||
|
||||
/// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared
|
||||
/// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it.
|
||||
#[test]
|
||||
fn shared_stem_sidecar_is_copied_not_taken() {
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_1.CR3", "raw"), ("/p/IMG_1.JPG", "jpg"), ("/p/IMG_1.xmp", "shared"), ("/p/IMG_1.CR3.xmp", "own")]);
|
||||
move_file_with(&fs, "/p/IMG_1.CR3", "/p/Trip_1.CR3").unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/IMG_1.JPG".into(), "jpg".into()),
|
||||
("/p/IMG_1.xmp".into(), "shared".into()),
|
||||
("/p/Trip_1.CR3".into(), "raw".into()),
|
||||
("/p/Trip_1.CR3.xmp".into(), "own".into()),
|
||||
("/p/Trip_1.xmp".into(), "shared".into()),
|
||||
]
|
||||
);
|
||||
// the JPEG is the last one using it: it moves
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", "/p/Trip_2.JPG").unwrap();
|
||||
assert!(fs.listing().contains(&("/p/Trip_2.xmp".into(), "shared".into())));
|
||||
assert!(!fs.exists(Path::new("/p/IMG_1.xmp")));
|
||||
// a failure after the copy removes the copy and keeps the original
|
||||
let fs = FakeFs::new(false, &[("/p/A.CR3", "raw"), ("/p/A.JPG", "jpg"), ("/p/A.xmp", "shared"), ("/p/A.CR3.xmp", "own")]);
|
||||
fs.fail.borrow_mut().push("A.CR3.xmp".into());
|
||||
assert!(move_file_with(&fs, "/p/A.CR3", "/p/B.CR3").is_err());
|
||||
assert_eq!(fs.listing().len(), 4);
|
||||
assert!(fs.listing().contains(&("/p/A.xmp".into(), "shared".into())) && !fs.exists(Path::new("/p/B.xmp")));
|
||||
}
|
||||
|
||||
/// Issue #95 on the real disk, whichever kind of volume the temp folder is on.
|
||||
#[test]
|
||||
fn case_only_rename_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-rename-case-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (upper, lower) = (dir.join("IMG_1.JPG"), dir.join("img_1.JPG"));
|
||||
std::fs::write(&upper, b"first").unwrap();
|
||||
let case_sensitive = !lower.exists();
|
||||
let p = |q: &Path| q.to_string_lossy().to_string();
|
||||
if case_sensitive {
|
||||
std::fs::write(&lower, b"second").unwrap();
|
||||
assert!(move_file(&p(&upper), &p(&lower)).is_err());
|
||||
assert_eq!(std::fs::read(&lower).unwrap(), b"second");
|
||||
assert_eq!(std::fs::read(&upper).unwrap(), b"first");
|
||||
} else {
|
||||
move_file(&p(&upper), &p(&lower)).unwrap();
|
||||
let names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert_eq!(names, vec!["img_1.JPG".to_string()]);
|
||||
assert_eq!(std::fs::read(&lower).unwrap(), b"first");
|
||||
}
|
||||
// a plain rename never replaces
|
||||
std::fs::write(dir.join("other.JPG"), b"other").unwrap();
|
||||
assert!(move_file(&p(&dir.join("other.JPG")), &p(&lower)).is_err());
|
||||
assert_eq!(std::fs::read(dir.join("other.JPG")).unwrap(), b"other");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_templates_stay_inside_the_destination() {
|
||||
|
||||
+162
-13
@@ -5,12 +5,25 @@
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//!
|
||||
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
|
||||
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
|
||||
//! everything else — another editor's `crs:` develop settings, `xmpMM` history, unknown
|
||||
//! namespaces — is kept byte for byte. A sidecar that can't be read as XMP is first copied to
|
||||
//! `<name>.xmp.bak-<time>` (never overwritten), then replaced. Writes are atomic (temp file,
|
||||
//! fsync, rename).
|
||||
//!
|
||||
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
|
||||
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
|
||||
//! the first by file name — [`Session::sidecar_naming`]); the others use `<file>.xmp`
|
||||
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
|
||||
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
|
||||
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
|
||||
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
|
||||
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -258,13 +271,99 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
lightcraft_meta::write_xmp_lc(&meta, &lc)
|
||||
}
|
||||
|
||||
/// Write `data` to `path` atomically (temp file + rename).
|
||||
/// The properties LightCraft writes and owns in a sidecar: replaced on every save (removed when
|
||||
/// LightCraft has no value, so clearing a field clears it in the file too). All of them are read
|
||||
/// back on import and by Read Metadata from File.
|
||||
pub const OWNED: &[&str] = &[
|
||||
"xmp:Rating",
|
||||
"xmp:Label",
|
||||
"dc:title",
|
||||
"dc:description",
|
||||
"dc:rights",
|
||||
"dc:creator",
|
||||
"dc:subject",
|
||||
"Iptc4xmpCore:Location",
|
||||
"Iptc4xmpCore:AltTextAccessibility",
|
||||
"Iptc4xmpCore:ExtDescrAccessibility",
|
||||
"photoshop:City",
|
||||
"photoshop:State",
|
||||
"photoshop:Country",
|
||||
"xmpRights:Marked",
|
||||
"xmpRights:UsageTerms",
|
||||
"xmpRights:WebStatement",
|
||||
"lc:*",
|
||||
];
|
||||
|
||||
/// Properties replaced only when LightCraft has a value (capture time; GPS, which LightCraft
|
||||
/// doesn't read from sidecars): otherwise the sidecar's own value stays. Anything else LightCraft
|
||||
/// writes (`xmp:CreatorTool`) goes only into new sidecars.
|
||||
pub const OWNED_IF_STATED: &[&str] = &["exif:DateTimeOriginal", "photoshop:DateCreated", "exif:GPSLatitude", "exif:GPSLongitude"];
|
||||
|
||||
/// Write `data` to `path` atomically: a temp file next to it, synced, then renamed over it.
|
||||
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension(format!("xmp.tmp-{}", std::process::id()));
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
lightcraft_catalog::safe_file::write_atomic(path, data)
|
||||
}
|
||||
|
||||
/// Copy `path` to a new `<name>.bak-<stamp>` (`-1`, `-2`… if taken; never overwriting).
|
||||
fn backup(path: &Path, stamp: &str) -> std::io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(path)?;
|
||||
let stamp: String = stamp.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
for k in 0..1000 {
|
||||
let b = path.with_file_name(if k == 0 { format!("{name}.bak-{stamp}") } else { format!("{name}.bak-{stamp}-{k}") });
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&b) {
|
||||
Ok(mut f) => {
|
||||
f.write_all(&bytes)?;
|
||||
f.sync_all()?;
|
||||
return Ok(b);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::other("no free backup name"))
|
||||
}
|
||||
|
||||
/// What [`Session::save_sidecar`] did.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SidecarSaved {
|
||||
pub path: PathBuf,
|
||||
/// An existing sidecar was merged into (its other contents kept).
|
||||
pub merged: bool,
|
||||
/// An existing sidecar that couldn't be read as XMP was copied here before being replaced.
|
||||
pub backup: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The new contents for the sidecar at `path`: `fresh` merged into the existing file, if any.
|
||||
/// Returns (contents, merged, backup).
|
||||
fn sidecar_contents(path: &Path, fresh: String, stamp: &str) -> std::io::Result<(String, bool, Option<PathBuf>)> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((fresh, false, None)),
|
||||
// can't see what is there: never overwrite it blindly
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Ok((fresh, false, None));
|
||||
}
|
||||
let rules = lightcraft_meta::MergeRules { owned: OWNED, owned_if_present: OWNED_IF_STATED };
|
||||
match std::str::from_utf8(&bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|old| lightcraft_meta::merge_xmp(old, &fresh, rules).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(merged) => Ok((merged, true, None)),
|
||||
Err(why) => {
|
||||
let b = backup(path, stamp)?;
|
||||
log::warn!("{}: not readable as XMP ({why}); kept a copy as {} before replacing it", path.display(), b.display());
|
||||
Ok((fresh, false, Some(b)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the stem sidecar a file maps to (case-insensitive).
|
||||
fn stem_key(original: &str) -> String {
|
||||
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
|
||||
}
|
||||
|
||||
fn file_path(p: &Photo) -> Option<&str> {
|
||||
@@ -302,24 +401,70 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which photo owns each stem sidecar that several catalogued files map to (see
|
||||
/// [`Session::sidecar_naming`]).
|
||||
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
|
||||
|
||||
impl StemOwners {
|
||||
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
(e.0, e.1) = (rank, p.id);
|
||||
}
|
||||
}
|
||||
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
|
||||
}
|
||||
|
||||
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
|
||||
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
|
||||
match (naming, file_path(p)) {
|
||||
(SidecarNaming::Stem, Some(path)) if self.0.get(&stem_key(path)).is_some_and(|owner| *owner != p.id) => SidecarNaming::Full,
|
||||
_ => naming,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Write the photo's XMP sidecar; returns its path.
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
|
||||
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<SidecarSaved> {
|
||||
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
|
||||
}
|
||||
|
||||
pub(crate) fn save_sidecar_with(&self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
if p.copy_of.is_some() {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
let path = sidecar_path(orig, self.xmp.naming);
|
||||
write_atomic(&path, sidecar_packet(p, &self.catalog).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
let path = sidecar_path(orig, owners.naming(p, self.xmp.naming));
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
|
||||
let (data, merged, backup) = sidecar_contents(&path, sidecar_packet(p, &self.catalog), &(self.clock)()).map_err(err)?;
|
||||
write_atomic(&path, data.as_bytes()).map_err(err)?;
|
||||
Ok(SidecarSaved { path, merged, backup })
|
||||
}
|
||||
|
||||
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
|
||||
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.xmp.naming) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
@@ -344,9 +489,13 @@ impl Session {
|
||||
pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) {
|
||||
let mut ids = Vec::new();
|
||||
ops.iter().for_each(|o| op_photos(o, &mut ids));
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let owners = StemOwners::of(&self.catalog);
|
||||
for id in ids {
|
||||
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
|
||||
&& let Err(e) = self.save_sidecar(id)
|
||||
&& let Err(e) = self.save_sidecar_with(id, &owners)
|
||||
{
|
||||
log::warn!("auto-write XMP: {e}");
|
||||
}
|
||||
|
||||
@@ -130,11 +130,16 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
Existing::Discard => std::fs::remove_file(&src),
|
||||
_ => {
|
||||
let dst = to.join(e.file_name());
|
||||
if dst.exists() {
|
||||
// named by content: the copy already there is the same preview
|
||||
if is_valid(&dst) {
|
||||
// named by content: the complete copy already there is the same preview
|
||||
std::fs::remove_file(&src)
|
||||
} else {
|
||||
std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src)))
|
||||
// across drives: copied atomically (never a partial proxy at the new place)
|
||||
std::fs::rename(&src, &dst).or_else(|_| {
|
||||
std::fs::read(&src)
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
|
||||
.and_then(|()| std::fs::remove_file(&src))
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -146,6 +151,29 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
/// A cheap check that the smart preview at `path` is complete: our header, then a JPEG from its
|
||||
/// start marker to its end marker. A file cut short by a crash, a full drive or an unplugged one
|
||||
/// fails it (and is then rebuilt by Build Smart Previews, not counted as there).
|
||||
pub fn is_valid(path: &Path) -> bool {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let Ok(mut f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::with_capacity(256);
|
||||
if (&mut f).take(256).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
|
||||
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
|
||||
let size_ok = rest
|
||||
.get(..nl)
|
||||
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
|
||||
.is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0));
|
||||
if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) {
|
||||
return false;
|
||||
}
|
||||
let mut end = [0u8; 2];
|
||||
f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
@@ -193,6 +221,27 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() {
|
||||
let base = temp("valid");
|
||||
let (a, b) = (base.join("a"), base.join("b"));
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let good = encode(&img).unwrap();
|
||||
std::fs::write(a.join("1.lcsp"), &good).unwrap();
|
||||
assert!(is_valid(&a.join("1.lcsp")));
|
||||
for cut in [good.len() - 1, good.len() / 2, 10, 0] {
|
||||
std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap();
|
||||
assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}");
|
||||
}
|
||||
assert!(!is_valid(&b.join("missing.lcsp")));
|
||||
// moving onto a damaged copy replaces it instead of deleting the good one
|
||||
assert_eq!(migrate(&a, &b, Existing::Move).0, 1);
|
||||
assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
|
||||
@@ -207,3 +207,119 @@ fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
|
||||
assert!(m > 0.02, "not black: mean {m}");
|
||||
assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}");
|
||||
}
|
||||
|
||||
/// A JPEG original `dir/IMG_1.jpg` imported into a library on disk.
|
||||
fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::path::PathBuf, std::path::PathBuf, Vec<u8>) {
|
||||
let dir = temp_dir(tag);
|
||||
let src = dir.join("IMG_1.jpg");
|
||||
let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, 120, 255]);
|
||||
let bytes = crate::export::encode_image(&img, &ExportOptions::default()).unwrap();
|
||||
std::fs::write(&src, &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap();
|
||||
(s, id, dir, src, bytes)
|
||||
}
|
||||
|
||||
fn disk_batch(
|
||||
s: &mut Session,
|
||||
id: lightcraft_catalog::PhotoId,
|
||||
o: &ExportOptions,
|
||||
to: &crate::export::Destination,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
crate::export::export_batch(s, &[id], o, to, &mut crate::export::write_file, &|p| std::path::Path::new(p).exists())
|
||||
}
|
||||
|
||||
/// Issue #93: exporting into the photo's own folder as `{name}` with "Overwrite" replaced the
|
||||
/// original with the re-encoded render. It is refused now, whatever the policy or exact path.
|
||||
#[test]
|
||||
fn export_never_overwrites_an_original() {
|
||||
use crate::export::{Conflict, Destination};
|
||||
let (mut s, id, dir, src, original) = library_with_jpeg("export-guard");
|
||||
let folder = Destination { dir: dir.to_string_lossy().to_string(), exact: None };
|
||||
let o = ExportOptions { naming: "{name}".into(), conflict: Conflict::Overwrite, ..Default::default() };
|
||||
let err = disk_batch(&mut s, id, &o, &folder).unwrap_err();
|
||||
assert!(err.contains("original of IMG_1.jpg"), "{err}");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original, "the original is byte-identical");
|
||||
|
||||
// the exact path of the control channel / MCP, also spelled another way
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
for exact in [src.to_string_lossy().to_string(), dir.join("sub/../IMG_1.jpg").to_string_lossy().to_string()] {
|
||||
let to = Destination { dir: String::new(), exact: Some(exact) };
|
||||
assert!(disk_batch(&mut s, id, &o, &to).unwrap_err().contains("never writes over an original"));
|
||||
}
|
||||
// the "Original" format re-writing the file onto itself (and its sidecar)
|
||||
let orig = ExportOptions { format: ExportFormat::Original, ..o.clone() };
|
||||
assert!(disk_batch(&mut s, id, &orig, &folder).is_err());
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||||
assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err());
|
||||
assert!(s.execute("export.checkTarget", &json!({"path": dir.join("free.jpg").to_string_lossy()})).is_ok());
|
||||
|
||||
// Unique moves past it; Overwrite still replaces an ordinary earlier export
|
||||
let unique = ExportOptions { conflict: Conflict::Unique, ..o.clone() };
|
||||
let files = disk_batch(&mut s, id, &unique, &folder).unwrap();
|
||||
assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}");
|
||||
let earlier = dir.join("IMG_1-2.jpg");
|
||||
std::fs::write(&earlier, b"an earlier export").unwrap();
|
||||
let named = ExportOptions { naming: "{name}-2".into(), ..o };
|
||||
disk_batch(&mut s, id, &named, &folder).unwrap();
|
||||
assert_ne!(std::fs::read(&earlier).unwrap(), b"an earlier export", "an ordinary file is overwritten");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #93: the XMP sidecar of an "Original" export ignored the conflict policy.
|
||||
#[test]
|
||||
fn export_sidecars_follow_the_conflict_policy() {
|
||||
use crate::export::{Conflict, Destination};
|
||||
let (mut s, id, dir, _src, original) = library_with_jpeg("export-sidecar");
|
||||
let out = dir.join("out");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let theirs = out.join("IMG_1.xmp");
|
||||
std::fs::write(&theirs, b"someone else's sidecar").unwrap();
|
||||
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
|
||||
let o = ExportOptions { format: ExportFormat::Original, naming: "{name}".into(), ..Default::default() };
|
||||
|
||||
// Unique: the photo and its sidecar both move to the next free name
|
||||
let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Unique, ..o.clone() }, &to).unwrap();
|
||||
assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}");
|
||||
assert!(files[0]["sidecars"][0].as_str().unwrap().ends_with("IMG_1-2.xmp"), "{files:?}");
|
||||
assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar");
|
||||
assert_eq!(std::fs::read(out.join("IMG_1-2.jpg")).unwrap(), original);
|
||||
// Skip: a taken sidecar name skips the photo
|
||||
std::fs::remove_file(out.join("IMG_1-2.jpg")).unwrap();
|
||||
std::fs::remove_file(out.join("IMG_1-2.xmp")).unwrap();
|
||||
let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Skip, ..o.clone() }, &to).unwrap();
|
||||
assert!(files[0]["skipped"].as_str().is_some(), "{files:?}");
|
||||
assert!(!out.join("IMG_1.jpg").exists());
|
||||
assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar");
|
||||
// Overwrite: replaced, as asked
|
||||
disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Overwrite, ..o }, &to).unwrap();
|
||||
assert!(std::fs::read_to_string(&theirs).unwrap().contains("xmpmeta"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #93: the native writer truncated the target first; a failure mid-write (full disk,
|
||||
/// unplugged drive) now leaves the previous file intact and no partial one.
|
||||
#[test]
|
||||
fn a_failed_export_write_keeps_the_previous_file() {
|
||||
use crate::export::{Conflict, Destination};
|
||||
let (mut s, id, dir, _src, _) = library_with_jpeg("export-midwrite");
|
||||
let out = dir.join("out");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let prev = out.join("IMG_1.jpg");
|
||||
std::fs::write(&prev, b"last week's export").unwrap();
|
||||
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
|
||||
let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() };
|
||||
{
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(100);
|
||||
let err = disk_batch(&mut s, id, &o, &to).unwrap_err();
|
||||
assert!(err.contains("injected"), "{err}");
|
||||
}
|
||||
assert_eq!(std::fs::read(&prev).unwrap(), b"last week's export");
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "no temp file left behind");
|
||||
disk_batch(&mut s, id, &o, &to).unwrap();
|
||||
assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,45 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
|
||||
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
|
||||
#[test]
|
||||
fn find_missing_matches_renamed_files_by_content() {
|
||||
let dir = temp_dir("missing-content");
|
||||
std::fs::create_dir_all(dir.join("old")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("moved/real")).unwrap();
|
||||
for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] {
|
||||
write_png(&dir.join("old").join(n), seed);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
|
||||
// an impostor: same name and size, other bytes
|
||||
let impostor = |src: &Path, dst: &Path| {
|
||||
let mut b = std::fs::read(src).unwrap();
|
||||
let k = b.len() - 20;
|
||||
b[k] ^= 0xff;
|
||||
std::fs::write(dst, b).unwrap();
|
||||
};
|
||||
// a: renamed; b: renamed, with an impostor under its old name; c: gone, two impostors
|
||||
std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap();
|
||||
impostor(&dir.join("old/b.png"), &dir.join("moved/b.png"));
|
||||
std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap();
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/c.png"));
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png"));
|
||||
std::fs::remove_file(dir.join("old/c.png")).unwrap();
|
||||
let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
|
||||
let found = r["found"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 2, "{r}");
|
||||
let to = |name: &str| {
|
||||
found.iter().find(|f| f["from"].as_str().unwrap().ends_with(name)).map(|f| (f["to"].as_str().unwrap().to_string(), f["by"].clone()))
|
||||
};
|
||||
assert_eq!(to("a.png"), Some((dir.join("moved/Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(to("b.png"), Some((dir.join("moved/real/Trip-002.png").to_string_lossy().to_string(), json!("content"))), "not the impostor");
|
||||
assert_eq!(r["missing"], 1);
|
||||
assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_files_are_found_and_relinked() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
|
||||
@@ -352,6 +391,44 @@ fn smart_previews_stand_in_for_offline_originals() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed
|
||||
/// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not
|
||||
/// reported as present and is rebuilt.
|
||||
#[test]
|
||||
fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() {
|
||||
let src = temp_dir("smartdmg-src");
|
||||
let lib = temp_dir("smartdmg-lib");
|
||||
write_png(&src.join("a.png"), 5);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap()));
|
||||
{
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(64);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["failed"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}");
|
||||
}
|
||||
assert!(!file.exists(), "no partial proxy");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no temp file left");
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false);
|
||||
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
assert!(crate::smart::is_valid(&file));
|
||||
// cut short, as by a crash before the fix
|
||||
let full = std::fs::read(&file).unwrap();
|
||||
std::fs::write(&file, &full[..full.len() / 2]).unwrap();
|
||||
assert!(!crate::smart::is_valid(&file));
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false, "not counted as there");
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// LUT profiles: a .cube imports as a creative profile (into the library), renders differently
|
||||
/// (Amount 0 = no change), is listed in the profile browser and comes back with the library.
|
||||
#[test]
|
||||
|
||||
@@ -163,6 +163,50 @@ fn a_failed_or_corrupt_copy_keeps_the_source() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -17,6 +17,8 @@ struct Flaky {
|
||||
fail: Arc<AtomicBool>,
|
||||
/// Only snapshots fail (appends work).
|
||||
snapshots_only: bool,
|
||||
/// Only appends fail (a dead log handle; fresh files can still be written).
|
||||
appends_only: bool,
|
||||
}
|
||||
|
||||
impl Flaky {
|
||||
@@ -39,7 +41,7 @@ impl Store for Flaky {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.failing() {
|
||||
if self.failing() && !self.appends_only {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, data)
|
||||
@@ -145,3 +147,47 @@ fn failed_snapshot_does_not_fail_the_command() {
|
||||
store.set_failing(false);
|
||||
assert_eq!(open(&store, false).catalog.albums().count(), 2);
|
||||
}
|
||||
|
||||
/// Issue #101: quitting while the log can't be appended to still saves the queued changes in the
|
||||
/// closing snapshot (a fresh file), and reports success; they are not appended again later.
|
||||
#[test]
|
||||
fn close_saves_queued_changes_in_the_snapshot_when_appends_fail() {
|
||||
let store = Flaky { appends_only: true, ..Default::default() };
|
||||
let mut s = open(&store, true);
|
||||
let id = s.selection.active.unwrap();
|
||||
store.set_failing(true);
|
||||
assert!(matches!(s.execute("photo.rate", &json!({"rating": 4})), Err(EngineError::NotSaved(_))));
|
||||
assert!(matches!(s.execute("photo.flag", &json!({"flag": "pick"})), Err(EngineError::NotSaved(_))));
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
assert!(s.pending_log.is_empty());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
store.set_failing(false);
|
||||
let mut s2 = open(&store, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let p = s2.catalog.photo(id).unwrap();
|
||||
assert_eq!((p.rating, p.flag), (4, Flag::Pick));
|
||||
// later edits continue after the absorbed ops
|
||||
s2.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||||
let expect = s2.catalog.to_snapshot();
|
||||
drop(s2);
|
||||
assert_eq!(open(&store, true).catalog.to_snapshot(), expect);
|
||||
}
|
||||
|
||||
/// When neither the log nor the snapshot can be written, closing fails with `NotSaved` and the
|
||||
/// changes stay queued (a later retry can still save them).
|
||||
#[test]
|
||||
fn close_fails_when_nothing_can_be_saved() {
|
||||
let store = Flaky::default();
|
||||
let mut s = open(&store, true);
|
||||
store.set_failing(true);
|
||||
assert!(s.execute("photo.rate", &json!({"rating": 4})).is_err());
|
||||
let e = s.close_library().unwrap_err();
|
||||
assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}");
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(1));
|
||||
store.set_failing(false);
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
}
|
||||
|
||||
@@ -329,6 +329,55 @@ fn convert_raw_to_dng() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #106: Convert to DNG wrote straight to the final name (no temp file, sync or check) and
|
||||
/// relinked the photo even when the write was cut short. A failed write now leaves no DNG and
|
||||
/// the photo on its raw; a good one is verified and never replaces an existing file.
|
||||
#[test]
|
||||
fn convert_to_dng_is_verified_and_atomic() {
|
||||
let dir = temp_dir("todng-safe");
|
||||
// a synthetic raw (stored as DNG, catalogued as a NEF so Convert to DNG takes it)
|
||||
let raw = dir.join("shot.dng");
|
||||
let bytes = synthetic_dng_with(None, Default::default());
|
||||
std::fs::write(&raw, &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let path = raw.to_string_lossy().to_string();
|
||||
s.catalog
|
||||
.apply(lightcraft_catalog::Op::Relink {
|
||||
id,
|
||||
file_name: "shot.dng".into(),
|
||||
source: lightcraft_catalog::Source::File { path: path.clone() },
|
||||
format: Some("NEF".into()),
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
let names = || {
|
||||
let mut v: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
{
|
||||
// the drive fills up part-way
|
||||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(200);
|
||||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||||
assert_eq!(r["converted"].as_array().unwrap().len(), 0, "{r}");
|
||||
assert!(r["skipped"][0][1].as_str().unwrap().contains("the raw is kept"), "{r}");
|
||||
}
|
||||
assert_eq!(names(), ["shot.dng"], "no partial DNG");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: path.clone() }, "not relinked");
|
||||
// a good conversion: a new name (the existing file is not replaced), decodable, relinked
|
||||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||||
let out = r["converted"][0]["path"].as_str().unwrap().to_string();
|
||||
assert!(out.ends_with("shot-2.dng"), "{r}");
|
||||
assert_eq!(std::fs::read(&raw).unwrap(), bytes, "the raw is untouched");
|
||||
let back = lightcraft_raw::decode(&std::fs::read(&out).unwrap()).unwrap();
|
||||
assert_eq!(back.data, lightcraft_raw::decode(&bytes).unwrap().data);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: out });
|
||||
assert_eq!(names(), ["shot-2.dng", "shot.dng"], "no temp file left");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to
|
||||
/// the original, added and stacked on top of it; a second one doesn't overwrite the first.
|
||||
#[test]
|
||||
@@ -576,3 +625,132 @@ fn copyright_status_usage_terms_and_url_round_trip() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// A sidecar another raw developer wrote: develop settings in attributes and elements, a
|
||||
/// structured edit history, an unknown namespace.
|
||||
const OTHER_APP_SIDECAR: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Other Toolkit">
|
||||
<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/"
|
||||
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
||||
xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmlns:other="http://example.com/other/1.0/"
|
||||
xmp:Rating="2"
|
||||
crs:Version="99.0"
|
||||
crs:Exposure2012="+0.65"
|
||||
crs:HasSettings="True"
|
||||
other:Secret="keep me">
|
||||
<xmpMM:History>
|
||||
<rdf:Seq>
|
||||
<rdf:li stEvt:action="derived" stEvt:parameters="converted from image/x-raw to image/png"/>
|
||||
</rdf:Seq>
|
||||
</xmpMM:History>
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>128, 140</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>"#;
|
||||
|
||||
/// Issue #92: saving merges into another app's sidecar instead of replacing it.
|
||||
#[test]
|
||||
fn saving_merges_into_another_apps_sidecar() {
|
||||
let src = temp_dir("merge");
|
||||
let lib = temp_dir("merge-lib");
|
||||
write_png(&src.join("shot.png"), 1);
|
||||
std::fs::write(src.join("shot.xmp"), OTHER_APP_SIDECAR).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("shot.png").to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "Harbour", "keywords": ["boats"]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 30})).unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
assert_eq!(r["merged"][0], src.join("shot.xmp").to_string_lossy().as_ref(), "{r}");
|
||||
let out = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||||
// the other app's data, byte for byte
|
||||
for part in [
|
||||
"crs:Exposure2012=\"+0.65\"",
|
||||
"other:Secret=\"keep me\"",
|
||||
" <xmpMM:History>\n <rdf:Seq>\n <rdf:li stEvt:action=\"derived\" stEvt:parameters=\"converted from image/x-raw to image/png\"/>\n </rdf:Seq>\n </xmpMM:History>",
|
||||
" <crs:ToneCurvePV2012>\n <rdf:Seq>\n <rdf:li>0, 0</rdf:li>\n <rdf:li>128, 140</rdf:li>",
|
||||
] {
|
||||
assert!(out.contains(part), "lost {part:?}:\n{out}");
|
||||
}
|
||||
let d = lightcraft_meta::parse_xmp(&out).unwrap();
|
||||
assert_eq!(d.metadata.rating, Some(5), "ours replaces theirs");
|
||||
assert_eq!(d.metadata.title.as_deref(), Some("Harbour"));
|
||||
assert_eq!(d.properties.get("crs:Version"), Some(&vec!["99.0".to_string()]));
|
||||
let saved = s.catalog.photo(id).unwrap().develop.clone();
|
||||
// reading it back restores our settings (lc:settings wins over crs:)
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 0})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop, saved);
|
||||
// saving again keeps one LightCraft description and the other app's data
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let again = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||||
assert_eq!(again.matches("<rdf:Description").count(), 2, "{again}");
|
||||
assert!(again.contains("other:Secret=\"keep me\"") && again.contains("crs:Exposure2012=\"+0.65\""));
|
||||
assert_eq!(lightcraft_meta::parse_xmp(&again).unwrap().metadata.rating, Some(3));
|
||||
|
||||
// a sidecar that isn't XMP is kept as a backup before it is replaced
|
||||
std::fs::write(src.join("shot.xmp"), b"<not xmp").unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let backup = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||||
assert!(backup.contains("shot.xmp.bak-"), "{r}");
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||||
assert_eq!(lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join("shot.xmp")).unwrap()).unwrap().metadata.rating, Some(3));
|
||||
// … and another one never overwrites the first backup
|
||||
std::fs::write(src.join("shot.xmp"), b"<still not xmp").unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let second = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||||
assert_ne!(second, backup);
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||||
assert_eq!(std::fs::read(&second).unwrap(), b"<still not xmp");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #92: two catalogued files with one stem (`IMG_1.jpg` + `IMG_1.png`) don't share a
|
||||
/// Stem-named sidecar: the first by name keeps `IMG_1.xmp`, the other writes and reads
|
||||
/// `IMG_1.png.xmp`.
|
||||
#[test]
|
||||
fn files_sharing_a_stem_get_their_own_sidecars() {
|
||||
let src = temp_dir("stem");
|
||||
let lib = temp_dir("stem-lib");
|
||||
write_png(&src.join("IMG_1.png"), 1);
|
||||
let img = lightcraft_raster::Rgba8::from_fn(40, 24, |x, y| [(x * 6) as u8, (y * 9) as u8, 7, 255]);
|
||||
let jpg =
|
||||
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Jpeg, ..Default::default() }).unwrap();
|
||||
std::fs::write(src.join("IMG_1.jpg"), jpg).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (j, p) = (id(&s, "IMG_1.jpg"), id(&s, "IMG_1.png"));
|
||||
assert_eq!(s.sidecar_naming(j), crate::sidecar::SidecarNaming::Stem);
|
||||
assert_eq!(s.sidecar_naming(p), crate::sidecar::SidecarNaming::Full);
|
||||
s.execute("photo.rate", &json!({"ids": [j.0], "rating": 2})).unwrap();
|
||||
s.execute("photo.rate", &json!({"ids": [p.0], "rating": 5})).unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||||
assert_eq!(r["written"].as_array().unwrap().len(), 2, "{r}");
|
||||
let rating = |f: &str| lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join(f)).unwrap()).unwrap().metadata.rating;
|
||||
assert_eq!(rating("IMG_1.xmp"), Some(2));
|
||||
assert_eq!(rating("IMG_1.png.xmp"), Some(5));
|
||||
// each reads its own back
|
||||
s.execute("photo.rate", &json!({"ids": [j.0, p.0], "rating": 0})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||||
assert_eq!((s.catalog.photo(j).unwrap().rating, s.catalog.photo(p).unwrap().rating), (2, 5));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user