Merge branch 'claude/fix-942-cube-import-messages'

# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
Claude committed 2026-10-11 12:04:31 +00:00
commit b89fb8bbab
5 files changed
+193 -51

No files matched your search

+27 -11
View File
@@ -34,22 +34,34 @@ pub fn register_all(s: &Session) {
}
}
/// `.cube` files in `paths` (folders searched, zip bundles opened).
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
/// `.cube` files in `paths` (folders searched, zip bundles opened), and the files that couldn't
/// be read with the reason (`[name, reason]`).
fn cube_files(paths: &[String]) -> (Vec<(String, String, Option<String>)>, Vec<Value>) {
// (name, text, group)
let mut out = Vec::new();
let mut failed = Vec::new();
let stem = |f: &Path| f.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
if let Ok(t) = std::fs::read_to_string(&ep) {
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
match std::fs::read_to_string(&ep) {
Ok(t) => {
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((stem(&ep), t, group));
}
Err(e) => failed.push(json!([stem(&ep), e.to_string()])),
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
let Ok(b) = std::fs::read(path) else { continue };
let b = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
failed.push(json!([stem(path), e.to_string()]));
continue;
}
};
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
if inner.to_ascii_lowercase().ends_with(".cube") {
let ip = Path::new(&inner);
@@ -65,11 +77,14 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
));
}
}
} else if let Ok(t) = std::fs::read_to_string(path) {
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
} else {
match std::fs::read_to_string(path) {
Ok(t) => out.push((stem(path), t, None)),
Err(e) => failed.push(json!([stem(path), e.to_string()])),
}
}
}
out
(out, failed)
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
@@ -83,8 +98,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = &dir {
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for (name, text, group) in cube_files(&paths) {
let (files, mut failed) = cube_files(&paths);
let mut imported = Vec::new();
for (name, text, group) in files {
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
Ok(l) => l,
Err(e) => {
+2
View File
@@ -98,6 +98,8 @@ mod tests_open_lightroom;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_preset_import;
#[cfg(test)]
mod tests_preview_limit;
#[cfg(test)]
mod tests_quit_unsaved;
+75 -39
View File
@@ -1494,64 +1494,100 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir()
})
.collect();
let profiles = if lut_paths.is_empty() {
0
} else {
app.session
.execute("profile.import", &json!({"paths": lut_paths}))
.ok()
.and_then(|v| v["imported"].as_array().map(Vec::len))
.unwrap_or(0)
};
// one toast for both halves (a second toast would replace the first)
let mut parts: Vec<String> = Vec::new();
// `name: reason` for each file that couldn't be imported
let mut problems: Vec<String> = Vec::new();
let mut profiles_result = Value::Null;
if !lut_paths.is_empty() {
match app.session.execute("profile.import", &json!({"paths": lut_paths})) {
Ok(v) => {
let profiles = v["imported"].as_array().map_or(0, Vec::len);
if profiles > 0 {
parts.push(crate::i18n::tr_format!(
"Imported {profiles} profile{} (Profile browser ▸ their groups)",
if profiles == 1 { "" } else { "s" },
profiles = profiles
));
}
problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| {
let name = f.get(0).and_then(Value::as_str).unwrap_or_default();
let reason = f.get(1).and_then(Value::as_str).unwrap_or_default();
format!("{name}: {reason}")
}));
profiles_result = v;
}
Err(e) => {
problems.push(e.to_string());
profiles_result = json!({"error": e.to_string()});
}
}
}
let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect();
let r = if preset_paths.is_empty() {
Ok(json!({"imported": [], "failed": [], "skipped": 0}))
} else {
app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string())
};
if profiles > 0 {
app.toast(
&ctx,
crate::i18n::tr_format!(
"Imported {profiles} profile{} (Profile browser ▸ their groups)",
if profiles == 1 { "" } else { "s" },
profiles = profiles
),
);
}
if let Ok(v) = &r {
if let (false, Ok(v)) = (preset_paths.is_empty(), &r) {
let n = v["imported"].as_array().map_or(0, Vec::len);
let failed = v["failed"].as_array().map_or(0, Vec::len);
let mut msg = match (n, failed) {
(0, 0) => "No new presets".to_string(),
(n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n),
(n, f) => crate::i18n::tr_format!(
let profile_news = !parts.is_empty() || !problems.is_empty();
let msg = match (n, failed) {
// a zip of LUTs only: the profile half already says what happened
(0, 0) if profile_news => None,
(0, 0) => Some("No new presets".to_string()),
(n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)),
(n, f) => Some(crate::i18n::tr_format!(
"Imported {n} preset{}, {f} file{} not readable",
if n == 1 { "" } else { "s" },
if f == 1 { "" } else { "s" },
f = f,
n = n
),
)),
};
// settings with no counterpart here (the other editor's profiles, masks…)
let mut skipped: Vec<&str> = v["imported"]
.as_array()
.into_iter()
.flatten()
.flat_map(|i| i["unmapped"].as_array().into_iter().flatten())
.filter_map(Value::as_str)
.collect();
skipped.sort_unstable();
skipped.dedup();
if !skipped.is_empty() {
let names: Vec<&str> = skipped.iter().take(3).copied().collect();
msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
if let Some(mut msg) = msg {
// settings with no counterpart here (the other editor's profiles, masks…)
let mut skipped: Vec<&str> = v["imported"]
.as_array()
.into_iter()
.flatten()
.flat_map(|i| i["unmapped"].as_array().into_iter().flatten())
.filter_map(Value::as_str)
.collect();
skipped.sort_unstable();
skipped.dedup();
if !skipped.is_empty() {
let names: Vec<&str> = skipped.iter().take(3).copied().collect();
msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
}
parts.push(msg);
}
app.toast(&ctx, msg);
if n > 0 {
app.ui.presets = true;
}
}
let mut msg = parts.join(" · ");
if !problems.is_empty() {
let detail =
format!("{}{}", problems.iter().take(2).cloned().collect::<Vec<_>>().join("; "), if problems.len() > 2 { "…" } else { "" });
if msg.is_empty() {
msg = crate::i18n::tr_format!("Import failed: {e}", e = detail);
} else {
msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail);
}
app.toast_error(&ctx, msg);
} else if !msg.is_empty() {
app.toast(&ctx, msg);
}
let r = r.map(|mut v| {
if !lut_paths.is_empty()
&& let Some(o) = v.as_object_mut()
{
o.insert("profiles".into(), profiles_result);
}
v
});
return Some(r);
}
"file.importKeywords" => {
+88
View File
@@ -0,0 +1,88 @@
//! File ▸ Import Profiles & Presets…: `.cube` LUTs become profiles, everything else presets, and
//! one toast says what happened to both (issue #942).
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn app() -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.settle(SETTLE);
h
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-ui-preset-import-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn import(h: &mut Headless, paths: &[&std::path::Path]) -> serde_json::Value {
let paths: Vec<String> = paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
let r = h.request("engine.execute", json!({"command": "file.importPresets", "params": {"paths": paths}}), T);
h.settle(SETTLE);
r
}
fn toast(h: &Headless) -> String {
h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default()
}
/// A 2×2×2 identity LUT.
fn identity_cube() -> String {
let mut s = String::from("TITLE \"Identity\"\nLUT_3D_SIZE 2\n");
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
s += &format!("{r} {g} {b}\n");
}
}
}
s
}
/// Importing only `.cube` files reports the profiles, not "No new presets".
#[test]
fn a_cube_only_import_reports_the_profiles() {
let dir = temp_dir("ok");
let cube = dir.join("Warm.cube");
std::fs::write(&cube, identity_cube()).unwrap();
let mut h = app();
let r = import(&mut h, &[&cube]);
assert_eq!(r["ok"], true, "{r}");
let t = toast(&h);
assert!(t.contains("1 profile"), "{t}");
assert!(!t.contains("preset"), "{t}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A `.cube` that can't be imported says which file and why.
#[test]
fn a_broken_cube_says_why() {
let dir = temp_dir("bad");
let bad = dir.join("Broken.cube");
std::fs::write(&bad, "LUT_3D_SIZE 2\n0 0 0\n").unwrap();
let mut h = app();
let r = import(&mut h, &[&bad]);
assert_eq!(r["ok"], true, "{r}");
let t = toast(&h);
assert!(t.contains("Broken"), "{t}");
assert!(!t.contains("No new presets"), "{t}");
let reason = r["result"]["profiles"]["failed"][0][1].as_str().unwrap_or_default().to_string();
assert!(!reason.is_empty() && t.contains(&reason), "{t} / {r}");
// with a good one alongside, both outcomes are in the one toast
let good = dir.join("Good.cube");
std::fs::write(&good, identity_cube()).unwrap();
import(&mut h, &[&good, &bad]);
let t = toast(&h);
assert!(t.contains("1 profile") && t.contains("Broken"), "{t}");
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -77,7 +77,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 Line truncated