mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 04:57:22 -06:00
Issue #92: XMP: merge into an existing sidecar instead of replacing it
Saving (Cmd+S or auto-write) now merges LightCraft's properties into an existing sidecar (lightcraft_meta::merge_xmp): owned properties (rating, label, dc/IPTC/rights fields, lc:*) are replaced or removed, capture time and GPS only when LightCraft has a value; everything else (crs:, xmpMM:History, unknown namespaces, packet wrapper) is kept byte for byte. The result is re-parsed and checked before it is written (temp + fsync + rename). A sidecar that isn't valid XMP is copied to .xmp.bak-<time> (never overwriting) before it is replaced; one that can't be read at all is left alone and the save fails. Shared stems: when several catalogued files map to one stem sidecar (IMG.CR3 + IMG.JPG), it belongs to the raw (else the first by name) and the others write and read <file>.xmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e99aa61fc4
commit
882a059fac
@@ -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
|
||||
),
|
||||
|
||||
+171
-11
@@ -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,110 @@ 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<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension(format!("xmp.tmp-{}", std::process::id()));
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, path).inspect_err(|_| {
|
||||
let r = (|| {
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)
|
||||
})();
|
||||
if r.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// 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 +412,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 +500,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}");
|
||||
}
|
||||
|
||||
@@ -576,3 +576,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