Engine: macro recorder keeps references on other sheets as they are

The recorder made every unqualified reference relative to the example input, whatever sheet the
step ran on. For example, starting a recording on Sheet1 with B2:C5 selected, activating Sheet2
and setting C4 recorded Input.cell(3,2), so playback wrote Sheet1 instead; a formula entered on
Sheet2 was changed to refer to Sheet1's cells. Each step now keeps the sheet it ran on, and only
references on the input's sheet become tokens.
This commit is contained in:
prxnvt
2026-10-22 02:26:32 -04:00
parent 252ffa95b6
commit c6dda8562f
3 changed files with 76 additions and 31 deletions
+36 -26
View File
@@ -9,9 +9,9 @@
//! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed
//! between commands without one, and runs of `selection.set` collapse to the last;
//! - with relative references (the default), the selection when recording started is the example
//! input: references in, next to or near it become tokens (`Input.col(2)`, `Input.below`,
//! `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's shape,
//! header and column types are inferred from it.
//! input: references on its sheet in, next to or near it become tokens (`Input.col(2)`,
//! `Input.below`, `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's
//! shape, header and column types are inferred from it.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{ColType, InputKind, Macro, NamedInput, Step, Workbook};
@@ -85,39 +85,42 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &
// Sheet and selection changes made without a command: where a command ran on another sheet,
// or with another selection, than the one before it, say so (a command that moved the
// selection itself does it again when played back, so the extra step is harmless).
let mut out: Vec<(String, Json, bool)> = Vec::new();
// Each step keeps the name of the sheet it runs on.
let mut out: Vec<(String, Json, String, bool)> = Vec::new();
let (mut sheet, mut sel) = (start.sheet, start.selection.clone());
for (id, params, at_sheet, sheet_name, at_sel, kept) in cmds {
if at_sheet != sheet {
out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), false));
out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), false));
}
// A recorded selection.set just before already put the selection there.
let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set");
if (at_sel != sel || at_sheet != sheet) && !selected {
out.push(("selection.set".into(), selection_params(&at_sel), false));
out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), false));
}
(sheet, sel) = (at_sheet, at_sel);
out.push((id, params, kept));
out.push((id, params, sheet_name, kept));
}
// Runs of selection.set (or sheet.activate): the last one wins.
let mut collapsed: Vec<(String, Json, bool)> = Vec::new();
for (id, params, kept) in out {
let mut collapsed: Vec<(String, Json, String, bool)> = Vec::new();
for (id, params, on, kept) in out {
if matches!(id.as_str(), "selection.set" | "sheet.activate")
&& let Some((last, ..)) = collapsed.last()
&& *last == id
{
collapsed.pop();
}
collapsed.push((id, params, kept));
collapsed.push((id, params, on, kept));
}
// Relative references.
// Relative references. Only references on the input's sheet become tokens: on another sheet
// `C4` is that sheet's C4.
let example = start.selection.current();
let mut used_input = false;
let steps: Vec<Step> = collapsed
.into_iter()
.map(|(id, params, kept)| {
let params = if start.relative && !kept { relative(&params, example, &start.sheet_name, &mut used_input, None) } else { params };
.map(|(id, params, on, kept)| {
let here = on.eq_ignore_ascii_case(&start.sheet_name);
let params = if start.relative && !kept { relative(&params, example, &start.sheet_name, here, &mut used_input, None) } else { params };
Step::command(id, params)
})
.collect();
@@ -133,20 +136,21 @@ fn selection_params(sel: &Selection) -> Json {
json!({"range": ranges.join(","), "active": sel.active.a1()})
}
/// `params` with references near `example` made into tokens.
fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Option<&str>) -> Json {
/// `params` with references near `example` (on `sheet`) made into tokens; `here` says whether the
/// step runs on that sheet, so its unqualified references are on it.
fn relative(v: &Json, example: RangeRef, sheet: &str, here: bool, used: &mut bool, key: Option<&str>) -> Json {
match v {
Json::Object(o) => {
let mut out = Map::new();
for (k, x) in o {
out.insert(k.clone(), relative(x, example, sheet, used, Some(k)));
out.insert(k.clone(), relative(x, example, sheet, here, used, Some(k)));
}
Json::Object(out)
}
Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, used, key)).collect()),
Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, here, used, key)).collect()),
Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => {
// A list of areas ("A1:B2,D4") stays as it is unless every part has a token.
let parts: Option<Vec<String>> = t.split(',').map(|part| ref_token(part, example, sheet)).collect();
let parts: Option<Vec<String>> = t.split(',').map(|part| ref_token(part, example, sheet, here)).collect();
match parts {
Some(p) if p.len() == 1 => {
*used = true;
@@ -155,7 +159,7 @@ fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Opti
_ => v.clone(),
}
}
Json::String(t) if t.starts_with('=') && example.count() > 1 => {
Json::String(t) if here && t.starts_with('=') && example.count() > 1 => {
let (f, changed) = formula_tokens(t, example);
*used |= changed;
json!(f)
@@ -164,11 +168,14 @@ fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Opti
}
}
/// The token for an A1 reference (on the example's sheet) near the example input.
fn ref_token(text: &str, example: RangeRef, sheet: &str) -> Option<String> {
/// The token for an A1 reference on the example's sheet near the example input. `here` says whether
/// an unqualified reference is on that sheet.
fn ref_token(text: &str, example: RangeRef, sheet: &str, here: bool) -> Option<String> {
let (on, body) = crate::cmd::split_sheet(text.trim());
if on.is_some_and(|s| !s.eq_ignore_ascii_case(sheet)) {
return None;
match on {
Some(on) if !on.eq_ignore_ascii_case(sheet) => return None,
None if !here => return None,
_ => {}
}
let t = RangeRef::parse(body)?;
if t.is_full_cols() || t.is_full_rows() {
@@ -367,10 +374,13 @@ mod tests {
("Z500", None),
("A:A", None),
] {
assert_eq!(ref_token(t, ex, "Sheet1").as_deref(), want, "{t}");
assert_eq!(ref_token(t, ex, "Sheet1", true).as_deref(), want, "{t}");
}
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1").as_deref(), Some("Input.cell(3,2)"));
assert_eq!(ref_token("Other!C4", ex, "Sheet1"), None);
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", true).as_deref(), Some("Input.cell(3,2)"));
assert_eq!(ref_token("Other!C4", ex, "Sheet1", true), None);
// On another sheet only a reference qualified with the input's sheet is on it.
assert_eq!(ref_token("C4", ex, "Sheet1", false), None);
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", false).as_deref(), Some("Input.cell(3,2)"));
}
#[test]
+35
View File
@@ -1010,3 +1010,38 @@ fn a_recording_is_kept_when_saving_it_fails() {
s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap();
assert_eq!(recorded(&mut s, "Late"), ["cell.set {\"cell\":\"B1\",\"input\":\"full\"}"]);
}
#[test]
fn a_relative_recording_keeps_references_on_another_sheet() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
data(&mut s);
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
s.execute("macro.record", json!({})).unwrap();
s.execute("home.bold", json!({"range": "B2:C2"})).unwrap();
// On the other sheet, C4 and C3:C5 are that sheet's cells, not the input's.
s.execute("sheet.activate", json!({"sheet": "Two"})).unwrap();
set(&mut s, "C4", "x");
set(&mut s, "D2", "=SUM(C3:C5)");
s.execute("macro.record", json!({"stop": true, "name": "Both"})).unwrap();
let steps = recorded(&mut s, "Both");
assert!(steps.contains(&"cell.set {\"cell\":\"C4\",\"input\":\"x\"}".to_string()), "{steps:?}");
assert!(steps.contains(&"cell.set {\"cell\":\"D2\",\"input\":\"=SUM(C3:C5)\"}".to_string()), "{steps:?}");
// Played back on another block of Sheet1, it writes Two's cells.
set(&mut s, "C4", "");
set(&mut s, "D2", "");
set(&mut s, "C3", "5");
set(&mut s, "C5", "7");
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
for (cell, input) in [("G10", "Who"), ("H10", "Paid"), ("G11", "A"), ("H11", "100"), ("G12", "B"), ("H12", "200"), ("G13", "C"), ("H13", "300")] {
set(&mut s, cell, input);
}
s.execute("macro.run", json!({"name": "Both", "inputs": "G10:H13", "onConflict": "overwrite"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
assert!(bold(&s, "G10") && bold(&s, "H10"));
// Nothing was written into or beside the input.
assert_eq!((v(&s, "H12").display(), v(&s, "I10").display()), ("200".into(), String::new()));
s.execute("sheet.activate", json!({"sheet": "Two"})).unwrap();
assert_eq!((v(&s, "C4").display(), v(&s, "D2").display()), ("x".into(), "12".into()));
}
+5 -5
View File
@@ -122,11 +122,11 @@ of the step that just ran, and the cells changed so far.
`description` when stopping). When saving the recording fails (a name the check refuses, a workbook
at its macro limit), recording goes on, so stopping again keeps what was recorded. With relative
references the selection when recording starts is the example input: references in and near it
become tokens, and the input's shape, header and column types are inferred. Toggles such as Bold
are recorded with the state they set (`"on": true`), so playing them back sets them rather than
flipping them. Commands a macro may not run are left out and listed in the result; a macro run
while recording is replaced by its steps when it has no inputs, and an Automate script run by the
script's steps.
on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and the input's shape,
header and column types are inferred. Toggles such as Bold are recorded with the state they set
(`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not
run are left out and listed in the result; a macro run while recording is replaced by its steps
when it has no inputs, and an Automate script run by the script's steps.
## Other commands and storage