//! Pushing local judgements out to XMP sidecars, and reloading sidecars a //! person chose to trust by hand. use crate::executors::{self, Executor}; use dr_catalog::Catalog; use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath}; use std::path::PathBuf; use std::sync::mpsc::Receiver; /// TRACES: FR-CAT-13 | NFR-R4 /// One image's ratings, label and keywords, on their way to the `.xmp` /// beside it. /// /// Distinct from [`SidecarWrite`], which amends DarkRoom's own document /// through the cache and the outbox. This is best-effort in the other /// direction: the catalog and the `.drsc` are authoritative, the `.xmp` is a /// courtesy to whatever else reads the folder, and a write that cannot /// happen now is written again — from the catalog, whole — the next time /// anything about the photograph is judged. So nothing is queued. #[derive(Debug, Clone)] pub struct XmpWrite { pub image_path: String, pub record: dr_xmp::Xmp, } /// TRACES: FR-CAT-13 | NFR-R4 /// Write each record into the XMP sidecar beside its image. /// /// An existing sidecar of either spelling is rewritten in place, which is the /// whole point of `dr_xmp::rewrite`: only the properties DarkRoom owns move, /// and another application's settings, comments and namespaces come through /// byte for byte. A photograph with neither gets a new file under Lightroom's /// name. Reported once at the end, as the judgement writes are. pub fn spawn_xmp_writes(conn: Connection, writes: Vec) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); executors::spawn(Executor::Network, "xmp-write", move || { let mut written = 0usize; let mut failed = 0usize; let mut last_error = None; let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { let _ = tx.send(XmpMessage::Finished { written: 0, failed: writes.len(), last_error: Some(e.to_string()), }); return; } }; rt.block_on(async { let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { failed = writes.len(); last_error = Some(e.to_string()); return; } }; for w in &writes { match write_one_xmp(&*backend, w).await { Ok(()) => written += 1, Err(e) => { log::warn!("xmp sidecar for {}: {e}", w.image_path); last_error = Some(e); failed += 1; } } } }); let _ = tx.send(XmpMessage::Finished { written, failed, last_error, }); }); rx } /// The outcome of a batch of XMP writes. #[derive(Debug)] pub enum XmpMessage { Finished { written: usize, failed: usize, last_error: Option, }, } /// Read-modify-write one image's XMP sidecar on the server. pub(super) async fn write_one_xmp(backend: &dyn RemoteBackend, w: &XmpWrite) -> Result<(), String> { let [darktable, lightroom] = crate::xmp_sync::candidate_paths(&w.image_path); // Whichever exists is the one rewritten; neither existing means the // Lightroom spelling is created. An existing file this build cannot // parse is left alone rather than replaced — it is somebody else's // document, and a refusal is recoverable where an overwrite is not. let mut target = lightroom.clone(); let mut existing: Option = None; for path in [&darktable, &lightroom] { if let Ok(bytes) = backend .get(&RemoteId::Path(RemotePath::new(path.clone())), None) .await { if !bytes.is_empty() { target = path.clone(); existing = Some(String::from_utf8_lossy(&bytes).into_owned()); break; } } } let text = match existing { Some(text) => { // The file's caption, copyright and hierarchy come through: the // catalog has nowhere to keep them, and a rewrite that said // nothing about them would remove them. let theirs = dr_xmp::Xmp::parse(&text) .map_err(|e| format!("{target} is not a sidecar this build can read: {e}"))?; let mut record = w.record.clone(); crate::xmp_sync::carry_through(&mut record, &theirs); record .rewrite(&text) .map_err(|e| format!("{target} is not a sidecar this build can rewrite: {e}"))? } None => w.record.to_text(), }; backend .put(&RemotePath::new(target), text.into_bytes(), None) .await .map(|_| ()) .map_err(|e| e.to_string()) } /// TRACES: FR-CAT-13 /// The offered reload: re-read the sidecars a person chose to trust, with /// the sidecar winning. One fetch per path, the catalog opened on this /// thread as the scan opens it. pub fn spawn_xmp_reload( conn: Connection, root: String, catalog_path: PathBuf, paths: Vec, ) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); executors::spawn(Executor::Network, "xmp-reload", move || { let mut written = 0usize; let mut failed = 0usize; let mut last_error = None; let outcome: Result<(), String> = (|| { let catalog = Catalog::open(&catalog_path).map_err(|e| e.to_string())?; let root_id: i64 = catalog .connection() .query_row( "SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'", [&root], |r| r.get(0), ) .map_err(|e| e.to_string())?; let rt = crate::net_runtime::build().map_err(|e| e.to_string())?; rt.block_on(async { let backend = crate::remote::connect(&conn).map_err(|e| e.to_string())?; for path in &paths { let fetched = backend .get(&RemoteId::Path(RemotePath::new(path.clone())), None) .await .map_err(|e| e.to_string()) .and_then(|bytes| { let text = String::from_utf8_lossy(&bytes); crate::xmp_sync::reload(catalog.connection(), root_id, path, &text) }); match fetched { Ok(taken) => written += taken.changed, Err(e) => { log::warn!("reloading {path}: {e}"); last_error = Some(e); failed += 1; } } } Ok(()) }) })(); if let Err(e) = outcome { failed = paths.len(); last_error = Some(e); } let _ = tx.send(XmpMessage::Finished { written, failed, last_error, }); }); rx }