diff --git a/crates/procnote-core/src/event/log.rs b/crates/procnote-core/src/event/log.rs index 14cc6d0..34664ac 100644 --- a/crates/procnote-core/src/event/log.rs +++ b/crates/procnote-core/src/event/log.rs @@ -76,19 +76,19 @@ mod tests { Event::StepStarted { at: now, execution_id: id, - step_heading: "Preconditions".to_string(), + step_id: "step-0".to_string(), }, Event::CheckboxToggled { at: now, execution_id: id, - step_heading: "Preconditions".to_string(), - text: "Chamber pressure < 1e-5 Pa".to_string(), + step_id: "step-0".to_string(), + checkbox_id: "step-0/cb-0".to_string(), checked: true, }, Event::StepCompleted { at: now, execution_id: id, - step_heading: "Preconditions".to_string(), + step_id: "step-0".to_string(), }, Event::ExecutionCompleted { at: now, @@ -210,40 +210,41 @@ mod tests { Event::StepAdded { at: now, execution_id: id, + step_id: "dyn-step-1".to_string(), heading: "New Step".to_string(), content: vec![StepContent::Prose { text: "Added during execution".to_string(), }], - after_step: Some("Preconditions".to_string()), + after_step_id: Some("step-0".to_string()), }, Event::StepStarted { at: now, execution_id: id, - step_heading: "Step 1".to_string(), + step_id: "step-0".to_string(), }, Event::StepCompleted { at: now, execution_id: id, - step_heading: "Step 1".to_string(), + step_id: "step-0".to_string(), }, Event::StepSkipped { at: now, execution_id: id, - step_heading: "Step 2".to_string(), + step_id: "step-1".to_string(), reason: "Not applicable".to_string(), }, Event::CheckboxToggled { at: now, execution_id: id, - step_heading: "Step 1".to_string(), - text: "Check item".to_string(), + step_id: "step-0".to_string(), + checkbox_id: "step-0/cb-0".to_string(), checked: true, }, Event::InputRecorded { at: now, execution_id: id, - step_heading: "Step 1".to_string(), - label: "Current".to_string(), + step_id: "step-0".to_string(), + input_id: "current-draw".to_string(), value: "120".to_string(), unit: Some("mA".to_string()), }, @@ -251,13 +252,13 @@ mod tests { at: now, execution_id: id, text: "Observation noted".to_string(), - step_heading: Some("Step 1".to_string()), + step_id: Some("step-0".to_string()), }, Event::AttachmentAdded { at: now, execution_id: id, - step_heading: "Step 1".to_string(), - label: "Log file".to_string(), + step_id: "step-0".to_string(), + input_id: "log-file".to_string(), filename: "photo.jpg".to_string(), path: "attachments/photo.jpg".to_string(), content_type: "image/jpeg".to_string(), diff --git a/crates/procnote-core/src/event/types.rs b/crates/procnote-core/src/event/types.rs index e62864d..cca52dd 100644 --- a/crates/procnote-core/src/event/types.rs +++ b/crates/procnote-core/src/event/types.rs @@ -47,28 +47,31 @@ pub enum Event { StepAdded { at: DateTime, execution_id: ExecutionId, + /// Stable element ID for this step. + step_id: String, heading: String, /// Ordered content items from the template (prose, checkboxes, input blocks). + /// Checkbox and input items carry their own IDs. #[serde(default, skip_serializing_if = "Vec::is_empty")] content: Vec, - /// Insert after this step heading. `None` means append at end. + /// Insert after this step ID. `None` means append at end. #[serde(skip_serializing_if = "Option::is_none")] - after_step: Option, + after_step_id: Option, }, StepStarted { at: DateTime, execution_id: ExecutionId, - step_heading: String, + step_id: String, }, StepCompleted { at: DateTime, execution_id: ExecutionId, - step_heading: String, + step_id: String, }, StepSkipped { at: DateTime, execution_id: ExecutionId, - step_heading: String, + step_id: String, reason: String, }, @@ -76,15 +79,15 @@ pub enum Event { CheckboxToggled { at: DateTime, execution_id: ExecutionId, - step_heading: String, - text: String, + step_id: String, + checkbox_id: String, checked: bool, }, InputRecorded { at: DateTime, execution_id: ExecutionId, - step_heading: String, - label: String, + step_id: String, + input_id: String, value: String, #[serde(skip_serializing_if = "Option::is_none")] unit: Option, @@ -94,15 +97,15 @@ pub enum Event { execution_id: ExecutionId, text: String, #[serde(skip_serializing_if = "Option::is_none")] - step_heading: Option, + step_id: Option, }, // -- Attachment -- AttachmentAdded { at: DateTime, execution_id: ExecutionId, - step_heading: String, - label: String, + step_id: String, + input_id: String, filename: String, path: String, content_type: String, @@ -185,42 +188,34 @@ impl Event { format!("Aborted execution: {reason}") } Self::StepAdded { heading, .. } => format!("Added step: {heading}"), - Self::StepStarted { step_heading, .. } => { - format!("Started step: {step_heading}") + Self::StepStarted { step_id, .. } => { + format!("Started step: {step_id}") } - Self::StepCompleted { step_heading, .. } => { - format!("Completed step: {step_heading}") + Self::StepCompleted { step_id, .. } => { + format!("Completed step: {step_id}") } Self::StepSkipped { - step_heading, - reason, - .. + step_id, reason, .. } => { - format!("Skipped step: {step_heading} ({reason})") + format!("Skipped step: {step_id} ({reason})") } Self::CheckboxToggled { - step_heading, - text, + checkbox_id, checked, .. } => { let verb = if *checked { "Checked" } else { "Unchecked" }; - format!("{verb} checkbox '{text}' in {step_heading}") + format!("{verb} checkbox {checkbox_id}") } Self::InputRecorded { - step_heading, - label, - value, - .. + input_id, value, .. } => { - format!("Recorded {label} = {value} in {step_heading}") + format!("Recorded {input_id} = {value}") } - Self::NoteAdded { - text, step_heading, .. - } => { - let scope = step_heading + Self::NoteAdded { text, step_id, .. } => { + let scope = step_id .as_ref() - .map(|h| format!(" to {h}")) + .map(|id| format!(" to {id}")) .unwrap_or_default(); let truncated = if text.len() > 50 { format!("{}...", &text[..50]) @@ -230,12 +225,9 @@ impl Event { format!("Added note{scope}: {truncated}") } Self::AttachmentAdded { - step_heading, - label, - filename, - .. + input_id, filename, .. } => { - format!("Recorded {label} = {filename} in {step_heading}") + format!("Recorded {input_id} = {filename}") } Self::ExecutionRenamed { name, .. } => { format!("Renamed execution to: {name}") diff --git a/crates/procnote-core/src/execution/engine.rs b/crates/procnote-core/src/execution/engine.rs index 880ef17..a69b6f3 100644 --- a/crates/procnote-core/src/execution/engine.rs +++ b/crates/procnote-core/src/execution/engine.rs @@ -58,6 +58,8 @@ pub enum StepStatus { /// Tracked state for a single step during execution. #[derive(Debug, Clone)] pub struct StepState { + /// Stable element ID for this step. + pub id: String, pub heading: String, pub status: StepStatus, /// Ordered content items from the template (prose, checkboxes, input blocks). @@ -173,89 +175,92 @@ impl ExecutionState { self.status = ExecutionStatus::Finished(CompletionStatus::Aborted); } Event::StepAdded { + step_id, heading, content, - after_step, + after_step_id, .. } => { self.require_active()?; - if self.steps.contains_key(heading) { + if self.steps.contains_key(step_id) { return Err(ExecutionError::DuplicateStepHeading(heading.clone())); } let step_state = StepState { + id: step_id.clone(), heading: heading.clone(), status: StepStatus::Pending, content: content.clone(), inputs: HashMap::new(), notes: Vec::new(), }; - self.steps.insert(heading.clone(), step_state); - match after_step { + self.steps.insert(step_id.clone(), step_state); + match after_step_id { Some(after) => { - if let Some(pos) = self.step_order.iter().position(|h| h == after) { - self.step_order.insert(pos + 1, heading.clone()); + if let Some(pos) = self.step_order.iter().position(|id| id == after) { + self.step_order.insert(pos + 1, step_id.clone()); } else { - self.step_order.push(heading.clone()); + self.step_order.push(step_id.clone()); } } None => { - self.step_order.push(heading.clone()); + self.step_order.push(step_id.clone()); } } } - Event::StepStarted { step_heading, .. } => { + Event::StepStarted { step_id, .. } => { self.require_active()?; - let step = self.get_step_mut(step_heading)?; + let step = self.get_step_mut(step_id)?; match step.status { StepStatus::Pending => step.status = StepStatus::Active, StepStatus::Active => { - return Err(ExecutionError::StepAlreadyStarted(step_heading.clone())); + return Err(ExecutionError::StepAlreadyStarted(step_id.clone())); } StepStatus::Completed | StepStatus::Skipped => { - return Err(ExecutionError::StepAlreadyFinished(step_heading.clone())); + return Err(ExecutionError::StepAlreadyFinished(step_id.clone())); } } } - Event::StepCompleted { step_heading, .. } => { + Event::StepCompleted { step_id, .. } => { self.require_active()?; - let step = self.get_step_mut(step_heading)?; + let step = self.get_step_mut(step_id)?; match step.status { StepStatus::Active => step.status = StepStatus::Completed, StepStatus::Pending => { - return Err(ExecutionError::StepNotStarted(step_heading.clone())); + return Err(ExecutionError::StepNotStarted(step_id.clone())); } StepStatus::Completed | StepStatus::Skipped => { - return Err(ExecutionError::StepAlreadyFinished(step_heading.clone())); + return Err(ExecutionError::StepAlreadyFinished(step_id.clone())); } } } - Event::StepSkipped { step_heading, .. } => { + Event::StepSkipped { step_id, .. } => { self.require_active()?; - let step = self.get_step_mut(step_heading)?; + let step = self.get_step_mut(step_id)?; match step.status { StepStatus::Pending | StepStatus::Active => { step.status = StepStatus::Skipped; } StepStatus::Completed | StepStatus::Skipped => { - return Err(ExecutionError::StepAlreadyFinished(step_heading.clone())); + return Err(ExecutionError::StepAlreadyFinished(step_id.clone())); } } } Event::CheckboxToggled { - step_heading, - text, + step_id, + checkbox_id, checked, .. } => { self.require_active()?; - let step = self.get_step_mut(step_heading)?; - // Find matching checkbox in content and update in-place. + let step = self.get_step_mut(step_id)?; + // Find matching checkbox in content by ID and update in-place. let found = step.content.iter_mut().any(|item| { if let StepContent::Checkbox { - text: t, + id: Some(id), checked: c, + .. } = item - && t == text + && id == checkbox_id { *c = *checked; return true; @@ -265,36 +270,35 @@ impl ExecutionState { if !found { // Checkbox not from template — add dynamically. step.content.push(StepContent::Checkbox { - text: text.clone(), + id: Some(checkbox_id.clone()), + text: String::new(), checked: *checked, }); } } Event::InputRecorded { - step_heading, - label, + step_id, + input_id, value, unit, .. } => { self.require_active()?; - let step = self.get_step_mut(step_heading)?; + let step = self.get_step_mut(step_id)?; step.inputs.insert( - label.clone(), + input_id.clone(), RecordedInput { - label: label.clone(), + label: input_id.clone(), value: value.clone(), unit: unit.clone(), }, ); } - Event::NoteAdded { - text, step_heading, .. - } => { + Event::NoteAdded { text, step_id, .. } => { self.require_active()?; - match step_heading { - Some(heading) => { - let step = self.get_step_mut(heading)?; + match step_id { + Some(id) => { + let step = self.get_step_mut(id)?; step.notes.push(text.clone()); } None => { @@ -304,17 +308,17 @@ impl ExecutionState { } Event::AttachmentAdded { - step_heading, - label, + step_id, + input_id, filename, .. } => { self.require_active()?; - let step = self.get_step_mut(step_heading)?; + let step = self.get_step_mut(step_id)?; step.inputs.insert( - label.clone(), + input_id.clone(), RecordedInput { - label: label.clone(), + label: input_id.clone(), value: filename.clone(), unit: None, }, @@ -371,13 +375,37 @@ impl ExecutionState { events.push(named); // Add steps from the template, preserving content order. - for step in &template.steps { + // Assign stable IDs to each step and its interactive content items. + for (step_index, step) in template.steps.iter().enumerate() { + let step_id = format!("step-{step_index}"); + + // Assign IDs to content items. + let mut cb_index = 0usize; + let content: Vec = step + .content + .iter() + .map(|item| match item { + StepContent::Checkbox { text, checked, .. } => { + let id = format!("{step_id}/cb-{cb_index}"); + cb_index += 1; + StepContent::Checkbox { + id: Some(id), + text: text.clone(), + checked: *checked, + } + } + // InputDefinition already has its own `id` from the template YAML. + other => other.clone(), + }) + .collect(); + let step_added = Event::StepAdded { at: now, execution_id, + step_id, heading: step.heading.clone(), - content: step.content.clone(), - after_step: None, + content, + after_step_id: None, }; self.apply(&step_added)?; events.push(step_added); @@ -403,53 +431,55 @@ impl ExecutionState { /// Add a new step during execution. pub fn add_step( &mut self, + step_id: &str, heading: &str, content: Vec, - after_step: Option<&str>, + after_step_id: Option<&str>, ) -> Result { self.require_active()?; let event = Event::StepAdded { at: Utc::now(), execution_id: self.require_execution_id()?, + step_id: step_id.to_string(), heading: heading.to_string(), content, - after_step: after_step.map(std::string::ToString::to_string), + after_step_id: after_step_id.map(std::string::ToString::to_string), }; self.apply(&event)?; Ok(event) } /// Start a step. - pub fn start_step(&mut self, heading: &str) -> Result { + pub fn start_step(&mut self, step_id: &str) -> Result { self.require_active()?; let event = Event::StepStarted { at: Utc::now(), execution_id: self.require_execution_id()?, - step_heading: heading.to_string(), + step_id: step_id.to_string(), }; self.apply(&event)?; Ok(event) } /// Complete a step. - pub fn complete_step(&mut self, heading: &str) -> Result { + pub fn complete_step(&mut self, step_id: &str) -> Result { self.require_active()?; let event = Event::StepCompleted { at: Utc::now(), execution_id: self.require_execution_id()?, - step_heading: heading.to_string(), + step_id: step_id.to_string(), }; self.apply(&event)?; Ok(event) } /// Skip a step. - pub fn skip_step(&mut self, heading: &str, reason: &str) -> Result { + pub fn skip_step(&mut self, step_id: &str, reason: &str) -> Result { self.require_active()?; let event = Event::StepSkipped { at: Utc::now(), execution_id: self.require_execution_id()?, - step_heading: heading.to_string(), + step_id: step_id.to_string(), reason: reason.to_string(), }; self.apply(&event)?; @@ -459,16 +489,16 @@ impl ExecutionState { /// Toggle a checkbox in a step. pub fn toggle_checkbox( &mut self, - step_heading: &str, - text: &str, + step_id: &str, + checkbox_id: &str, checked: bool, ) -> Result { self.require_active()?; let event = Event::CheckboxToggled { at: Utc::now(), execution_id: self.require_execution_id()?, - step_heading: step_heading.to_string(), - text: text.to_string(), + step_id: step_id.to_string(), + checkbox_id: checkbox_id.to_string(), checked, }; self.apply(&event)?; @@ -478,8 +508,8 @@ impl ExecutionState { /// Record an input value. pub fn record_input( &mut self, - step_heading: &str, - label: &str, + step_id: &str, + input_id: &str, value: &str, unit: Option<&str>, ) -> Result { @@ -487,8 +517,8 @@ impl ExecutionState { let event = Event::InputRecorded { at: Utc::now(), execution_id: self.require_execution_id()?, - step_heading: step_heading.to_string(), - label: label.to_string(), + step_id: step_id.to_string(), + input_id: input_id.to_string(), value: value.to_string(), unit: unit.map(std::string::ToString::to_string), }; @@ -497,17 +527,13 @@ impl ExecutionState { } /// Add a note. - pub fn add_note( - &mut self, - text: &str, - step_heading: Option<&str>, - ) -> Result { + pub fn add_note(&mut self, text: &str, step_id: Option<&str>) -> Result { self.require_active()?; let event = Event::NoteAdded { at: Utc::now(), execution_id: self.require_execution_id()?, text: text.to_string(), - step_heading: step_heading.map(std::string::ToString::to_string), + step_id: step_id.map(std::string::ToString::to_string), }; self.apply(&event)?; Ok(event) @@ -516,8 +542,8 @@ impl ExecutionState { /// Add an attachment. pub fn add_attachment( &mut self, - step_heading: &str, - label: &str, + step_id: &str, + input_id: &str, filename: &str, path: &str, content_type: &str, @@ -527,8 +553,8 @@ impl ExecutionState { let event = Event::AttachmentAdded { at: Utc::now(), execution_id: self.require_execution_id()?, - step_heading: step_heading.to_string(), - label: label.to_string(), + step_id: step_id.to_string(), + input_id: input_id.to_string(), filename: filename.to_string(), path: path.to_string(), content_type: content_type.to_string(), @@ -636,10 +662,10 @@ impl ExecutionState { self.execution_id.ok_or(ExecutionError::NotStarted) } - fn get_step_mut(&mut self, heading: &str) -> Result<&mut StepState, ExecutionError> { + fn get_step_mut(&mut self, step_id: &str) -> Result<&mut StepState, ExecutionError> { self.steps - .get_mut(heading) - .ok_or_else(|| ExecutionError::StepNotFound(heading.to_string())) + .get_mut(step_id) + .ok_or_else(|| ExecutionError::StepNotFound(step_id.to_string())) } } @@ -667,14 +693,17 @@ mod tests { }, steps: vec![ Step { + id: None, heading: "Preconditions".to_string(), content: vec![], }, Step { + id: None, heading: "Step 1: Power On".to_string(), content: vec![], }, Step { + id: None, heading: "Postconditions".to_string(), content: vec![], }, @@ -693,9 +722,13 @@ mod tests { assert_eq!(state.status, ExecutionStatus::Active); assert!(state.name.is_some()); assert_eq!(state.step_order.len(), 3); - assert_eq!(state.step_order[0], "Preconditions"); - assert_eq!(state.step_order[1], "Step 1: Power On"); - assert_eq!(state.step_order[2], "Postconditions"); + assert_eq!(state.step_order[0], "step-0"); + assert_eq!(state.step_order[1], "step-1"); + assert_eq!(state.step_order[2], "step-2"); + // Verify headings are still preserved + assert_eq!(state.steps["step-0"].heading, "Preconditions"); + assert_eq!(state.steps["step-1"].heading, "Step 1: Power On"); + assert_eq!(state.steps["step-2"].heading, "Postconditions"); } #[test] @@ -738,31 +771,27 @@ mod tests { // Start all_events.extend(state.start(&template).unwrap()); - // Step through preconditions - all_events.push(state.start_step("Preconditions").unwrap()); + // Step through preconditions (step-0) + all_events.push(state.start_step("step-0").unwrap()); all_events.push( state - .toggle_checkbox("Preconditions", "Check 1", true) + .toggle_checkbox("step-0", "step-0/cb-0", true) .unwrap(), ); - all_events.push(state.complete_step("Preconditions").unwrap()); + all_events.push(state.complete_step("step-0").unwrap()); - // Step 1 - all_events.push(state.start_step("Step 1: Power On").unwrap()); - all_events.push( - state - .record_input("Step 1: Power On", "Current", "120", Some("mA")) - .unwrap(), - ); + // Step 1 (step-1) + all_events.push(state.start_step("step-1").unwrap()); all_events.push( state - .add_note("Voltage stable", Some("Step 1: Power On")) + .record_input("step-1", "current-draw", "120", Some("mA")) .unwrap(), ); - all_events.push(state.complete_step("Step 1: Power On").unwrap()); + all_events.push(state.add_note("Voltage stable", Some("step-1")).unwrap()); + all_events.push(state.complete_step("step-1").unwrap()); - // Skip postconditions - all_events.push(state.skip_step("Postconditions", "Not applicable").unwrap()); + // Skip postconditions (step-2) + all_events.push(state.skip_step("step-2", "Not applicable").unwrap()); // Complete all_events.push(state.complete(CompletionStatus::Pass).unwrap()); @@ -771,20 +800,14 @@ mod tests { state.status, ExecutionStatus::Finished(CompletionStatus::Pass) ); - assert_eq!(state.steps["Preconditions"].status, StepStatus::Completed); - assert_eq!( - state.steps["Step 1: Power On"].status, - StepStatus::Completed - ); - assert_eq!(state.steps["Postconditions"].status, StepStatus::Skipped); - assert!(state.steps["Preconditions"].content.iter().any(|item| { - matches!(item, StepContent::Checkbox { text, checked } if text == "Check 1" && *checked) + assert_eq!(state.steps["step-0"].status, StepStatus::Completed); + assert_eq!(state.steps["step-1"].status, StepStatus::Completed); + assert_eq!(state.steps["step-2"].status, StepStatus::Skipped); + assert!(state.steps["step-0"].content.iter().any(|item| { + matches!(item, StepContent::Checkbox { id: Some(id), checked, .. } if id == "step-0/cb-0" && *checked) })); - assert_eq!( - state.steps["Step 1: Power On"].inputs["Current"].value, - "120" - ); - assert_eq!(state.steps["Step 1: Power On"].notes.len(), 1); + assert_eq!(state.steps["step-1"].inputs["current-draw"].value, "120"); + assert_eq!(state.steps["step-1"].notes.len(), 1); // Replay from events let replayed = ExecutionState::from_events(&all_events).unwrap(); @@ -793,8 +816,8 @@ mod tests { ExecutionStatus::Finished(CompletionStatus::Pass) ); assert_eq!(replayed.step_order.len(), 3); - assert!(replayed.steps["Preconditions"].content.iter().any(|item| { - matches!(item, StepContent::Checkbox { text, checked } if text == "Check 1" && *checked) + assert!(replayed.steps["step-0"].content.iter().any(|item| { + matches!(item, StepContent::Checkbox { id: Some(id), checked, .. } if id == "step-0/cb-0" && *checked) })); } @@ -804,22 +827,23 @@ mod tests { let mut state = ExecutionState::new(); state.start(&template).unwrap(); - // Add a step after "Step 1: Power On" + // Add a step after "step-1" (Step 1: Power On) state .add_step( + "dyn-step-1", "Step 1.5: Verification", vec![StepContent::Prose { text: "Extra verification step".to_string(), }], - Some("Step 1: Power On"), + Some("step-1"), ) .unwrap(); assert_eq!(state.step_order.len(), 4); - assert_eq!(state.step_order[0], "Preconditions"); - assert_eq!(state.step_order[1], "Step 1: Power On"); - assert_eq!(state.step_order[2], "Step 1.5: Verification"); - assert_eq!(state.step_order[3], "Postconditions"); + assert_eq!(state.step_order[0], "step-0"); + assert_eq!(state.step_order[1], "step-1"); + assert_eq!(state.step_order[2], "dyn-step-1"); + assert_eq!(state.step_order[3], "step-2"); } #[test] @@ -834,7 +858,7 @@ mod tests { #[test] fn test_cannot_act_before_start() { let mut state = ExecutionState::new(); - let result = state.start_step("Step 1"); + let result = state.start_step("step-0"); assert_eq!(result.unwrap_err(), ExecutionError::NotStarted); } @@ -845,7 +869,7 @@ mod tests { state.start(&template).unwrap(); state.complete(CompletionStatus::Pass).unwrap(); - let result = state.start_step("Preconditions"); + let result = state.start_step("step-0"); assert_eq!(result.unwrap_err(), ExecutionError::AlreadyFinished); } @@ -855,10 +879,10 @@ mod tests { let mut state = ExecutionState::new(); state.start(&template).unwrap(); - let result = state.complete_step("Preconditions"); + let result = state.complete_step("step-0"); assert_eq!( result.unwrap_err(), - ExecutionError::StepNotStarted("Preconditions".to_string()) + ExecutionError::StepNotStarted("step-0".to_string()) ); } @@ -867,13 +891,13 @@ mod tests { let template = sample_template(); let mut state = ExecutionState::new(); state.start(&template).unwrap(); - state.start_step("Preconditions").unwrap(); - state.complete_step("Preconditions").unwrap(); + state.start_step("step-0").unwrap(); + state.complete_step("step-0").unwrap(); - let result = state.start_step("Preconditions"); + let result = state.start_step("step-0"); assert_eq!( result.unwrap_err(), - ExecutionError::StepAlreadyFinished("Preconditions".to_string()) + ExecutionError::StepAlreadyFinished("step-0".to_string()) ); } @@ -895,12 +919,12 @@ mod tests { let template = sample_template(); let mut state = ExecutionState::new(); state.start(&template).unwrap(); - state.start_step("Step 1: Power On").unwrap(); + state.start_step("step-1").unwrap(); state .add_attachment( - "Step 1: Power On", - "Log file", + "step-1", + "log-file", "photo.jpg", "attachments/photo.jpg", "image/jpeg", @@ -908,7 +932,7 @@ mod tests { ) .unwrap(); - let input = &state.steps["Step 1: Power On"].inputs["Log file"]; + let input = &state.steps["step-1"].inputs["log-file"]; assert_eq!(input.value, "photo.jpg"); } @@ -925,15 +949,15 @@ mod tests { } #[test] - fn test_duplicate_step_heading() { + fn test_duplicate_step_id() { let template = sample_template(); let mut state = ExecutionState::new(); state.start(&template).unwrap(); - let result = state.add_step("Preconditions", vec![], None); + let result = state.add_step("step-0", "Preconditions Again", vec![], None); assert_eq!( result.unwrap_err(), - ExecutionError::DuplicateStepHeading("Preconditions".to_string()) + ExecutionError::DuplicateStepHeading("Preconditions Again".to_string()) ); } @@ -946,8 +970,8 @@ mod tests { let mut events: Vec = Vec::new(); events.extend(state.start(&template).unwrap()); // indices 0..4: ExecutionStarted + ExecutionRenamed + 3 StepAdded - events.push(state.start_step("Preconditions").unwrap()); // index 5 - events.push(state.complete_step("Preconditions").unwrap()); // index 6 + events.push(state.start_step("step-0").unwrap()); // index 5 + events.push(state.complete_step("step-0").unwrap()); // index 6 events } @@ -960,7 +984,7 @@ mod tests { let state = ExecutionState::from_events(&events).unwrap(); // Step should be back to Active (StepStarted still applies) - assert_eq!(state.steps["Preconditions"].status, StepStatus::Active); + assert_eq!(state.steps["step-0"].status, StepStatus::Active); } #[test] @@ -969,13 +993,13 @@ mod tests { let mut state = ExecutionState::new(); let mut events: Vec = Vec::new(); events.extend(state.start(&template).unwrap()); - events.push(state.start_step("Preconditions").unwrap()); // index 5 + events.push(state.start_step("step-0").unwrap()); // index 5 let revert = ExecutionState::revert_event(&events, 5, "wrong step").unwrap(); events.push(revert); let state = ExecutionState::from_events(&events).unwrap(); - assert_eq!(state.steps["Preconditions"].status, StepStatus::Pending); + assert_eq!(state.steps["step-0"].status, StepStatus::Pending); } #[test] @@ -984,13 +1008,13 @@ mod tests { let mut state = ExecutionState::new(); let mut events: Vec = Vec::new(); events.extend(state.start(&template).unwrap()); - events.push(state.skip_step("Preconditions", "N/A").unwrap()); // index 5 + events.push(state.skip_step("step-0", "N/A").unwrap()); // index 5 let revert = ExecutionState::revert_event(&events, 5, "actually needed").unwrap(); events.push(revert); let state = ExecutionState::from_events(&events).unwrap(); - assert_eq!(state.steps["Preconditions"].status, StepStatus::Pending); + assert_eq!(state.steps["step-0"].status, StepStatus::Pending); } #[test] @@ -999,10 +1023,10 @@ mod tests { let mut state = ExecutionState::new(); let mut events: Vec = Vec::new(); events.extend(state.start(&template).unwrap()); - events.push(state.start_step("Preconditions").unwrap()); + events.push(state.start_step("step-0").unwrap()); events.push( state - .record_input("Preconditions", "Voltage", "5.0", Some("V")) + .record_input("step-0", "voltage", "5.0", Some("V")) .unwrap(), ); // index 6 @@ -1010,7 +1034,7 @@ mod tests { events.push(revert); let state = ExecutionState::from_events(&events).unwrap(); - assert!(!state.steps["Preconditions"].inputs.contains_key("Voltage")); + assert!(!state.steps["step-0"].inputs.contains_key("voltage")); } #[test] @@ -1034,10 +1058,10 @@ mod tests { let mut state = ExecutionState::new(); let mut events: Vec = Vec::new(); events.extend(state.start(&template).unwrap()); - events.push(state.start_step("Preconditions").unwrap()); + events.push(state.start_step("step-0").unwrap()); events.push( state - .toggle_checkbox("Preconditions", "Check A", true) + .toggle_checkbox("step-0", "step-0/dyn-cb-0", true) .unwrap(), ); // index 6 @@ -1047,8 +1071,8 @@ mod tests { let state = ExecutionState::from_events(&events).unwrap(); // The checkbox was dynamically added by the toggle; reverting removes it entirely // since it was not in the template. - assert!(!state.steps["Preconditions"].content.iter().any(|item| { - matches!(item, StepContent::Checkbox { text, .. } if text == "Check A") + assert!(!state.steps["step-0"].content.iter().any(|item| { + matches!(item, StepContent::Checkbox { id: Some(id), .. } if id == "step-0/dyn-cb-0") })); } @@ -1073,12 +1097,12 @@ mod tests { let mut state = ExecutionState::new(); let mut events: Vec = Vec::new(); events.extend(state.start(&template).unwrap()); - events.push(state.start_step("Step 1: Power On").unwrap()); // index 5 + events.push(state.start_step("step-1").unwrap()); // index 5 events.push( state .add_attachment( - "Step 1: Power On", - "Log file", + "step-1", + "log-file", "photo.jpg", "path/photo.jpg", "image/jpeg", @@ -1091,11 +1115,7 @@ mod tests { events.push(revert); let state = ExecutionState::from_events(&events).unwrap(); - assert!( - !state.steps["Step 1: Power On"] - .inputs - .contains_key("Log file") - ); + assert!(!state.steps["step-1"].inputs.contains_key("log-file")); } #[test] @@ -1163,14 +1183,11 @@ mod tests { // Now rebuild state and complete the step again let mut state = ExecutionState::from_events(&events).unwrap(); - assert_eq!(state.steps["Preconditions"].status, StepStatus::Active); - events.push(state.complete_step("Preconditions").unwrap()); + assert_eq!(state.steps["step-0"].status, StepStatus::Active); + events.push(state.complete_step("step-0").unwrap()); let final_state = ExecutionState::from_events(&events).unwrap(); - assert_eq!( - final_state.steps["Preconditions"].status, - StepStatus::Completed - ); + assert_eq!(final_state.steps["step-0"].status, StepStatus::Completed); } #[test] @@ -1194,7 +1211,7 @@ mod tests { .map(|j| serde_json::from_str(j).unwrap()) .collect(); let state = ExecutionState::from_events(&deserialized_events).unwrap(); - assert_eq!(state.steps["Preconditions"].status, StepStatus::Active); + assert_eq!(state.steps["step-0"].status, StepStatus::Active); } #[test] @@ -1204,18 +1221,18 @@ mod tests { let mut events: Vec = Vec::new(); events.extend(state.start(&template).unwrap()); - // Start and complete Preconditions - events.push(state.start_step("Preconditions").unwrap()); // index 5 - events.push(state.complete_step("Preconditions").unwrap()); // index 6 + // Start and complete Preconditions (step-0) + events.push(state.start_step("step-0").unwrap()); // index 5 + events.push(state.complete_step("step-0").unwrap()); // index 6 - // Start and complete Step 1 - events.push(state.start_step("Step 1: Power On").unwrap()); // index 7 + // Start and complete Step 1 (step-1) + events.push(state.start_step("step-1").unwrap()); // index 7 events.push( state - .record_input("Step 1: Power On", "Current", "120", Some("mA")) + .record_input("step-1", "current-draw", "120", Some("mA")) .unwrap(), ); // index 8 - events.push(state.complete_step("Step 1: Power On").unwrap()); // index 9 + events.push(state.complete_step("step-1").unwrap()); // index 9 // Revert Step 1 completion (index 9) let revert1 = ExecutionState::revert_event(&events, 9, "redo step 1").unwrap(); @@ -1226,12 +1243,8 @@ mod tests { events.push(revert2); let rebuilt = ExecutionState::from_events(&events).unwrap(); - assert_eq!(rebuilt.steps["Preconditions"].status, StepStatus::Completed); - assert_eq!(rebuilt.steps["Step 1: Power On"].status, StepStatus::Active); - assert!( - !rebuilt.steps["Step 1: Power On"] - .inputs - .contains_key("Current") - ); + assert_eq!(rebuilt.steps["step-0"].status, StepStatus::Completed); + assert_eq!(rebuilt.steps["step-1"].status, StepStatus::Active); + assert!(!rebuilt.steps["step-1"].inputs.contains_key("current-draw")); } } diff --git a/crates/procnote-core/src/template/parser.rs b/crates/procnote-core/src/template/parser.rs index 24a9fc4..406fa43 100644 --- a/crates/procnote-core/src/template/parser.rs +++ b/crates/procnote-core/src/template/parser.rs @@ -104,6 +104,7 @@ fn parse_body(body: &str) -> Result, ParseError> { // Flush previous step. if let Some(heading) = current_heading.take() { steps.push(Step { + id: None, heading, content: std::mem::take(&mut current_content), }); @@ -153,6 +154,7 @@ fn parse_body(body: &str) -> Result, ParseError> { } let text = collect_task_text(&events, &mut i); current_content.push(StepContent::Checkbox { + id: None, text: text.trim().to_string(), checked, }); @@ -209,6 +211,7 @@ fn parse_body(body: &str) -> Result, ParseError> { flush_prose(body, &mut prose_start, body.len(), &mut current_content); if let Some(heading) = current_heading.take() { steps.push(Step { + id: None, heading, content: std::mem::take(&mut current_content), }); @@ -499,7 +502,7 @@ Execute self-test command via EGSE. .content .iter() .filter_map(|c| match c { - StepContent::Checkbox { text, checked } => Some((text.clone(), *checked)), + StepContent::Checkbox { text, checked, .. } => Some((text.clone(), *checked)), _ => None, }) .collect(); diff --git a/crates/procnote-core/src/template/types.rs b/crates/procnote-core/src/template/types.rs index 77f57f2..83e8335 100644 --- a/crates/procnote-core/src/template/types.rs +++ b/crates/procnote-core/src/template/types.rs @@ -37,6 +37,10 @@ pub struct Equipment { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[ts(export)] pub struct Step { + /// Stable element ID, assigned at execution start. `None` in raw templates. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub id: Option, pub heading: String, pub content: Vec, } @@ -49,7 +53,14 @@ pub enum StepContent { /// Free-form prose text (Markdown source). Prose { text: String }, /// A checkbox item from a task list (`- [ ]` or `- [x]`). - Checkbox { text: String, checked: bool }, + Checkbox { + /// Stable element ID, assigned at execution start. `None` in raw templates. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, + text: String, + checked: bool, + }, /// A block of input definitions from a fenced `inputs` code block. InputBlock { inputs: Vec }, } diff --git a/src-tauri/src/commands/execution.rs b/src-tauri/src/commands/execution.rs index 0917599..c971d4d 100644 --- a/src-tauri/src/commands/execution.rs +++ b/src-tauri/src/commands/execution.rs @@ -46,19 +46,20 @@ pub struct EventHistoryEntry { pub description: String, pub revertible: bool, pub reverted: bool, - /// Step heading for step-scoped events, if applicable. + /// Step ID for step-scoped events, if applicable. #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] - pub step_heading: Option, - /// Label for input/attachment events, if applicable. + pub step_id: Option, + /// Element ID (`checkbox_id` or `input_id`) for element-scoped events, if applicable. #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] - pub label: Option, + pub element_id: Option, } #[derive(Debug, Serialize, TS)] #[ts(export)] pub struct StepSummary { + pub id: String, pub heading: String, pub status: String, /// ISO 8601 timestamp of the most recent status change (started/completed/skipped). @@ -78,6 +79,8 @@ pub enum StepContentSummary { text: String, }, Checkbox { + #[ts(optional)] + id: Option, text: String, checked: bool, /// ISO 8601 timestamp of the last toggle, if any. @@ -173,15 +176,15 @@ fn summarize( // Store as RFC3339 strings to avoid depending on chrono in this crate. let mut started_at: Option = None; let mut finished_at: Option = None; - // step_heading -> most recent status-change timestamp + // step_id -> most recent status-change timestamp let mut step_status_at: HashMap<&str, String> = HashMap::new(); - // (step_heading, checkbox_text) -> most recent toggle timestamp - let mut checkbox_at: HashMap<(&str, &str), String> = HashMap::new(); - // (step_heading, input_label) -> most recent record timestamp - let mut input_at: HashMap<(&str, &str), String> = HashMap::new(); - // (step_heading, label) -> full SHA256 hash for attachments - let mut attachment_sha256: HashMap<(&str, &str), String> = HashMap::new(); - // (step_heading, note_index_in_step) -> add timestamp + // checkbox_id -> most recent toggle timestamp + let mut checkbox_at: HashMap<&str, String> = HashMap::new(); + // input_id -> most recent record timestamp + let mut input_at: HashMap<&str, String> = HashMap::new(); + // input_id -> full SHA256 hash for attachments + let mut attachment_sha256: HashMap<&str, String> = HashMap::new(); + // (step_id, note_index_in_step) -> add timestamp // We count notes per step to match the index in StepState.notes. let mut note_at: HashMap<(&str, usize), String> = HashMap::new(); let mut note_counts: HashMap<&str, usize> = HashMap::new(); @@ -197,50 +200,35 @@ fn summarize( Event::ExecutionCompleted { at, .. } | Event::ExecutionAborted { at, .. } => { finished_at = Some(at.to_rfc3339()); } - Event::StepStarted { - at, step_heading, .. - } - | Event::StepCompleted { - at, step_heading, .. - } - | Event::StepSkipped { - at, step_heading, .. - } => { - step_status_at.insert(step_heading, at.to_rfc3339()); + Event::StepStarted { at, step_id, .. } + | Event::StepCompleted { at, step_id, .. } + | Event::StepSkipped { at, step_id, .. } => { + step_status_at.insert(step_id, at.to_rfc3339()); } Event::CheckboxToggled { - at, - step_heading, - text, - .. + at, checkbox_id, .. } => { - checkbox_at.insert((step_heading, text), at.to_rfc3339()); + checkbox_at.insert(checkbox_id, at.to_rfc3339()); } - Event::InputRecorded { - at, - step_heading, - label, - .. - } => { - input_at.insert((step_heading, label), at.to_rfc3339()); + Event::InputRecorded { at, input_id, .. } => { + input_at.insert(input_id, at.to_rfc3339()); } Event::AttachmentAdded { at, - step_heading, - label, + input_id, sha256, .. } => { - input_at.insert((step_heading, label), at.to_rfc3339()); - attachment_sha256.insert((step_heading, label), sha256.clone()); + input_at.insert(input_id, at.to_rfc3339()); + attachment_sha256.insert(input_id, sha256.clone()); } Event::NoteAdded { at, - step_heading: Some(heading), + step_id: Some(id), .. } => { - let count = note_counts.entry(heading).or_insert(0); - note_at.insert((heading, *count), at.to_rfc3339()); + let count = note_counts.entry(id).or_insert(0); + note_at.insert((id, *count), at.to_rfc3339()); *count += 1; } _ => {} @@ -250,8 +238,8 @@ fn summarize( let steps = state .step_order .iter() - .filter_map(|heading| { - state.steps.get(heading).map(|step| { + .filter_map(|step_id| { + state.steps.get(step_id).map(|step| { let content = step .content .iter() @@ -259,26 +247,27 @@ fn summarize( StepContent::Prose { text } => { StepContentSummary::Prose { text: text.clone() } } - StepContent::Checkbox { text, checked } => StepContentSummary::Checkbox { - text: text.clone(), - checked: *checked, - at: checkbox_at.get(&(heading.as_str(), text.as_str())).cloned(), - }, + StepContent::Checkbox { id, text, checked } => { + StepContentSummary::Checkbox { + id: id.clone(), + text: text.clone(), + checked: *checked, + at: id + .as_ref() + .and_then(|cb_id| checkbox_at.get(cb_id.as_str()).cloned()), + } + } StepContent::InputBlock { inputs } => StepContentSummary::InputBlock { inputs: inputs .iter() .map(|def| { let recorded = - step.inputs.get(&def.label).map(|input| InputState { + step.inputs.get(&def.id).map(|input| InputState { label: input.label.clone(), value: input.value.clone(), unit: input.unit.clone(), - at: input_at - .get(&(heading.as_str(), input.label.as_str())) - .cloned(), - sha256: attachment_sha256 - .get(&(heading.as_str(), input.label.as_str())) - .cloned(), + at: input_at.get(def.id.as_str()).cloned(), + sha256: attachment_sha256.get(def.id.as_str()).cloned(), }); InputDefinitionSummary { definition: def.clone(), @@ -295,13 +284,14 @@ fn summarize( .enumerate() .map(|(i, text)| NoteState { text: text.clone(), - at: note_at.get(&(heading.as_str(), i)).cloned(), + at: note_at.get(&(step_id.as_str(), i)).cloned(), }) .collect(); StepSummary { + id: step_id.clone(), heading: step.heading.clone(), status: step_status_string(&step.status), - status_at: step_status_at.get(heading.as_str()).cloned(), + status_at: step_status_at.get(step_id.as_str()).cloned(), content, notes, } @@ -347,7 +337,7 @@ fn build_event_history(events: &[Event]) -> Vec { .map(|(index, event)| { let revertible = event.revertibility() == Revertibility::Revertible && !reverted_indices.contains(&index); - let (step_heading, label) = event_step_and_label(event); + let (step_id, element_id) = event_step_and_label(event); EventHistoryEntry { index, event_type: event_type_string(event), @@ -355,31 +345,31 @@ fn build_event_history(events: &[Event]) -> Vec { description: event.description(), revertible, reverted: reverted_indices.contains(&index), - step_heading, - label, + step_id, + element_id, } }) .collect() } -/// Extract optional `step_heading` and label from an event. +/// Extract optional `step_id` and `element_id` from an event. fn event_step_and_label(event: &Event) -> (Option, Option) { match event { - Event::StepStarted { step_heading, .. } - | Event::StepCompleted { step_heading, .. } - | Event::StepSkipped { step_heading, .. } - | Event::CheckboxToggled { step_heading, .. } => (Some(step_heading.clone()), None), - Event::InputRecorded { - step_heading, - label, + Event::StepStarted { step_id, .. } + | Event::StepCompleted { step_id, .. } + | Event::StepSkipped { step_id, .. } => (Some(step_id.clone()), None), + Event::CheckboxToggled { + step_id, + checkbox_id, .. + } => (Some(step_id.clone()), Some(checkbox_id.clone())), + Event::InputRecorded { + step_id, input_id, .. } | Event::AttachmentAdded { - step_heading, - label, - .. - } => (Some(step_heading.clone()), Some(label.clone())), - Event::NoteAdded { step_heading, .. } => (step_heading.clone(), None), + step_id, input_id, .. + } => (Some(step_id.clone()), Some(input_id.clone())), + Event::NoteAdded { step_id, .. } => (step_id.clone(), None), _ => (None, None), } } @@ -524,23 +514,23 @@ pub fn start_execution( #[serde(tag = "action", rename_all = "snake_case")] pub enum ExecutionAction { StartStep { - step_heading: String, + step_id: String, }, CompleteStep { - step_heading: String, + step_id: String, }, SkipStep { - step_heading: String, + step_id: String, reason: String, }, ToggleCheckbox { - step_heading: String, - text: String, + step_id: String, + checkbox_id: String, checked: bool, }, RecordInput { - step_heading: String, - label: String, + step_id: String, + input_id: String, value: String, #[ts(optional)] unit: Option, @@ -548,18 +538,19 @@ pub enum ExecutionAction { AddNote { text: String, #[ts(optional)] - step_heading: Option, + step_id: Option, }, AddStep { + step_id: String, heading: String, #[serde(default)] content: Vec, #[ts(optional)] - after_step: Option, + after_step_id: Option, }, AddAttachment { - step_heading: String, - label: String, + step_id: String, + input_id: String, filename: String, path: String, content_type: String, @@ -615,46 +606,44 @@ pub fn record_action( } let event: Event = match action { - ExecutionAction::StartStep { step_heading } => exec_state - .start_step(&step_heading) - .map_err(|e| e.to_string())?, - ExecutionAction::CompleteStep { step_heading } => exec_state - .complete_step(&step_heading) + ExecutionAction::StartStep { step_id } => { + exec_state.start_step(&step_id).map_err(|e| e.to_string())? + } + ExecutionAction::CompleteStep { step_id } => exec_state + .complete_step(&step_id) .map_err(|e| e.to_string())?, - ExecutionAction::SkipStep { - step_heading, - reason, - } => exec_state - .skip_step(&step_heading, &reason) + ExecutionAction::SkipStep { step_id, reason } => exec_state + .skip_step(&step_id, &reason) .map_err(|e| e.to_string())?, ExecutionAction::ToggleCheckbox { - step_heading, - text, + step_id, + checkbox_id, checked, } => exec_state - .toggle_checkbox(&step_heading, &text, checked) + .toggle_checkbox(&step_id, &checkbox_id, checked) .map_err(|e| e.to_string())?, ExecutionAction::RecordInput { - step_heading, - label, + step_id, + input_id, value, unit, } => exec_state - .record_input(&step_heading, &label, &value, unit.as_deref()) + .record_input(&step_id, &input_id, &value, unit.as_deref()) .map_err(|e| e.to_string())?, - ExecutionAction::AddNote { text, step_heading } => exec_state - .add_note(&text, step_heading.as_deref()) + ExecutionAction::AddNote { text, step_id } => exec_state + .add_note(&text, step_id.as_deref()) .map_err(|e| e.to_string())?, ExecutionAction::AddStep { + step_id, heading, content, - after_step, + after_step_id, } => exec_state - .add_step(&heading, content, after_step.as_deref()) + .add_step(&step_id, &heading, content, after_step_id.as_deref()) .map_err(|e| e.to_string())?, ExecutionAction::AddAttachment { - step_heading, - label, + step_id, + input_id, filename, path, content_type, @@ -672,8 +661,8 @@ pub fn record_action( exec_state .add_attachment( - &step_heading, - &label, + &step_id, + &input_id, &filename, &relative_path, &content_type, diff --git a/src/lib/components/AddStepDialog.svelte b/src/lib/components/AddStepDialog.svelte index 648418c..22ba7c0 100644 --- a/src/lib/components/AddStepDialog.svelte +++ b/src/lib/components/AddStepDialog.svelte @@ -2,32 +2,35 @@ import type { StepContent } from "$lib/types"; let { - stepHeadings, + steps, onconfirm, oncancel, }: { - stepHeadings: string[]; + steps: { id: string; heading: string }[]; onconfirm: ( + stepId: string, heading: string, content: StepContent[], - afterStep?: string, + afterStepId?: string, ) => void; oncancel: () => void; } = $props(); let heading = $state(""); let description = $state(""); - let afterStep = $state(""); + let afterStepId = $state(""); function submit() { if (!heading.trim()) return; + const stepId = `dyn-step-${crypto.randomUUID().slice(0, 8)}`; const content: StepContent[] = description.trim() ? [{ type: "Prose", text: description.trim() }] : []; onconfirm( + stepId, heading.trim(), content, - afterStep || undefined, + afterStepId || undefined, ); } @@ -61,10 +64,10 @@ diff --git a/src/lib/components/CheckboxItem.svelte b/src/lib/components/CheckboxItem.svelte index 7b2c345..6fc8b62 100644 --- a/src/lib/components/CheckboxItem.svelte +++ b/src/lib/components/CheckboxItem.svelte @@ -11,7 +11,7 @@ }: { checkbox: CheckboxContent; disabled?: boolean; - ontoggle: (text: string, checked: boolean) => void; + ontoggle: (checkboxId: string, checked: boolean) => void; } = $props(); @@ -20,7 +20,7 @@ type="checkbox" checked={checkbox.checked} {disabled} - onchange={() => ontoggle(checkbox.text, !checkbox.checked)} + onchange={() => ontoggle(checkbox.id ?? "", !checkbox.checked)} /> {checkbox.text} {#if checkbox.at} diff --git a/src/lib/components/InputField.svelte b/src/lib/components/InputField.svelte index 6b43f0f..9b2d6a5 100644 --- a/src/lib/components/InputField.svelte +++ b/src/lib/components/InputField.svelte @@ -12,7 +12,7 @@ definition: InputDefinition; recorded?: InputState; disabled?: boolean; - onrecord: (label: string, value: string, unit?: string) => void; + onrecord: (inputId: string, value: string, unit?: string) => void; onrevert?: () => void; } = $props(); @@ -24,7 +24,7 @@ function submit() { const val = String(inputValue).trim(); if (!val) return; - onrecord(definition.label, val, definition.unit); + onrecord(definition.id, val, definition.unit); } let expectedText = $derived.by(() => { @@ -36,7 +36,7 @@ }); let isRecorded = $derived(!!recorded); - let inputId = $derived(`input-${definition.label.replace(/\s+/g, "-").toLowerCase()}`); + let inputId = $derived(`input-${definition.id}`);
diff --git a/src/lib/components/StepCard.svelte b/src/lib/components/StepCard.svelte index 0644b77..6f38417 100644 --- a/src/lib/components/StepCard.svelte +++ b/src/lib/components/StepCard.svelte @@ -95,15 +95,15 @@ .at(-1), ); - // Build a map of input label -> most recent revertible input/attachment event. + // Build a map of element_id -> most recent revertible input/attachment event. let revertibleInputEvents = $derived.by(() => { const map = new Map(); for (const e of revertibleEvents) { if ( (e.event_type === "input_recorded" || e.event_type === "attachment_added") && - e.label + e.element_id ) { - map.set(e.label, e); + map.set(e.element_id, e); } } return map; @@ -116,13 +116,13 @@ ); function startStep() { - onaction({ action: "start_step", step_heading: stepSummary.heading }); + onaction({ action: "start_step", step_id: stepSummary.id }); } function completeStep() { onaction({ action: "complete_step", - step_heading: stepSummary.heading, + step_id: stepSummary.id, }); } @@ -130,37 +130,37 @@ if (!skipReason.trim()) return; onaction({ action: "skip_step", - step_heading: stepSummary.heading, + step_id: stepSummary.id, reason: skipReason.trim(), }); showSkipDialog = false; skipReason = ""; } - function toggleCheckbox(text: string, checked: boolean) { + function toggleCheckbox(checkboxId: string, checked: boolean) { onaction({ action: "toggle_checkbox", - step_heading: stepSummary.heading, - text, + step_id: stepSummary.id, + checkbox_id: checkboxId, checked, }); } - function recordInput(label: string, value: string, unit?: string) { + function recordInput(inputId: string, value: string, unit?: string) { onaction({ action: "record_input", - step_heading: stepSummary.heading, - label, + step_id: stepSummary.id, + input_id: inputId, value, unit, }); } - function attachFile(label: string, filename: string, path: string, contentType: string) { + function attachFile(inputId: string, filename: string, path: string, contentType: string) { onaction({ action: "add_attachment", - step_heading: stepSummary.heading, - label, + step_id: stepSummary.id, + input_id: inputId, filename, path, content_type: contentType, @@ -171,7 +171,7 @@ onaction({ action: "add_note", text, - step_heading: stepSummary.heading, + step_id: stepSummary.id, }); } @@ -204,7 +204,7 @@ {:else if block.type === "InputBlock"}
{#each block.inputs as input} - {@const inputEvent = revertibleInputEvents.get(input.definition.label)} + {@const inputEvent = revertibleInputEvents.get(input.definition.id)} {@const revertHandler = inputEvent && executionActive ? () => onaction({ @@ -219,7 +219,7 @@ recorded={input.recorded} disabled={!isInteractable} onattach={(filename, path, contentType) => - attachFile(input.definition.label, filename, path, contentType)} + attachFile(input.definition.id, filename, path, contentType)} onrevert={revertHandler} /> {:else} diff --git a/src/lib/types/generated/EventHistoryEntry.ts b/src/lib/types/generated/EventHistoryEntry.ts index a0664cc..489ba3f 100644 --- a/src/lib/types/generated/EventHistoryEntry.ts +++ b/src/lib/types/generated/EventHistoryEntry.ts @@ -9,10 +9,10 @@ export type EventHistoryEntry = { index: number, event_type: string, */ at: string, description: string, revertible: boolean, reverted: boolean, /** - * Step heading for step-scoped events, if applicable. + * Step ID for step-scoped events, if applicable. */ -step_heading?: string, +step_id?: string, /** - * Label for input/attachment events, if applicable. + * Element ID (`checkbox_id` or `input_id`) for element-scoped events, if applicable. */ -label?: string, }; +element_id?: string, }; diff --git a/src/lib/types/generated/ExecutionAction.ts b/src/lib/types/generated/ExecutionAction.ts index aaffc8d..b4d8003 100644 --- a/src/lib/types/generated/ExecutionAction.ts +++ b/src/lib/types/generated/ExecutionAction.ts @@ -5,4 +5,4 @@ import type { StepContent } from "./StepContent"; /** * Action payload from the frontend for recording events. */ -export type ExecutionAction = { "action": "start_step", step_heading: string, } | { "action": "complete_step", step_heading: string, } | { "action": "skip_step", step_heading: string, reason: string, } | { "action": "toggle_checkbox", step_heading: string, text: string, checked: boolean, } | { "action": "record_input", step_heading: string, label: string, value: string, unit?: string, } | { "action": "add_note", text: string, step_heading?: string, } | { "action": "add_step", heading: string, content: Array, after_step?: string, } | { "action": "add_attachment", step_heading: string, label: string, filename: string, path: string, content_type: string, } | { "action": "complete", status: CompletionStatus, } | { "action": "abort", reason: string, } | { "action": "rename_execution", name: string, } | { "action": "revert_event", event_index: number, reason: string, }; +export type ExecutionAction = { "action": "start_step", step_id: string, } | { "action": "complete_step", step_id: string, } | { "action": "skip_step", step_id: string, reason: string, } | { "action": "toggle_checkbox", step_id: string, checkbox_id: string, checked: boolean, } | { "action": "record_input", step_id: string, input_id: string, value: string, unit?: string, } | { "action": "add_note", text: string, step_id?: string, } | { "action": "add_step", step_id: string, heading: string, content: Array, after_step_id?: string, } | { "action": "add_attachment", step_id: string, input_id: string, filename: string, path: string, content_type: string, } | { "action": "complete", status: CompletionStatus, } | { "action": "abort", reason: string, } | { "action": "rename_execution", name: string, } | { "action": "revert_event", event_index: number, reason: string, }; diff --git a/src/lib/types/generated/Step.ts b/src/lib/types/generated/Step.ts index 1c62ec1..8853436 100644 --- a/src/lib/types/generated/Step.ts +++ b/src/lib/types/generated/Step.ts @@ -4,4 +4,8 @@ import type { StepContent } from "./StepContent"; /** * A single step in the procedure (corresponds to a `## ` heading). */ -export type Step = { heading: string, content: Array, }; +export type Step = { +/** + * Stable element ID, assigned at execution start. `None` in raw templates. + */ +id?: string, heading: string, content: Array, }; diff --git a/src/lib/types/generated/StepContent.ts b/src/lib/types/generated/StepContent.ts index 391a2d5..cbcdac0 100644 --- a/src/lib/types/generated/StepContent.ts +++ b/src/lib/types/generated/StepContent.ts @@ -4,4 +4,8 @@ import type { InputDefinition } from "./InputDefinition"; /** * Content items within a step. */ -export type StepContent = { "type": "Prose", text: string, } | { "type": "Checkbox", text: string, checked: boolean, } | { "type": "InputBlock", inputs: Array, }; +export type StepContent = { "type": "Prose", text: string, } | { "type": "Checkbox", +/** + * Stable element ID, assigned at execution start. `None` in raw templates. + */ +id?: string, text: string, checked: boolean, } | { "type": "InputBlock", inputs: Array, }; diff --git a/src/lib/types/generated/StepContentSummary.ts b/src/lib/types/generated/StepContentSummary.ts index 4aa0fd7..ed28d69 100644 --- a/src/lib/types/generated/StepContentSummary.ts +++ b/src/lib/types/generated/StepContentSummary.ts @@ -4,7 +4,7 @@ import type { InputDefinitionSummary } from "./InputDefinitionSummary"; /** * A single content item within a step, merging template structure with runtime state. */ -export type StepContentSummary = { "type": "Prose", text: string, } | { "type": "Checkbox", text: string, checked: boolean, +export type StepContentSummary = { "type": "Prose", text: string, } | { "type": "Checkbox", id?: string, text: string, checked: boolean, /** * ISO 8601 timestamp of the last toggle, if any. */ diff --git a/src/lib/types/generated/StepSummary.ts b/src/lib/types/generated/StepSummary.ts index 0ff78a5..8d08583 100644 --- a/src/lib/types/generated/StepSummary.ts +++ b/src/lib/types/generated/StepSummary.ts @@ -2,7 +2,7 @@ import type { NoteState } from "./NoteState"; import type { StepContentSummary } from "./StepContentSummary"; -export type StepSummary = { heading: string, status: string, +export type StepSummary = { id: string, heading: string, status: string, /** * ISO 8601 timestamp of the most recent status change (started/completed/skipped). */ diff --git a/src/routes/execution/[id]/+page.svelte b/src/routes/execution/[id]/+page.svelte index 8dbdacd..c2719d5 100644 --- a/src/routes/execution/[id]/+page.svelte +++ b/src/routes/execution/[id]/+page.svelte @@ -33,7 +33,7 @@ ); let totalSteps = $derived(summary?.steps.length ?? 0); - let stepHeadings = $derived(summary?.steps.map((s) => s.heading) ?? []); + let stepRefs = $derived(summary?.steps.map((s) => ({ id: s.id, heading: s.heading })) ?? []); // Find the revertible execution-level finish event (completed or aborted). let revertibleFinishEvent = $derived( @@ -56,15 +56,15 @@ ), ); - // Build a map of step_heading -> revertible events for that step. + // Build a map of step_id -> revertible events for that step. let revertibleEventsByStep = $derived.by(() => { const map = new Map(); if (!summary) return map; for (const entry of summary.event_history) { - if (entry.revertible && !entry.reverted && entry.step_heading) { - if (!map.has(entry.step_heading)) map.set(entry.step_heading, []); - map.get(entry.step_heading)!.push(entry); + if (entry.revertible && !entry.reverted && entry.step_id) { + if (!map.has(entry.step_id)) map.set(entry.step_id, []); + map.get(entry.step_id)!.push(entry); } } return map; @@ -75,15 +75,17 @@ } async function addStep( + stepId: string, heading: string, content: StepContent[], - afterStep?: string, + afterStepId?: string, ) { await executionStore.act({ action: "add_step", + step_id: stepId, heading, content, - after_step: afterStep, + after_step_id: afterStepId, }); showAddStepDialog = false; } @@ -266,7 +268,7 @@ {/each} @@ -276,7 +278,7 @@ {#if showAddStepDialog} (showAddStepDialog = false)} />