//! XMP sidecar commands: save/read metadata to/from files, sidecar preferences. use lightcraft_catalog::Op; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, has_selection, str_param}; use crate::sidecar::SidecarNaming; use crate::{Result, Session}; fn save(s: &mut Session, p: &Value) -> Result { let mut written = Vec::new(); let mut merged = Vec::new(); let mut backups = Vec::new(); let mut failed = Vec::new(); let ids = s.targets(p)?; let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids); for id in ids { 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, "merged": merged, "backups": backups, "failed": failed})) } fn read(s: &mut Session, p: &Value) -> Result { let mut ops = Vec::new(); let mut read = Vec::new(); let mut failed = Vec::new(); let mut warnings = Vec::new(); for id in s.targets(p)? { match s.read_sidecar_op_with_warnings(id) { Ok(Some((op, from, mut photo_warnings))) => { ops.push(op); read.push(json!({"id": id.0, "from": from.display().to_string()})); warnings.append(&mut photo_warnings); } Ok(None) => failed.push(json!({"id": id.0, "error": "no XMP sidecar or embedded XMP"})), Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})), } } if !ops.is_empty() { s.commit("Read Metadata from File", Op::Batch { ops })?; // Don't auto-write the sidecars just read: that would add our `lc:settings` to one that had // only another app's `crs:` settings, and later reads prefer `lc:settings`, so that app's // next edits would be ignored (issue #934). The next edit, or Save Metadata, writes them. s.skip_auto_write = true; } let mut result = json!({"read": read, "failed": failed}); if !warnings.is_empty() { result["warnings"] = serde_json::to_value(warnings).unwrap_or_default(); } Ok(result) } fn prefs(s: &mut Session, p: &Value) -> Result { if let Some(a) = p.get("autoWrite").and_then(Value::as_bool) { s.xmp.auto_write = a; } if let Some(n) = str_param(p, "naming") { s.xmp.naming = match n { "stem" => SidecarNaming::Stem, "full" => SidecarNaming::Full, other => return Err(bad("library.xmpPreferences", format!("unknown naming `{other}` (stem|full)"))), }; } if p.as_object().is_some_and(|o| !o.is_empty()) { s.save_prefs()?; } Ok(serde_json::to_value(s.xmp).unwrap_or_default()) } pub fn specs() -> Vec { vec![ cmd!( "photo.saveMetadataToFile", "Save Metadata to File", ["Photo"], Some("Cmd+S"), "{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 ), cmd!( "photo.readMetadataFromFile", "Read Metadata from File", ["Photo"], None, "{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}", has_selection, read ), cmd!( "library.xmpPreferences", "XMP Sidecar Preferences", [], None, "{autoWrite?: bool, naming?: stem (IMG_1.xmp, default) | full (IMG_1.CR3.xmp)} → current preferences (saved with the library)", always, prefs ), cmd!( "library.retrySidecars", "Write Waiting XMP Sidecars", [], None, "{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}", always, |s, _| { let written = s.retry_unsaved_sidecars(); Ok(json!({"written": written, "waiting": s.unsaved_sidecars()})) } ), cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| { let on = !s.xmp.auto_write; prefs(s, &json!({"autoWrite": on})) }), ] }