Bring a Lightroom library across, and start with something in the list

Two halves of the same complaint: a preset sheet that opens on "No
presets yet" is homework, and a photographer with ten years of presets
in Lightroom has no way to bring them.

`dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be
mostly a rename rather than a conversion, because Adobe and this
pipeline already agree: exposure is in stops in both, and contrast, the
four recovery controls, clarity, texture, vibrance and saturation are
all ±100 in both. That is not imitation, it is the convention raw
developers converged on — `highlights_shadows.yaml` cites it in as many
words. Only sharpening needed arithmetic, Adobe's 0…150 against our
0…100.

The white balance does not come across, and says so rather than
guessing. Adobe writes absolute Kelvin for a raw file where ours is a
relative nudge from what the camera recorded, so converting needs the
*target image's* as-shot white balance — exactly what a preset cannot
carry, since the same preset lands on a frame shot at 3200K and one shot
at 7000K. A guess would be wrong on most images and invisibly so.

A folder is read as readily as a file, nested, because that is the shape
an exported preset folder is in and importing ninety files one at a time
is asking someone not to bother.

`dr_pipeline::starter` is six presets a first run begins with, written
against this pipeline in its units and deliberately mild — a starting
point, not a caricature. They are seeded when the library *file* does
not exist rather than when the library is empty, so deleting all six
does not hand them back on the next launch.

Both of these name operations, and `ui_names_no_operation` was right to
stop them living in `ui/`. That test exists because the failure is
silent and cumulative, and it caught exactly what it was written for: a
preset called "Punch" is a statement about contrast, clarity and
vibrance, and a table mapping Adobe's vocabulary to ours is a statement
about the pipeline. Neither is a fact about an interface. So the starter
set went into `dr-pipeline`, and the importer into its own crate —
between two walls, since `dr-pipeline` depends on nothing on purpose and
XMP is real XML not worth hand-rolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:00:53 +02:00
co-authored by Claude Opus 5
parent bb35665bd2
commit 754ab91347
11 changed files with 1209 additions and 19 deletions
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "dr-preset-xmp"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
dr-pipeline.workspace = true
# XMP is real XML with namespaces and is not worth hand-rolling. Already in the
# tree for WebDAV, so this is a use rather than a new dependency.
quick-xml.workspace = true
log.workspace = true
thiserror.workspace = true
+693
View File
@@ -0,0 +1,693 @@
//! TRACES: FR-DEV-6
//! Reading a Lightroom `.xmp` preset into one of ours.
//!
//! # Why this is possible at all
//!
//! Adobe's Camera Raw settings and ours agree on more than they had any
//! obligation to. Exposure is in stops in both; contrast, the four recovery
//! controls, clarity, texture, vibrance and saturation are all ±100 in both.
//! That is not imitation — it is the convention raw developers converged on,
//! and `highlights_shadows.yaml` cites it in as many words ("matching the
//! convention every other developer uses"). So most of the table below is a
//! rename rather than a conversion.
//!
//! # Why this is its own crate
//!
//! Two walls, and it sits between them. It cannot live in `dr-pipeline`,
//! which depends on nothing deliberately — that is what lets the descriptor
//! and codegen logic be tested without a device (ARCH §6.5a) — and XMP is real
//! XML with namespaces, not worth hand-rolling. And it cannot live in `dr-ui`,
//! because the table below *names operations* and nothing in the interface may
//! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a
//! mapping from Adobe's vocabulary to ours is a statement about the pipeline,
//! not about any interface.
//!
//! So: a small crate below the interface and beside the pipeline, which is how
//! the rest of `core/` is already organised.
//!
//! # What is deliberately not imported
//!
//! **White balance.** Lightroom writes `crs:Temperature` as absolute Kelvin
//! for a raw file; ours is a relative ±100 nudge away from what the camera
//! recorded. Converting between them needs the *target image's* as-shot white
//! balance, which is exactly what a preset does not carry — the same preset
//! lands on a frame shot at 3200K and one shot at 7000K. A guess here would be
//! wrong on most images and invisibly so, which is worse than an honest gap,
//! so these keys are counted as skipped and reported.
//!
//! **Tone curves, colour mixing, masks, lens profiles and grain.** Each is a
//! structure rather than a number, and each would need its own argument about
//! whether the two applications mean the same thing. They are skipped by
//! omission — a key not in the table is simply not understood — and the
//! [`Import::skipped`] count is what tells the user their preset arrived
//! partial.
use std::collections::BTreeMap;
use dr_pipeline::Preset;
use quick_xml::events::Event;
use quick_xml::XmlVersion;
/// How a Lightroom value becomes one of ours.
#[derive(Debug, Clone, Copy)]
enum Convert {
/// Same units, same range. Most of the table.
Direct,
/// Same zero, different full deflection — multiply.
///
/// Carries the factor rather than the two ranges because that is what the
/// arithmetic needs, and a range pair would invite the reader to check it
/// against a descriptor that is not the authority for Adobe's half.
Scale(f32),
}
/// One key we understand.
struct Mapping {
/// The attribute name inside the `crs` namespace.
crs: &'static str,
op: &'static str,
param: &'static str,
convert: Convert,
}
/// Every Camera Raw key this build can translate.
///
/// The op and param names are checked against the pipeline's own descriptors
/// by a test, so a renamed parameter breaks the build rather than silently
/// dropping a setting on import.
const MAPPINGS: &[Mapping] = &[
// Stops in both. The one mapping that needed no thought at all.
Mapping {
crs: "Exposure2012",
op: "exposure",
param: "exposure",
convert: Convert::Direct,
},
Mapping {
crs: "Contrast2012",
op: "contrast",
param: "contrast",
convert: Convert::Direct,
},
Mapping {
crs: "Highlights2012",
op: "highlights_shadows",
param: "highlights",
convert: Convert::Direct,
},
Mapping {
crs: "Shadows2012",
op: "highlights_shadows",
param: "shadows",
convert: Convert::Direct,
},
Mapping {
crs: "Whites2012",
op: "blacks_whites",
param: "whites",
convert: Convert::Direct,
},
Mapping {
crs: "Blacks2012",
op: "blacks_whites",
param: "blacks",
convert: Convert::Direct,
},
Mapping {
crs: "Clarity2012",
op: "clarity",
param: "amount",
convert: Convert::Direct,
},
Mapping {
crs: "Texture",
op: "texture",
param: "amount",
convert: Convert::Direct,
},
Mapping {
crs: "Vibrance",
op: "vibrance",
param: "vibrance",
convert: Convert::Direct,
},
Mapping {
crs: "Saturation",
op: "saturation",
param: "saturation",
convert: Convert::Direct,
},
// Adobe's sharpening runs 0…150 where ours runs 0…100, so a preset asking
// for its maximum gets ours rather than being clamped there silently.
Mapping {
crs: "Sharpness",
op: "capture_sharpen",
param: "amount",
convert: Convert::Scale(100.0 / 150.0),
},
Mapping {
crs: "LuminanceSmoothing",
op: "noise_reduction",
param: "luminance",
convert: Convert::Direct,
},
Mapping {
crs: "ColorNoiseReduction",
op: "noise_reduction",
param: "chroma",
convert: Convert::Direct,
},
];
/// Keys that are understood well enough to know we are *not* importing them.
///
/// Separate from silence so the report can say "the white balance did not come
/// across" rather than leaving the photographer to notice. See the module note.
const KNOWN_UNSUPPORTED: &[&str] = &[
"Temperature",
"Tint",
"ToneCurvePV2012",
"ToneCurvePV2012Red",
"ToneCurvePV2012Green",
"ToneCurvePV2012Blue",
];
/// What came of reading one file.
#[derive(Debug, Clone, PartialEq)]
pub struct Import {
/// The preset's name, from `crs:Name` where the file carries one.
pub name: Option<String>,
/// The settings that translated.
pub preset: Preset,
/// Keys recognised as deliberately unsupported — the white balance, the
/// tone curve. Named, so the report can be specific.
pub skipped: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ImportError {
#[error("not readable as XML: {0}")]
NotXml(String),
#[error("no Camera Raw settings in this file")]
NoSettings,
}
/// Read one Lightroom `.xmp` preset.
///
/// Tolerant in the same direction the sidecar parser is: a value that will not
/// parse as a number costs that setting and not the file, because a preset
/// that arrives missing its contrast is still worth having and an error
/// message is not.
///
/// Camera Raw writes its settings either as attributes on an
/// `rdf:Description` or as child elements of one, depending on version and on
/// whether the value is a structure. Both are read, because a photographer's
/// preset folder spans a decade of Lightroom versions and the file does not
/// say which shape it used.
pub fn read_xmp(text: &str) -> Result<Import, ImportError> {
let mut reader = quick_xml::Reader::from_str(text);
reader.config_mut().trim_text(true);
let mut found: BTreeMap<String, String> = BTreeMap::new();
let mut name: Option<String> = None;
let mut pending_element: Option<String> = None;
// Inside `<crs:Name><rdf:Alt><rdf:li>` the text is the preset's name.
let mut in_name = false;
loop {
match reader.read_event() {
Err(e) => return Err(ImportError::NotXml(e.to_string())),
Ok(Event::Eof) => break,
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let local = local_name(e.name().as_ref());
if local == "Name" {
in_name = true;
}
pending_element = Some(local.clone());
for attribute in e.attributes().flatten() {
let key = local_name(attribute.key.as_ref());
// `normalized_value` rather than the deprecated
// `unescape_value`. `Implicit1_0` is the right version
// here: an XMP packet opens with `<?xpacket?>` rather than
// an XML declaration, so the version is unstated and the
// specification says to assume 1.0.
let Ok(value) = attribute.normalized_value(XmlVersion::Implicit1_0) else {
continue;
};
found.entry(key).or_insert_with(|| value.to_string());
}
}
Ok(Event::End(e)) => {
if local_name(e.name().as_ref()) == "Name" {
in_name = false;
}
pending_element = None;
}
Ok(Event::Text(e)) => {
let Ok(text) = e.xml10_content() else {
continue;
};
let text = text.trim().to_string();
if text.is_empty() {
continue;
}
if in_name && name.is_none() {
name = Some(text.clone());
}
if let Some(key) = pending_element.clone() {
found.entry(key).or_insert(text);
}
}
_ => {}
}
}
if found.is_empty() {
return Err(ImportError::NoSettings);
}
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
for mapping in MAPPINGS {
let Some(raw) = found.get(mapping.crs) else {
continue;
};
// Adobe writes a leading `+` on positive numbers, which Rust's float
// parser accepts, and occasionally a trailing `%`, which it does not.
let Ok(value) = raw.trim().trim_end_matches('%').parse::<f32>() else {
log::debug!("xmp: {} is not a number: {raw:?}", mapping.crs);
continue;
};
let value = match mapping.convert {
Convert::Direct => value,
Convert::Scale(by) => value * by,
};
// Out-of-range values are left as they are: `EditGraph::set_param`
// clamps when the preset is applied, and clamping here as well would
// mean two places to be wrong about a range.
params.insert((mapping.op.to_string(), mapping.param.to_string()), value);
}
let skipped = KNOWN_UNSUPPORTED
.iter()
.filter(|k| found.contains_key(**k))
.map(|k| k.to_string())
.collect();
if params.is_empty() && name.is_none() {
return Err(ImportError::NoSettings);
}
Ok(Import {
name,
preset: Preset::from_params(params),
skipped,
})
}
/// The part of `ns:Local` after the colon.
///
/// Namespace prefixes are declared per document and Camera Raw is not the only
/// thing that writes XMP, so matching on the prefix would be matching on a
/// convention rather than on the standard. Comparing local names is what makes
/// a file written by a different tool with a different prefix still readable.
fn local_name(raw: &[u8]) -> String {
let name = String::from_utf8_lossy(raw);
match name.split_once(':') {
Some((_, local)) => local.to_string(),
None => name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::{EditGraph, Scope};
/// The attribute form: settings as attributes on `rdf:Description`.
const ATTRIBUTE_FORM: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:PresetType="Normal"
crs:Exposure2012="+0.75"
crs:Contrast2012="+25"
crs:Highlights2012="-40"
crs:Shadows2012="+30"
crs:Whites2012="+10"
crs:Blacks2012="-15"
crs:Clarity2012="+12"
crs:Texture="+8"
crs:Vibrance="+20"
crs:Saturation="-5"
crs:Sharpness="75"
crs:Temperature="5400"
crs:Tint="+12">
<crs:Name>
<rdf:Alt>
<rdf:li xml:lang="x-default">Warm Portrait</rdf:li>
</rdf:Alt>
</crs:Name>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
/// The element form: the same settings as child elements.
const ELEMENT_FORM: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
<crs:Exposure2012>+0.75</crs:Exposure2012>
<crs:Contrast2012>+25</crs:Contrast2012>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
fn value(import: &Import, op: &str, param: &str) -> Option<f32> {
import
.preset
.params()
.get(&(op.to_string(), param.to_string()))
.copied()
}
#[test]
fn every_mapping_names_a_parameter_this_build_actually_has() {
// The test that keeps the table honest. Adobe's half cannot be checked
// from here, but ours can: a renamed operation or parameter must break
// this rather than silently drop a setting on every import.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for mapping in MAPPINGS {
let op = capabilities
.iter()
.find(|c| c.id.0 == mapping.op)
.unwrap_or_else(|| panic!("no operation {:?}", mapping.op));
assert!(
op.params.iter().any(|p| p.id.0 == mapping.param),
"operation {:?} has no parameter {:?}",
mapping.op,
mapping.param
);
}
}
#[test]
fn no_two_mappings_claim_the_same_key_or_the_same_target() {
let mut keys: Vec<&str> = MAPPINGS.iter().map(|m| m.crs).collect();
keys.sort_unstable();
let before = keys.len();
keys.dedup();
assert_eq!(before, keys.len(), "two mappings read the same crs key");
let mut targets: Vec<(&str, &str)> = MAPPINGS.iter().map(|m| (m.op, m.param)).collect();
targets.sort_unstable();
let before = targets.len();
targets.dedup();
assert_eq!(
before,
targets.len(),
"two mappings write the same parameter"
);
}
#[test]
fn the_settings_that_share_a_convention_come_across_unchanged() {
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
assert_eq!(value(&import, "contrast", "contrast"), Some(25.0));
assert_eq!(
value(&import, "highlights_shadows", "highlights"),
Some(-40.0)
);
assert_eq!(value(&import, "highlights_shadows", "shadows"), Some(30.0));
assert_eq!(value(&import, "blacks_whites", "whites"), Some(10.0));
assert_eq!(value(&import, "blacks_whites", "blacks"), Some(-15.0));
assert_eq!(value(&import, "clarity", "amount"), Some(12.0));
assert_eq!(value(&import, "texture", "amount"), Some(8.0));
assert_eq!(value(&import, "vibrance", "vibrance"), Some(20.0));
assert_eq!(value(&import, "saturation", "saturation"), Some(-5.0));
}
#[test]
fn a_range_that_differs_is_rescaled_rather_than_clamped() {
// Adobe's sharpening runs to 150. A preset asking for half of its
// range should ask for half of ours, not for two thirds of it.
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
assert_eq!(value(&import, "capture_sharpen", "amount"), Some(50.0));
}
#[test]
fn the_white_balance_is_reported_as_skipped_rather_than_guessed_at() {
// The honest gap. Adobe's Kelvin cannot become our relative nudge
// without the target image's as-shot white balance, which a preset
// does not carry.
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
assert!(value(&import, "white_balance", "temperature").is_none());
assert!(value(&import, "white_balance", "tint").is_none());
assert!(import.skipped.contains(&"Temperature".to_string()));
assert!(import.skipped.contains(&"Tint".to_string()));
}
#[test]
fn the_preset_carries_its_name() {
assert_eq!(
read_xmp(ATTRIBUTE_FORM).unwrap().name.as_deref(),
Some("Warm Portrait")
);
}
#[test]
fn settings_written_as_elements_read_the_same_as_settings_written_as_attributes() {
// A preset folder spans a decade of Lightroom versions and the file
// does not say which shape it used.
let import = read_xmp(ELEMENT_FORM).unwrap();
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
assert_eq!(value(&import, "contrast", "contrast"), Some(25.0));
}
#[test]
fn an_imported_preset_applies_like_any_other() {
// The point of the exercise: what comes out is a `Preset`, not a
// second kind of thing with a second apply path.
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
let mut graph = EditGraph::default_chain();
import.preset.apply(&mut graph, Scope::adjustments());
assert_eq!(
graph.param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE
),
Some(0.75)
);
}
#[test]
fn a_value_that_is_not_a_number_costs_that_setting_and_not_the_file() {
let text =
ATTRIBUTE_FORM.replace(r#"crs:Contrast2012="+25""#, r#"crs:Contrast2012="lots""#);
let import = read_xmp(&text).unwrap();
assert_eq!(value(&import, "contrast", "contrast"), None);
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
}
#[test]
fn a_file_that_is_not_xml_is_refused() {
assert!(matches!(
read_xmp("<x:xmpmeta><unclosed>"),
Err(ImportError::NotXml(_)) | Err(ImportError::NoSettings)
));
}
#[test]
fn a_file_with_no_camera_raw_settings_is_refused() {
let text = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"></x:xmpmeta>"#;
assert_eq!(read_xmp(text), Err(ImportError::NoSettings));
}
#[test]
fn a_prefix_other_than_crs_is_still_read() {
// Namespace prefixes are declared per document. Matching on `crs:`
// would be matching on a convention rather than on the standard.
let text = ELEMENT_FORM.replace("crs:", "cameraraw:");
let import = read_xmp(&text).unwrap();
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
}
// -----------------------------------------------------------------------
// Reading a folder
// -----------------------------------------------------------------------
fn tempdir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-preset-import-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_folder_of_presets_is_read_including_its_subfolders() {
// The shape a photographer's presets are actually in: an exported
// Lightroom folder, nested one level per group.
let dir = tempdir("folder");
std::fs::write(dir.join("one.xmp"), ATTRIBUTE_FORM).unwrap();
let group = dir.join("Portraits");
std::fs::create_dir_all(&group).unwrap();
std::fs::write(group.join("two.xmp"), ELEMENT_FORM).unwrap();
// Not a preset, and not an error either.
std::fs::write(dir.join("notes.txt"), "ignore me").unwrap();
let report = read_path(&dir);
assert_eq!(report.presets.len(), 2, "{report:?}");
assert_eq!(report.failed, 0);
}
#[test]
fn a_preset_without_a_name_is_called_after_its_file() {
// Lightroom writes the name it displays, which is not always the file
// name — but a file with no `crs:Name` still has to arrive callable.
let dir = tempdir("unnamed");
std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
let report = read_path(&dir);
assert_eq!(report.presets[0].0, "Golden Hour");
}
#[test]
fn the_displayed_name_wins_over_the_file_name() {
let dir = tempdir("named");
std::fs::write(dir.join("preset_04b.xmp"), ATTRIBUTE_FORM).unwrap();
let report = read_path(&dir);
assert_eq!(report.presets[0].0, "Warm Portrait");
}
#[test]
fn an_unreadable_file_is_counted_rather_than_stopping_the_import() {
// Ninety presets and one bad file should import eighty-nine.
let dir = tempdir("partial");
std::fs::write(dir.join("good.xmp"), ATTRIBUTE_FORM).unwrap();
std::fs::write(dir.join("bad.xmp"), "not xml at all <<<").unwrap();
let report = read_path(&dir);
assert_eq!(report.presets.len(), 1);
assert_eq!(report.failed, 1);
}
#[test]
fn the_unsupported_keys_are_gathered_once_and_not_once_per_file() {
// After forty presets, "the white balance did not come across" is the
// useful sentence; forty repetitions of it is not.
let dir = tempdir("gathered");
std::fs::write(dir.join("a.xmp"), ATTRIBUTE_FORM).unwrap();
std::fs::write(dir.join("b.xmp"), ATTRIBUTE_FORM).unwrap();
let report = read_path(&dir);
assert_eq!(report.presets.len(), 2);
assert!(report.unsupported.contains("Temperature"));
assert_eq!(
report
.unsupported
.iter()
.filter(|k| *k == "Temperature")
.count(),
1
);
}
#[test]
fn a_single_file_can_be_imported_on_its_own() {
let dir = tempdir("single");
let file = dir.join("one.xmp");
std::fs::write(&file, ATTRIBUTE_FORM).unwrap();
assert_eq!(read_path(&file).presets.len(), 1);
}
#[test]
fn a_path_that_is_not_there_reports_nothing_rather_than_failing() {
let report = read_path(std::path::Path::new("/definitely/not/here"));
assert_eq!(report, Report::default());
}
}
// ---------------------------------------------------------------------------
// Reading a folder of them
// ---------------------------------------------------------------------------
/// What came of reading a path.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Report {
/// Presets read and named, ready to be stored.
pub presets: Vec<(String, Preset)>,
/// Files that were `.xmp` but did not yield a preset.
pub failed: usize,
/// Every deliberately-unsupported key seen across the whole run.
///
/// Gathered rather than counted: after importing forty presets, "the white
/// balance did not come across" is the useful sentence, and forty
/// repetitions of it is not.
pub unsupported: std::collections::BTreeSet<String>,
}
/// Read one `.xmp` file, or every `.xmp` under a folder.
///
/// A folder because that is the shape a photographer's presets are in — an
/// exported Lightroom preset folder, nested one level per group — and asking
/// them to import ninety files one at a time would be asking them not to
/// bother. Nested folders are walked, which is what makes the group structure
/// available to whatever wants it later.
///
/// The *name* comes from `crs:Name` where the file carries one and from the
/// file stem where it does not. Lightroom writes the name it displays, which
/// is not always the file name, and the displayed name is the one the
/// photographer will look for.
pub fn read_path(path: &std::path::Path) -> Report {
let mut report = Report::default();
read_into(path, &mut report);
report.presets.sort_by(|a, b| a.0.cmp(&b.0));
report
}
fn read_into(path: &std::path::Path, report: &mut Report) {
if path.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else {
log::warn!("preset import: cannot read {}", path.display());
return;
};
// Sorted, so importing the same folder twice reports the same order
// and a name collision resolves the same way both times.
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for path in paths {
read_into(&path, report);
}
return;
}
let is_xmp = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("xmp"));
if !is_xmp {
return;
}
let Ok(text) = std::fs::read_to_string(path) else {
report.failed += 1;
return;
};
match read_xmp(&text) {
Ok(import) => {
let name = import.name.unwrap_or_else(|| {
path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
});
report.unsupported.extend(import.skipped);
report.presets.push((name, import.preset));
}
Err(e) => {
log::debug!("preset import: {}: {e}", path.display());
report.failed += 1;
}
}
}