Split library.rs into library/ by area of behaviour

library.rs was 7,729 lines wiring together everything "open a remote
library" touches: scanning, pulling other devices' judgements out of
sidecars found along the way, writing local edits back out to the
sidecar outbox, pushing/reloading XMP by hand, fetching and prefetching
thumbnails and originals, generating thumbnails locally, the metadata
and thumbnail background sweeps, on-disk paths for the catalog and
model files, and reading the grid's cells, spans and rating filter.
Same motivation as the develop.rs split (docs/dev/code-health.md CH-1):
a pure, no-behaviour-change move into one file per area, each under
about 1,500 lines.

Tracing actual call sites rather than trusting the file's physical
layout mattered here: `persist`, `load_folder_etags`, `pull_sidecars`,
`load_sidecar_etags`, `record_sidecar_read` and `apply_judgement` sit
textually beside the XMP push/reload functions but are called only
from `run_scan` (pulling a device's own past judgements out of the
sidecars a scan just walked), so they went to scan.rs and not xmp.rs.
`cells` came out at over 1,800 lines once its tests moved with it and
split further into cells.rs (windowed reads, trash, ordinals) and
spans.rs (collection scope, manual reordering, the capture-time
histogram) -- ten submodules rather than the nine first planned.

Previously-private items reached from a sibling module became
`pub(super)`, narrower than the whole-crate reachability one file gave
them. Tests moved with the code they test; the two test fixtures used
across more than one file (`scanned`, and develop.rs's
`session_with_a_left_half_subject` in the matching commit) joined the
shared `test_support` module alongside the existing `entry`/
`with_images`/`image_ids` helpers. `mod.rs` re-exports every module's
public items under `library::`, including the `pub(crate)`
`test_support` module `repairs.rs` reads its fixtures from, so no file
outside `library` needed a change.

The previous commit split develop.rs the same way; taken alone it left
dr-ui without library.rs, so that intermediate commit does not build on
its own. This one restores it.
This commit is contained in:
2026-09-20 18:21:43 +02:00
parent 050c2c9d16
commit a1d511fd4b
12 changed files with 7895 additions and 7729 deletions
+192
View File
@@ -0,0 +1,192 @@
//! Pushing local judgements out to XMP sidecars, and reloading sidecars a
//! person chose to trust by hand.
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<XmpWrite>) -> Receiver<XmpMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(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<String>,
},
}
/// 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<String> = 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<String>,
) -> Receiver<XmpMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(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
}