diff --git a/package-lock.json b/package-lock.json index ab10594..3dbfe09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iccery", - "version": "0.1.4", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iccery", - "version": "0.1.4", + "version": "0.3.3", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/package.json b/package.json index 660c706..453f1a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iccery", "private": true, - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "tauri": "tauri" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7254304..a9be0fe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.3.2" +version = "0.3.3" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6d8be3c..26ccb22 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -507,6 +507,68 @@ pub async fn run_average( state.spawn(app, id, binary, args, cwd).await } +fn sanitize_basename(basename: &str) -> Result { + let name = basename.trim(); + if name.is_empty() { + return Err("basename is empty".to_string()); + } + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err("basename must not contain path separators".to_string()); + } + Ok(name.to_string()) +} + +pub fn snapshot_ti3_filename(basename: &str, pass_index: u32) -> Result { + if pass_index == 0 { + return Err("pass_index must be 1-based".to_string()); + } + let name = sanitize_basename(basename)?; + Ok(format!("{}_pass{}.ti3", name, pass_index)) +} + +/// Copy `{cwd}/{basename}.ti3` to `{cwd}/{basename}_pass{N}.ti3` and remove the +/// canonical file so Stage 4 stays locked until Finish (#109 / #110). +#[tauri::command] +pub fn snapshot_ti3(cwd: String, basename: String, pass_index: u32) -> Result { + let dest_name = snapshot_ti3_filename(&basename, pass_index)?; + let name = sanitize_basename(&basename)?; + let dir = std::path::Path::new(&cwd); + if !dir.is_dir() { + return Err(format!("working directory does not exist: {}", cwd)); + } + let src = dir.join(format!("{}.ti3", name)); + if !src.is_file() { + return Err(format!("measurement file not found: {}", src.display())); + } + let dest = dir.join(&dest_name); + std::fs::copy(&src, &dest).map_err(|e| format!("failed to snapshot .ti3: {}", e))?; + std::fs::remove_file(&src) + .map_err(|e| format!("failed to remove canonical .ti3 after snapshot: {}", e))?; + Ok(dest_name) +} + +/// Copy a pass file (relative name) back to `{cwd}/{basename}.ti3`. +#[tauri::command] +pub fn promote_ti3(cwd: String, source: String, basename: String) -> Result<(), String> { + let name = sanitize_basename(&basename)?; + let src_name = source.trim(); + if src_name.is_empty() + || src_name.contains('/') + || src_name.contains('\\') + || src_name.contains("..") + { + return Err("source filename is invalid".to_string()); + } + let dir = std::path::Path::new(&cwd); + let src = dir.join(src_name); + if !src.is_file() { + return Err(format!("source measurement file not found: {}", src.display())); + } + let dest = dir.join(format!("{}.ti3", name)); + std::fs::copy(&src, &dest).map_err(|e| format!("failed to promote .ti3: {}", e))?; + Ok(()) +} + #[derive(Debug, Deserialize, Serialize)] pub struct ColprofConfig { pub algorithm: String, @@ -824,6 +886,55 @@ mod tests { assert_eq!(args, vec!["-v", "pass1.ti3", "pass2.ti3", "avg.ti3"]); } + #[test] + fn test_build_average_args_pass_files() { + let config = AverageConfig { + inputs: vec!["job_pass1.ti3".to_string(), "job_pass2.ti3".to_string()], + output: "job.ti3".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_average_args(&config); + assert_eq!(args, vec!["-v", "job_pass1.ti3", "job_pass2.ti3", "job.ti3"]); + } + + #[test] + fn test_snapshot_ti3_filename() { + assert_eq!(snapshot_ti3_filename("foo", 1).unwrap(), "foo_pass1.ti3"); + assert_eq!(snapshot_ti3_filename("foo", 2).unwrap(), "foo_pass2.ti3"); + assert!(snapshot_ti3_filename("foo", 0).is_err()); + assert!(snapshot_ti3_filename("../x", 1).is_err()); + assert!(snapshot_ti3_filename("a/b", 1).is_err()); + } + + #[test] + fn test_snapshot_and_promote_ti3_roundtrip() { + let dir = std::env::temp_dir().join(format!( + "iccery_ti3_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let canonical = dir.join("job.ti3"); + std::fs::write(&canonical, b"PASS1").unwrap(); + let cwd = dir.to_string_lossy().to_string(); + let name = snapshot_ti3(cwd.clone(), "job".to_string(), 1).unwrap(); + assert_eq!(name, "job_pass1.ti3"); + assert!(!canonical.exists(), "canonical .ti3 must be removed after snapshot"); + assert_eq!(std::fs::read(dir.join("job_pass1.ti3")).unwrap(), b"PASS1"); + + std::fs::write(dir.join("job_pass2.ti3"), b"PASS2").unwrap(); + let name2 = snapshot_ti3_filename("job", 2).unwrap(); + assert_eq!(name2, "job_pass2.ti3"); + assert!(dir.join("job_pass1.ti3").exists(), "pass 1 must survive pass 2"); + + promote_ti3(cwd, name, "job".to_string()).unwrap(); + assert_eq!(std::fs::read(&canonical).unwrap(), b"PASS1"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn test_build_colprof_args() { let config = ColprofConfig { @@ -865,4 +976,4 @@ mod tests { let candidates_exe = get_binary_candidates("targen.exe"); assert_eq!(candidates_exe, vec!["targen.exe"]); } -} \ No newline at end of file +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ebf33af..42e5884 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,8 @@ pub fn run() { commands::read_file_base64, commands::read_tiff_preview_png, commands::run_chartread, + commands::snapshot_ti3, + commands::promote_ti3, commands::run_average, commands::run_colprof, commands::run_profcheck, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ae910c7..f33028f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.3.2", + "version": "0.3.3", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/index.html b/src/index.html index 31eb400..bbefaf2 100644 --- a/src/index.html +++ b/src/index.html @@ -50,7 +50,7 @@ - + @@ -311,11 +311,11 @@

Multi-Pass Target Averaging

Pass 1 Complete -

You can measure additional printed copies to reduce measurement and printing noise before computing the profile.

+

Each successful sheet is saved as a separate _passN.ti3 file. Stage 4 stays locked until you finish. Measure extra copies to reduce noise, then finish to write the canonical .ti3.

- +
@@ -564,4 +564,4 @@ - \ No newline at end of file + diff --git a/src/js/chartread.js b/src/js/chartread.js index f5a42bf..7d3190e 100644 --- a/src/js/chartread.js +++ b/src/js/chartread.js @@ -29,6 +29,9 @@ const STATE = { let currentState = STATE.IDLE; let currentProcessId = ""; +let measurementInProgress = false; +let currentPassIndex = 0; +const recordedPasses = []; export function initChartread() { const btnStartRead = document.getElementById("btnStartRead"); @@ -42,6 +45,47 @@ export function initChartread() { const stateLabel = document.getElementById("chartreadState"); const logContainer = document.getElementById("chartreadLogContainer"); const logPre = document.getElementById("chartreadLog"); + const averagingPanel = document.getElementById("chartreadAveragingPanel"); + const passesList = document.getElementById("passesList"); + const passCounterBadge = document.getElementById("passCounterBadge"); + const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet"); + const btnFinishAndAverage = document.getElementById("btnFinishAndAverage"); + + function setMeasurementBusy(busy) { + measurementInProgress = busy; + if (btnStartRead) btnStartRead.disabled = busy; + if (btnMeasureAnotherSheet) btnMeasureAnotherSheet.disabled = busy; + if (btnFinishAndAverage) { + btnFinishAndAverage.disabled = busy || recordedPasses.length === 0; + } + } + + function renderPassesList() { + if (!passesList) return; + passesList.innerHTML = ""; + recordedPasses.forEach((pass, i) => { + const item = document.createElement("div"); + item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:6px 10px; border-radius:4px; font-size:0.85rem;"; + const label = document.createElement("span"); + const strong = document.createElement("strong"); + strong.textContent = `Sheet Pass #${i + 1}`; + label.appendChild(strong); + label.appendChild(document.createTextNode(` (${pass.filename})`)); + const time = document.createElement("span"); + time.style.opacity = "0.7"; + time.textContent = `✓ ${pass.time}`; + item.appendChild(label); + item.appendChild(time); + passesList.appendChild(item); + }); + } + + function acceptStage3(basename, cwd) { + wizardState.setTarget(basename, cwd); + setStage3Result(basename, cwd); + wizardState.updateGating(); + advanceToStage4(); + } // Instrument detection logic if (btnDetectInstruments && instrumentSelect) { @@ -133,7 +177,10 @@ export function initChartread() { btnCancel.classList.remove("hidden"); break; case STATE.FINISHED: - btnStartRead.classList.remove("hidden"); + // After a recorded pass, further sheets go through "Measure Another Sheet" + if (recordedPasses.length === 0) { + btnStartRead.classList.remove("hidden"); + } break; } } @@ -145,6 +192,11 @@ export function initChartread() { // Start reading button if (btnStartRead) { btnStartRead.addEventListener("click", async () => { + if (measurementInProgress) { + setPrompt("A measurement is already running. Wait for it to finish."); + return; + } + const basename = stage2Basename || wizardState.basename; const cwd = stage2Cwd || wizardState.cwd; @@ -159,6 +211,7 @@ export function initChartread() { logPre.textContent = ""; logContainer.open = false; logContainer.classList.remove("hidden"); + setMeasurementBusy(true); setState(STATE.CALIBRATING); setPrompt("Starting chartread... waiting for instrument calibration prompt."); @@ -219,29 +272,41 @@ export function initChartread() { stopSwatchListener(); if (event.payload.code === 0) { - setState(STATE.FINISHED); - setPrompt("✅ Sheet measurement completed!"); - logPre.textContent += "\n[SUCCESS] chartread sheet measurement completed.\n"; + try { + const passIndex = currentPassIndex + 1; + const filename = await invoke("snapshot_ti3", { + cwd: cwd, + basename: basename, + pass_index: passIndex, + }); - // Track pass in session - currentPassIndex++; - recordedPasses.push({ - index: currentPassIndex, - filename: `${basename}.ti3`, - time: new Date().toLocaleTimeString(), - }); + currentPassIndex = passIndex; + recordedPasses.push({ + index: currentPassIndex, + filename: filename, + time: new Date().toLocaleTimeString(), + }); - renderPassesList(); - if (averagingPanel) averagingPanel.classList.remove("hidden"); - if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`; + setState(STATE.FINISHED); + setPrompt(`Sheet pass #${currentPassIndex} saved as ${filename}. Stage 4 stays locked until you finish.`); + logPre.textContent += `\n[SUCCESS] chartread completed. Snapshotted ${filename} (canonical ${basename}.ti3 removed until Finish).\n`; - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); + renderPassesList(); + if (averagingPanel) averagingPanel.classList.remove("hidden"); + if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`; + // Do not call setStage3Result / wizardState.setTarget here (#110) + } catch (snapErr) { + setState(STATE.FINISHED); + setPrompt(`Measurement finished but pass snapshot failed: ${snapErr}`); + logPre.textContent += `\n[ERROR] snapshot_ti3 failed: ${snapErr}\n`; + } } else { setState(STATE.FINISHED); setPrompt(`❌ chartread exited with code ${event.payload.code}.`); logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`; } + + setMeasurementBusy(false); }); await invoke("run_chartread", { config }); @@ -249,34 +314,19 @@ export function initChartread() { } catch (err) { setPrompt(`Invoke error: ${err}`); setState(STATE.IDLE); + setMeasurementBusy(false); } }); } - // Multi-pass measurement controls - const averagingPanel = document.getElementById("chartreadAveragingPanel"); - const passesList = document.getElementById("passesList"); - const passCounterBadge = document.getElementById("passCounterBadge"); - const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet"); - const btnFinishAndAverage = document.getElementById("btnFinishAndAverage"); - let currentPassIndex = 0; - const recordedPasses = []; - - function renderPassesList() { - if (!passesList) return; - passesList.innerHTML = ""; - recordedPasses.forEach((pass, i) => { - const item = document.createElement("div"); - item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:6px 10px; border-radius:4px; font-size:0.85rem;"; - item.innerHTML = `Sheet Pass #${i + 1} (${pass.filename})✓ ${pass.time}`; - passesList.appendChild(item); - }); - } - if (btnMeasureAnotherSheet) { btnMeasureAnotherSheet.addEventListener("click", () => { + if (measurementInProgress) { + setPrompt("Wait for the current measurement to finish and be snapshotted."); + return; + } setState(STATE.IDLE); - setPrompt(`Ready to measure sheet pass #${recordedPasses.length + 1}. Press 'Start Measurement'.`); + setPrompt(`Ready to measure sheet pass #${recordedPasses.length + 1}. Starting chartread...`); if (btnStartRead) btnStartRead.click(); }); } @@ -286,11 +336,36 @@ export function initChartread() { const basename = stage2Basename || wizardState.basename; const cwd = stage2Cwd || wizardState.cwd; - if (recordedPasses.length <= 1) { - setPrompt("Single pass accepted. Proceeding to Stage 4..."); - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); - advanceToStage4(); + if (!basename || !cwd) { + setPrompt("Error: No working directory or basename."); + return; + } + + if (measurementInProgress) { + setPrompt("Wait for the current measurement to finish."); + return; + } + + if (recordedPasses.length === 0) { + setPrompt("No measurement passes recorded yet."); + return; + } + + if (recordedPasses.length === 1) { + setPrompt("Single pass accepted. Restoring canonical .ti3 and proceeding to Stage 4..."); + btnFinishAndAverage.disabled = true; + try { + await invoke("promote_ti3", { + cwd: cwd, + source: recordedPasses[0].filename, + basename: basename, + }); + logPre.textContent += `\n[SUCCESS] Promoted ${recordedPasses[0].filename} → ${basename}.ti3 (single pass, average not invoked).\n`; + acceptStage3(basename, cwd); + } catch (e) { + btnFinishAndAverage.disabled = false; + setPrompt(`Failed to restore ${basename}.ti3: ${e}`); + } return; } @@ -304,21 +379,30 @@ export function initChartread() { cwd: cwd, }; - const unlistenAvgExit = await listen("process:exit", (event) => { + logPre.textContent += `\n[average] average -v ${averageConfig.inputs.join(" ")} ${averageConfig.output}\n`; + + const unlistenAvgExit = await listen("process:exit", async (event) => { if (event.payload.id !== `average_${basename}.ti3`) return; unlistenAvgExit(); - btnFinishAndAverage.disabled = false; if (event.payload.code === 0) { setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`); - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); - advanceToStage4(); + logPre.textContent += `\n[SUCCESS] average wrote ${basename}.ti3\n`; + acceptStage3(basename, cwd); } else { - setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Proceeding with primary pass.`); - wizardState.setTarget(basename, cwd); - setStage3Result(basename, cwd); - advanceToStage4(); + setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Promoting pass 1 as fallback.`); + logPre.textContent += `\n[ERROR] average exited ${event.payload.code}. Promoting ${recordedPasses[0].filename}.\n`; + try { + await invoke("promote_ti3", { + cwd: cwd, + source: recordedPasses[0].filename, + basename: basename, + }); + acceptStage3(basename, cwd); + } catch (e) { + btnFinishAndAverage.disabled = false; + setPrompt(`Average failed and fallback promote failed: ${e}`); + } } }); @@ -326,8 +410,7 @@ export function initChartread() { } catch (e) { console.error("run_average error:", e); btnFinishAndAverage.disabled = false; - setStage3Result(basename, cwd); - advanceToStage4(); + setPrompt(`run_average error: ${e}`); } }); } @@ -369,9 +452,15 @@ export function initChartread() { try { await invoke("kill_process", { id: currentProcessId }); stopSwatchListener(); - setState(STATE.IDLE); + setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE); setPrompt("Measurement cancelled."); - } catch (e) { console.error("kill_process error:", e); } + } catch (e) { + console.error("kill_process error:", e); + setPrompt(`Cancel failed: ${e}`); + } finally { + setMeasurementBusy(false); + stopSwatchListener(); + } }); } @@ -385,7 +474,10 @@ function advanceToStage4() { const stages = document.querySelectorAll('.stage'); steps.forEach(s => s.classList.remove('active')); - if (steps[3]) steps[3].classList.add('active'); + if (steps[3]) { + steps[3].classList.add('active'); + steps[3].classList.remove('disabled'); + } stages.forEach(s => { s.classList.remove('active');