feat(stage3): support opening existing .ti2 target files to jump to Stage 3 (resolves #140) #143

Merged
gronod merged 1 commits from feat/resume-stage3-140 into development 2026-08-29 17:49:42 +01:00
6 changed files with 218 additions and 5 deletions
+95
View File
@@ -166,6 +166,101 @@ pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Ti2Metadata {
pub basename: String,
pub cwd: String,
pub instrument: Option<String>,
pub patch_count: Option<u32>,
pub colorant_count: Option<u32>,
pub pages: Option<u32>,
pub has_ti1: bool,
pub has_ti2: bool,
}
#[tauri::command]
pub fn parse_ti2_header(app: AppHandle, file_path: String) -> Result<Ti2Metadata, String> {
let path = std::path::PathBuf::from(&file_path);
if !path.exists() {
return Err(format!("File does not exist: {}", file_path));
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read target file: {}", e))?;
let parent = path.parent().unwrap_or_else(|| std::path::Path::new(""));
let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let cwd = parent.to_string_lossy().to_string();
let basename = file_stem.to_string();
let ti1_path = parent.join(format!("{}.ti1", basename));
let ti2_path = parent.join(format!("{}.ti2", basename));
let mut instrument: Option<String> = None;
let mut patch_count: Option<u32> = None;
let mut colorant_count: Option<u32> = None;
let mut pages: Option<u32> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("TARGET_INSTRUMENT") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
instrument = Some(parts[1].trim_matches('"').to_string());
}
} else if trimmed.starts_with("NUMBER_OF_FIELDS") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
colorant_count = parts[1].parse::<u32>().ok();
}
} else if trimmed.starts_with("NUMBER_OF_SETS") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
patch_count = parts[1].parse::<u32>().ok();
}
} else if trimmed.starts_with("NUMBER_OF_PAGES") || trimmed.starts_with("PAGES") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
pages = parts[1].parse::<u32>().ok();
}
}
}
Ok(Ti2Metadata {
basename,
cwd,
instrument,
patch_count,
colorant_count,
pages,
has_ti1: ti1_path.exists(),
has_ti2: ti2_path.exists(),
})
}
#[tauri::command]
pub async fn select_existing_target(
app: AppHandle,
default_dir: Option<String>,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let mut builder = app.dialog().file().add_filter("ArgyllCMS Target Files (.ti1, .ti2)", &["ti1", "ti2"]);
if let Some(ref dir) = default_dir {
if !dir.trim().is_empty() {
builder = builder.set_directory(std::path::PathBuf::from(dir));
}
}
let (tx, rx) = tokio::sync::oneshot::channel();
builder.pick_file(move |file_path| {
let res = file_path.map(|p| p.to_string());
let _ = tx.send(res);
});
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
}
#[tauri::command] #[tauri::command]
pub async fn select_target_file( pub async fn select_target_file(
app: AppHandle, app: AppHandle,
+2
View File
@@ -33,6 +33,8 @@ pub fn run() {
commands::get_default_working_dir, commands::get_default_working_dir,
commands::get_log_path, commands::get_log_path,
commands::open_log_dir, commands::open_log_dir,
commands::parse_ti2_header,
commands::select_existing_target,
commands::select_target_file, commands::select_target_file,
commands::select_directory, commands::select_directory,
commands::send_stdin, commands::send_stdin,
+13 -1
View File
@@ -97,7 +97,10 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Project Directory & Filename</label> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label style="margin: 0;">Project Directory &amp; Filename</label>
<button type="button" id="btnOpenExisting" class="secondary" style="font-size: 0.75rem; padding: 2px 8px;" title="Open existing .ti1 or .ti2 target file">📂 Open Target...</button>
</div>
<div class="input-row has-btn"> <div class="input-row has-btn">
<input type="text" id="targetBasename" placeholder="e.g. my_paper_profile"> <input type="text" id="targetBasename" placeholder="e.g. my_paper_profile">
<button type="button" id="btnBrowse" class="secondary">Browse...</button> <button type="button" id="btnBrowse" class="secondary">Browse...</button>
@@ -305,6 +308,15 @@
<h2>Measure Target</h2> <h2>Measure Target</h2>
<p>Read the printed target patches with your spectrophotometer.</p> <p>Read the printed target patches with your spectrophotometer.</p>
<!-- Loaded Target Context Banner -->
<div id="stage3LoadedTargetBanner" class="info-banner hidden" style="margin-bottom: 16px; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); padding: 10px 14px; border-radius: 6px; display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>Active Target:</strong> <span id="stage3TargetBasename" style="font-family: monospace; font-weight: 600;"></span>
<div id="stage3TargetMeta" style="font-size: 0.8rem; opacity: 0.8; margin-top: 2px;"></div>
</div>
<span class="badge" id="stage3TargetBadge" style="background: var(--accent-color, #3b82f6); color: white; padding: 3px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500;">Ready</span>
</div>
<!-- Instrument & Hardware Port Selector --> <!-- Instrument & Hardware Port Selector -->
<div class="form-container" style="margin-bottom: 16px;"> <div class="form-container" style="margin-bottom: 16px;">
<div class="form-group"> <div class="form-group">
+27 -1
View File
@@ -9,12 +9,38 @@ let stage2Basename = "";
let stage2Cwd = ""; let stage2Cwd = "";
/** /**
* Called by printtarg.js after Stage 2 completes. * Called by printtarg.js after Stage 2 completes, or when resuming from .ti2.
*/ */
export function setStage2Result(basename, cwd) { export function setStage2Result(basename, cwd) {
stage2Basename = basename || wizardState.basename; stage2Basename = basename || wizardState.basename;
stage2Cwd = cwd || wizardState.cwd; stage2Cwd = cwd || wizardState.cwd;
wizardState.setTarget(stage2Basename, stage2Cwd); wizardState.setTarget(stage2Basename, stage2Cwd);
populateStage3TargetContext();
}
export function populateStage3TargetContext(metadata) {
const banner = document.getElementById("stage3LoadedTargetBanner");
const basenameEl = document.getElementById("stage3TargetBasename");
const metaEl = document.getElementById("stage3TargetMeta");
const badgeEl = document.getElementById("stage3TargetBadge");
const basename = (metadata && metadata.basename) || stage2Basename || wizardState.basename;
const cwd = (metadata && metadata.cwd) || stage2Cwd || wizardState.cwd;
if (basename && banner) {
banner.classList.remove("hidden");
if (basenameEl) basenameEl.textContent = `${basename}.ti2`;
if (metaEl) {
const parts = [];
if (metadata && metadata.patch_count) parts.push(`${metadata.patch_count} patches`);
if (metadata && metadata.instrument) parts.push(`Instrument: ${metadata.instrument}`);
if (cwd) parts.push(`Dir: ${cwd}`);
metaEl.textContent = parts.join(" • ");
}
if (badgeEl) {
badgeEl.textContent = metadata ? "Resumed from .ti2" : "Generated";
}
}
} }
// State machine states // State machine states
+26 -1
View File
@@ -8,7 +8,32 @@ export const wizardState = {
setTarget(basename, cwd) { setTarget(basename, cwd) {
if (basename) this.basename = basename; if (basename) this.basename = basename;
if (cwd) this.cwd = cwd; if (cwd) this.cwd = cwd;
this.updateGating(); return this.updateGating();
},
navigateToStage(stageNumber) {
this.currentStage = stageNumber;
const steps = document.querySelectorAll('.step');
const stages = document.querySelectorAll('.stage');
steps.forEach(s => {
if (s.getAttribute('data-step') === String(stageNumber)) {
s.classList.remove('disabled');
s.classList.add('active');
} else {
s.classList.remove('active');
}
});
stages.forEach(s => {
if (s.id === `stage-${stageNumber}`) {
s.classList.remove('hidden');
s.classList.add('active');
} else {
s.classList.remove('active');
s.classList.add('hidden');
}
});
}, },
async updateGating() { async updateGating() {
+55 -2
View File
@@ -1,6 +1,7 @@
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event; const { listen } = window.__TAURI__.event;
import { setStage1Result } from './printtarg.js'; import { setStage1Result } from './printtarg.js';
import { populateStage3TargetContext } from './chartread.js';
import { wizardState } from './state.js'; import { wizardState } from './state.js';
export function initTargen() { export function initTargen() {
@@ -12,6 +13,7 @@ export function initTargen() {
const targetBasename = document.getElementById("targetBasename"); const targetBasename = document.getElementById("targetBasename");
const selectedPathDisplay = document.getElementById("selectedPathDisplay"); const selectedPathDisplay = document.getElementById("selectedPathDisplay");
const btnBrowse = document.getElementById("btnBrowse"); const btnBrowse = document.getElementById("btnBrowse");
const btnOpenExisting = document.getElementById("btnOpenExisting");
const btnGenerate = document.getElementById("btnGenerate"); const btnGenerate = document.getElementById("btnGenerate");
const logContainer = document.getElementById("targenLogContainer"); const logContainer = document.getElementById("targenLogContainer");
const logPre = document.getElementById("targenLog"); const logPre = document.getElementById("targenLog");
@@ -100,13 +102,64 @@ export function initTargen() {
} }
updateGenerateButton(); updateGenerateButton();
} }
} catch (err) } catch (err) {
{
console.error("Failed to open file dialog:", err); console.error("Failed to open file dialog:", err);
} }
}); });
} }
// Open existing target (.ti1 or .ti2)
if (btnOpenExisting) {
btnOpenExisting.addEventListener("click", async () => {
try {
const filePath = await invoke("select_existing_target", {
defaultDir: currentWorkingDir || null,
});
if (filePath) {
const isWindows = filePath.includes('\\');
const sep = isWindows ? '\\' : '/';
const parts = filePath.split(sep);
const fileName = parts.pop();
const isTi2 = fileName.toLowerCase().endsWith('.ti2');
currentWorkingDir = parts.join(sep);
const basename = fileName.replace(/\.(ti1|ti2)$/i, '');
if (targetBasename) {
targetBasename.value = basename;
}
if (selectedPathDisplay) {
selectedPathDisplay.textContent = `Directory: ${currentWorkingDir}`;
}
updateGenerateButton();
if (isTi2) {
// Parse .ti2 metadata and jump straight to Stage 3
try {
const meta = await invoke("parse_ti2_header", { filePath });
await wizardState.setTarget(meta.basename, meta.cwd);
populateStage3TargetContext(meta);
wizardState.navigateToStage(3);
} catch (parseErr) {
console.warn("Could not parse .ti2 header, navigating with basic target:", parseErr);
await wizardState.setTarget(basename, currentWorkingDir);
populateStage3TargetContext({ basename, cwd: currentWorkingDir });
wizardState.navigateToStage(3);
}
} else {
// .ti1 selected -> advance to Stage 2 (Print Layout)
await wizardState.setTarget(basename, currentWorkingDir);
setStage1Result(basename, currentWorkingDir);
wizardState.navigateToStage(2);
}
}
} catch (err) {
console.error("Failed to open existing target:", err);
}
});
}
// Generate button clicks // Generate button clicks
if (btnGenerate) { if (btnGenerate) {
btnGenerate.addEventListener("click", async () => { btnGenerate.addEventListener("click", async () => {