feat(stage3): support opening existing .ti2 target files to jump to Stage 3 (resolves #140) #143
@@ -166,6 +166,101 @@ pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
|
||||
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]
|
||||
pub async fn select_target_file(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -33,6 +33,8 @@ pub fn run() {
|
||||
commands::get_default_working_dir,
|
||||
commands::get_log_path,
|
||||
commands::open_log_dir,
|
||||
commands::parse_ti2_header,
|
||||
commands::select_existing_target,
|
||||
commands::select_target_file,
|
||||
commands::select_directory,
|
||||
commands::send_stdin,
|
||||
|
||||
+13
-1
@@ -97,7 +97,10 @@
|
||||
</div>
|
||||
|
||||
<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 & 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">
|
||||
<input type="text" id="targetBasename" placeholder="e.g. my_paper_profile">
|
||||
<button type="button" id="btnBrowse" class="secondary">Browse...</button>
|
||||
@@ -305,6 +308,15 @@
|
||||
<h2>Measure Target</h2>
|
||||
<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 -->
|
||||
<div class="form-container" style="margin-bottom: 16px;">
|
||||
<div class="form-group">
|
||||
|
||||
+27
-1
@@ -9,12 +9,38 @@ let stage2Basename = "";
|
||||
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) {
|
||||
stage2Basename = basename || wizardState.basename;
|
||||
stage2Cwd = cwd || wizardState.cwd;
|
||||
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
|
||||
|
||||
+26
-1
@@ -8,7 +8,32 @@ export const wizardState = {
|
||||
setTarget(basename, cwd) {
|
||||
if (basename) this.basename = basename;
|
||||
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() {
|
||||
|
||||
+55
-2
@@ -1,6 +1,7 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
import { setStage1Result } from './printtarg.js';
|
||||
import { populateStage3TargetContext } from './chartread.js';
|
||||
import { wizardState } from './state.js';
|
||||
|
||||
export function initTargen() {
|
||||
@@ -12,6 +13,7 @@ export function initTargen() {
|
||||
const targetBasename = document.getElementById("targetBasename");
|
||||
const selectedPathDisplay = document.getElementById("selectedPathDisplay");
|
||||
const btnBrowse = document.getElementById("btnBrowse");
|
||||
const btnOpenExisting = document.getElementById("btnOpenExisting");
|
||||
const btnGenerate = document.getElementById("btnGenerate");
|
||||
const logContainer = document.getElementById("targenLogContainer");
|
||||
const logPre = document.getElementById("targenLog");
|
||||
@@ -100,13 +102,64 @@ export function initTargen() {
|
||||
}
|
||||
updateGenerateButton();
|
||||
}
|
||||
} catch (err)
|
||||
{
|
||||
} catch (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
|
||||
if (btnGenerate) {
|
||||
btnGenerate.addEventListener("click", async () => {
|
||||
|
||||
Reference in New Issue
Block a user