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

# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
Claude 2026-10-11 12:04:31 +00:00
commit b89fb8bbab
No known key found for this signature in database
5 changed files with 193 additions and 51 deletions

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) => {

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;

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" => {

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);
}

File diff suppressed because one or more lines are too long