LR-IMP-PROFILES: .cube-only imports report their profiles and failures (#942)

File > Import Profiles & Presets... now builds one toast for both halves
(a second toast replaced the first, so a .cube-only import said "No new
presets"). The preset half is skipped when no preset files were picked,
and profile.import's failures are shown with their reason. profile.import
also reports .cube files and zips it couldn't read instead of dropping
them silently. The command's result carries the profile half under
"profiles".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
This commit is contained in:
Claude 2026-10-11 12:00:08 +00:00
parent 0ba118de92
commit da008ce53c
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

@ -76,6 +76,8 @@ mod tests_offline;
#[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

@ -1358,64 +1358,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