Engine: a recording keeps what ran and plays back as it was recorded

- A script step that only opened a dialog (a menu-style call that left the workbook unchanged)
  is left out of the script run's steps, by the recorder hook's rule; a script run whose steps
  the recording couldn't keep is left out and listed in `skipped`, not read from the workbook
  as the script stands when recording stops (a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording).
- A script's toggles keep the state they set (`"on": true`), as a top-level Bold does
  (a_script_run_records_the_state_its_toggles_set).
- A recorded macro.run keeps the steps it ran, as a script run does, so saving or deleting the
  macro before the recording stops changes nothing (a_recorded_macro_run_keeps_the_steps_that_ran).
- The example input's shape and column types are inferred when recording starts, not from the
  data the recording changed (the_recorded_input_is_inferred_as_it_was_when_recording_started).
- Recorded text that playback would read as a token stays text: a reference parameter that reads
  as one becomes {"$text": …}, and `{{` becomes `{{{{`
  (recorded_text_that_reads_as_a_token_plays_back_as_text). The resolver (below #2152) gains
  `{"$text": …}`, text passed as it is (text_marked_as_text_is_never_a_token).
- A recording over the macro size limit is cut at the limit and the steps left out are counted
  in `dropped` (a_recording_past_the_macro_size_is_cut_and_counted).
- A macro recorded in a workbook opened from a file is approved for the session, since the
  person recorded it (a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking).
- macro.record refuses parameters it would ignore: name or description when starting, relative
  when stopping, stop: false while recording, others beside cancel: true, and wrong kinds
  (record_refuses_parameters_it_would_ignore).
- Undo while recording keeps the undone step, as in Excel; documented
  (undo_while_recording_keeps_the_undone_step_as_excel_does).
- Docs: macros.md Recording and Tokens; module comments no longer say scripts are read.
This commit is contained in:
prxnvt
2026-10-25 03:19:35 -04:00
parent 8b018d89cd
commit 460a72d3bb
9 changed files with 528 additions and 134 deletions
+10 -19
View File
@@ -1088,6 +1088,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
continue;
}
let before = s.ui_requests.len();
let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone()));
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
@@ -1098,10 +1099,17 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
let result = ran?;
n += 1;
if s.recorder.is_some() {
ran_steps.push((id.to_string(), st["params"].clone()));
// The recorder's rule for each step, as for a command run on its own: a menu-style
// step that only opened a dialog is left out (playing it back would stop at the
// dialog), and a toggle keeps the state it set.
let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..))));
let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb)));
if !opened_dialog || changed {
ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result)));
}
}
}
if s.recorder.is_some() {
@@ -1110,23 +1118,6 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"ran": n}))
}
/// The command steps of the Automate script called `name`, as `automate.runScript` runs them
/// (steps that run other scripts are left out), or `None` when there is no such script.
pub(crate) fn script_steps(wb: &Workbook, name: &str) -> Option<Vec<(String, Json)>> {
let all = stash_get(wb, SCRIPTS_NAME);
let steps = all.get(name)?.as_array()?;
Some(
steps
.iter()
.take(100_000)
.filter_map(|st| {
let id = st["command"].as_str()?;
(!id.starts_with("automate.")).then(|| (id.to_string(), st["params"].clone()))
})
.collect(),
)
}
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
/// Redo work there again.
fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
+75 -23
View File
@@ -1,6 +1,7 @@
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
//! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate,
//! older feature with their own storage; these commands neither read nor change them.
//! older feature with their own storage; these commands neither read nor change it. A recording
//! keeps the steps a script run ran while it recorded, as the run reported them.
use serde_json::{Value as Json, json};
@@ -177,7 +178,20 @@ fn rename(s: &mut Session, p: &Json) -> Result<Json> {
fn run(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.run", p)?;
crate::macros::run::run(s, name, p)
// While recording: the steps a recording keeps for this run, taken from the macro as it runs,
// so saving or deleting the macro before the recording stops doesn't change what it saved.
let inline = if s.is_recording() {
let wb = &s.doc()?.wb;
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).and_then(crate::macros::recorder::inline_steps)
} else {
None
};
let out = crate::macros::run::run(s, name, p)?;
if s.is_recording() {
// `None` when the macro's steps don't stand on their own: the recording leaves the run out.
s.script_ran = inline;
}
Ok(out)
}
fn check(s: &mut Session, p: &Json) -> Result<Json> {
@@ -186,8 +200,9 @@ fn check(s: &mut Session, p: &Json) -> Result<Json> {
}
fn record(s: &mut Session, p: &Json) -> Result<Json> {
check_record_params(s.is_recording(), p)?;
// `cancel` ends a recording without saving it: the way out of a recording whose macro
// cannot be saved (too large for the workbook, for one).
// cannot be saved (a name the check refuses, a full workbook).
if bool_param(p, "cancel").unwrap_or(false) {
let was = s.stop_recording().is_some();
s.macro_recording = None;
@@ -199,15 +214,47 @@ fn record(s: &mut Session, p: &Json) -> Result<Json> {
start_recording(s, bool_param(p, "relative").unwrap_or(true))
}
/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `name` and
/// `description` when starting (they name the macro when it stops), `relative` when stopping (it
/// is chosen when recording starts), `stop: false` while recording (the call stops it), anything
/// beside `cancel: true`, and parameters of the wrong kind.
fn check_record_params(recording: bool, p: &Json) -> Result<()> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
for k in ["stop", "cancel", "relative"] {
if given(k).is_some_and(|v| !v.is_boolean()) {
return Err(bad("macro.record", format!("`{k}` must be true or false")));
}
}
for k in ["name", "description"] {
if given(k).is_some_and(|v| !v.is_string()) {
return Err(bad("macro.record", format!("`{k}` must be text")));
}
}
if given("cancel").and_then(Json::as_bool) == Some(true) {
if let Some(k) = ["stop", "relative", "name", "description"].into_iter().find(|k| given(k).is_some()) {
return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored")));
}
return Ok(());
}
let stop = given("stop").and_then(Json::as_bool);
if recording && stop == Some(false) {
return Err(bad(
"macro.record",
"a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved",
));
}
if recording || stop == Some(true) {
if given("relative").is_some() {
return Err(bad("macro.record", "`relative` is chosen when recording starts"));
}
} else if let Some(k) = ["name", "description"].into_iter().find(|k| given(k).is_some()) {
return Err(bad("macro.record", format!("`{k}` names the macro when recording stops, not when it starts")));
}
Ok(())
}
fn start_recording(s: &mut Session, relative: bool) -> Result<Json> {
let d = s.doc()?;
let start = RecordingStart {
doc: d.uid,
sheet: d.wb.active_sheet,
sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
selection: d.selection.clone(),
relative,
};
let start = RecordingStart::new(s.doc()?, relative);
let input = start.selection.current().a1();
s.start_recording();
s.macro_recording = Some(start);
@@ -244,27 +291,24 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) {
return Err(EngineError::Disabled("macro.record".into(), crate::macros::debug::BUSY.into()));
}
let start = start.unwrap_or_else(|| RecordingStart {
doc: d.uid,
sheet: d.wb.active_sheet,
sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
selection: d.selection.clone(),
relative: true,
});
let wb = d.wb.clone();
// A recording started without `macro.record` (`Session::start_recording`) takes its example
// input from the workbook as it is now.
let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
let (doc, wb) = (d.uid, d.wb.clone());
// A name another macro has gets a suffix, so stopping never replaces a macro.
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => crate::macros::unique_name(&wb, n),
None => crate::macros::next_name(&wb),
};
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran, &wb);
// Steps past the macro limit, and ones recorded in another workbook, are left out and counted
// with the ones the recorder dropped.
let cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran);
// Steps past the macro limit or its size, and ones recorded in another workbook, are left
// out and counted with the ones the recorder dropped.
let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS);
if let Some(desc) = str_param(p, "description") {
converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect();
}
cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac));
let steps = converted.mac.steps.len();
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
// Saved into that workbook even when another one is active now.
@@ -273,6 +317,14 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false}));
s.set_active(active);
saved?;
// The person recorded it, so it runs without the approval a macro that came with a file
// needs, until it changes.
if let Some(d) = s.docs.iter().find(|d| d.uid == doc)
&& let Some(m) = crate::macros::find(&d.wb, &name).and_then(|i| d.wb.macros.get(i))
{
let key = (doc, crate::macros::fingerprint(m));
s.macro_approvals.insert(key);
}
Ok(
json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}),
)
+10 -12
View File
@@ -459,8 +459,9 @@ pub struct Session {
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// The steps the last successful `automate.runScript` ran while a recorder was on, for the
/// recorder to keep with that step (see [`record::Recorder::ran`]); taken by the recording hook.
/// The steps the last successful `automate.runScript` or `macro.run` ran while a recorder was
/// on, for the recorder to keep with that step (see [`record::Recorder::ran`]); taken by the
/// recording hook.
pub(crate) script_ran: Option<Vec<(String, Json)>>,
/// Macros the person allowed to run in this session: (document uid, the macro's content as
/// canonical JSON, from [`macros::fingerprint`]).
@@ -720,19 +721,16 @@ impl Session {
{
// A toggle (Bold) is recorded with the state it set, as Excel records
// `Bold = True`: playing it back sets that state rather than flipping it again.
let mut params = cmd::journal_params(&params);
if let (Ok(Json::Object(r)), Json::Object(p)) = (&result, &mut params)
&& !p.contains_key("on")
&& let Some(on @ Json::Bool(_)) = r.get("on")
{
p.insert("on".into(), on.clone());
}
let params = match &result {
Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(&params), r),
_ => cmd::journal_params(&params),
};
let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
// A script run is kept with the steps it ran, so editing or deleting the script
// before the recording stops does not change what was recorded.
// A script or macro run is kept with the steps it ran, so editing or deleting the
// script or macro before the recording stops does not change what was recorded.
if let Some(ran) = self.script_ran.take()
&& kept
&& id == "automate.runScript"
&& matches!(id, "automate.runScript" | "macro.run")
{
rec.attach_ran(ran);
}
+26 -1
View File
@@ -5,7 +5,8 @@
//! GridCraft's hidden workbook data ([`crate::cmd::stash`]), `_gc_macro_<n>_<k>`, written when the
//! file is saved and taken back out of the names when it is opened, so the names never linger in
//! the open document. Scripts made with Automate › New Script or Record Actions are a separate,
//! older feature with their own storage; macros neither read nor change them.
//! older feature with their own storage; macros neither read nor change it. A recording keeps the
//! steps a script run ran while it recorded, as the run reported them.
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -64,6 +65,30 @@ fn check_size(m: &Macro) -> Result<(), String> {
Ok(())
}
/// Leaves out the last top-level steps of `m` until it fits the size [`check_size`] allows, and
/// returns how many steps it left out (a recording cut to fit, as at the step limit). Each step's
/// JSON is measured once.
pub fn fit_size(m: &mut Macro) -> usize {
let chars = |v: &Json| v.to_string().chars().count();
let steps = std::mem::take(&mut m.steps);
// The macro without steps holds `"steps":[]`; each step adds its JSON, and a comma after the
// first.
let mut size = chars(&macro_json(m));
let mut kept = 0usize;
for (i, st) in steps.iter().enumerate() {
let add = chars(&serde_json::to_value(st).unwrap_or(Json::Null)).saturating_add(usize::from(i > 0));
if size.saturating_add(add) > stash::MAX_ITEM_CHARS {
break;
}
size = size.saturating_add(add);
kept = i.saturating_add(1);
}
let cut = steps.len().saturating_sub(kept);
m.steps = steps;
m.steps.truncate(kept);
cut
}
/// How many macros the workbook holds: the readable ones and the ones kept in a newer format
/// (`Workbook::macros_unread`), which a save writes back and the next open counts too.
pub fn count(wb: &Workbook) -> usize {
+117 -52
View File
@@ -6,13 +6,18 @@
//! - commands a macro may not run (saving, opening files…) and other macros' commands are left
//! out; a macro run while recording is replaced by its steps when it has no inputs, no output
//! and only plain commands (otherwise it is left out), and an Automate script run while
//! recording by the script's steps, leaving out the steps a macro may not run;
//! recording by the steps it ran, leaving out the steps a macro may not run; both are the steps
//! as they ran, kept with the recording ([`crate::record::Recorder::ran`]), and a run without
//! them is left out;
//! - 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 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.
//! shape, header and column types are inferred from it as it was when recording started;
//! - recorded text that playback would read as a token stays text: a reference parameter that
//! reads as one (a defined name called `Output`) becomes `{"$text": …}`, and `{{` in a string
//! becomes `{{{{`.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook};
@@ -35,6 +40,55 @@ pub struct RecordingStart {
pub sheet_name: String,
pub selection: Selection,
pub relative: bool,
/// With relative references, the example input (the selection) as it was when recording
/// started: the recorded steps may change its values (text made into numbers), and the
/// macro is for data like the data it was recorded on.
pub input: Option<NamedInput>,
}
impl RecordingStart {
/// Recording starting now in `d`.
pub fn new(d: &crate::DocState, relative: bool) -> RecordingStart {
let sheet = d.wb.active_sheet;
RecordingStart {
doc: d.uid,
sheet,
sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
selection: d.selection.clone(),
relative,
input: relative.then(|| infer_input(&d.wb, sheet, d.selection.current())),
}
}
}
/// The steps a recording keeps for a run of `m`: its enabled command steps as written, when they
/// stand on their own (no inputs, no output and only plain commands; a Repeat, If or Stop means
/// nothing outside its macro). `None` when they don't, and the recording leaves the run out.
pub fn inline_steps(m: &Macro) -> Option<Vec<(String, Json)>> {
if !m.inputs.is_empty() || m.output.anchor.is_some() || !m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) {
return None;
}
Some(
m.steps
.iter()
.filter(|x| x.enabled)
.filter_map(|x| match &x.kind {
StepKind::Command { id, params } => Some((id.clone(), params.clone())),
_ => None,
})
.collect(),
)
}
/// Where a gathered command came from, which says how its parameters are written.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Origin {
/// Run while recording: plain parameters, which may become relative.
Recorded,
/// A step of a recorded script run: plain parameters, kept as they are.
Script,
/// A step of a recorded macro run: macro parameters (tokens and all), kept as written.
Macro,
}
/// A recording turned into a macro, with what was left out.
@@ -48,18 +102,18 @@ pub struct Converted {
}
/// Builds a macro called `name` from the recorded steps.
/// `ran` is what a script step ran, by step index ([`crate::record::Recorder::ran`]); a script step
/// without an entry is read from `wb`.
pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap<usize, Vec<(String, Json)>>, wb: &Workbook) -> Converted {
/// `ran` is what a macro or script run ran, by step index ([`crate::record::Recorder::ran`]); a
/// run without an entry is left out and listed in `skipped`.
pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap<usize, Vec<(String, Json)>>) -> Converted {
let mut skipped = Vec::new();
// Commands as they'll be played back: (id, params, sheet before, selection before, whether
// they came from another macro and stay as written). At most the macro's step limit are
// gathered: a recording of a thousand runs of a long macro would otherwise expand to
// millions of steps before the limit cut them back.
let mut cmds: Vec<(String, Json, usize, String, Selection, bool)> = Vec::new();
// Commands as they'll be played back: (id, params, sheet before, selection before, where
// they came from). At most the macro's step limit are gathered: a recording of a thousand
// runs of a long macro would otherwise expand to millions of steps before the limit cut them
// back.
let mut cmds: Vec<(String, Json, usize, String, Selection, Origin)> = Vec::new();
let mut cut = steps.iter().filter(|st| st.doc != start.doc).count();
let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_));
let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, bool)>, cmd: (String, Json, usize, String, Selection, bool)| {
let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, Origin)>, cmd: (String, Json, usize, String, Selection, Origin)| {
if cmds.len() < MAX_MACRO_STEPS {
cmds.push(cmd);
} else {
@@ -68,37 +122,23 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran:
};
for (index, st) in steps.iter().enumerate().filter(|(_, st)| st.doc == start.doc) {
let id = st.id.as_str();
if id == "macro.run" {
// Another macro's steps, when they stand on their own: no inputs, no output and only
// plain commands (a Repeat, If or Stop means nothing outside its macro).
let inner = st.params.get("name").and_then(Json::as_str).and_then(|n| super::find(wb, n)).and_then(|i| wb.macros.get(i));
match inner {
Some(m) if m.inputs.is_empty() && m.output.anchor.is_none() && m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) => {
for x in m.steps.iter().filter(|x| x.enabled) {
let StepKind::Command { id: cid, params } = &x.kind else { continue };
// Another macro's steps (when they stand on their own, [`inline_steps`]) or an Automate
// script's, as they ran: what the run kept with the recording. Without them the run is
// left out; the macro or script as it stands now may not be what ran.
let origin = match id {
"macro.run" => Some(Origin::Macro),
"automate.runScript" => Some(Origin::Script),
_ => None,
};
if let Some(origin) = origin {
match ran.get(&index) {
Some(steps) => {
for (cid, params) in steps {
if refused(cid, params) {
skipped.push(cid.clone());
continue;
}
push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true));
}
}
_ => skipped.push(id.to_string()),
}
continue;
}
if id == "automate.runScript" {
// An Automate script's steps as they ran, or as the script stands now when the recorder
// could not keep them.
let now = || st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n));
match ran.get(&index).cloned().or_else(now) {
Some(steps) => {
for (cid, params) in steps {
if refused(&cid, &params) {
skipped.push(cid);
continue;
}
push(&mut cmds, (cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true));
push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), origin));
}
}
None => skipped.push(id.to_string()),
@@ -109,37 +149,37 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran:
skipped.push(id.to_string());
continue;
}
push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), false));
push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), Origin::Recorded));
}
// 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).
// Each step keeps the name of the sheet it runs on.
let mut out: Vec<(String, Json, String, bool)> = Vec::new();
let mut out: Vec<(String, Json, String, Origin)> = Vec::new();
let (mut sheet, mut sel) = (start.sheet, start.selection.clone());
for (id, params, at_sheet, sheet_name, at_sel, kept) in cmds {
for (id, params, at_sheet, sheet_name, at_sel, origin) in cmds {
if at_sheet != sheet {
out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), false));
out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), Origin::Recorded));
}
// 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), sheet_name.clone(), false));
out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), Origin::Recorded));
}
(sheet, sel) = (at_sheet, at_sel);
out.push((id, params, sheet_name, kept));
out.push((id, params, sheet_name, origin));
}
// Runs of selection.set (or sheet.activate): the last one wins.
let mut collapsed: Vec<(String, Json, String, bool)> = Vec::new();
for (id, params, on, kept) in out {
let mut collapsed: Vec<(String, Json, String, Origin)> = Vec::new();
for (id, params, on, origin) in out {
if matches!(id.as_str(), "selection.set" | "sheet.activate")
&& let Some((last, ..)) = collapsed.last()
&& *last == id
{
collapsed.pop();
}
collapsed.push((id, params, on, kept));
collapsed.push((id, params, on, origin));
}
// Relative references. Only references on the input's sheet become tokens: on another sheet
@@ -148,7 +188,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran:
let mut used_input = false;
let steps: Vec<Step> = collapsed
.into_iter()
.map(|(id, params, on, kept)| {
.map(|(id, params, on, origin)| {
// A step that names another sheet with its `sheet` parameter runs there, whatever
// sheet was active: its unqualified references are that sheet's cells.
let names_other_sheet = params.get("sheet").is_some_and(|v| match v {
@@ -157,13 +197,19 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran:
_ => false,
});
let here = on.eq_ignore_ascii_case(&start.sheet_name) && !names_other_sheet;
let params = if start.relative && !kept { relative(&params, example, &start.sheet_name, here, &mut used_input, None) } else { params };
// Plain text first, so the tokens added next are the only ones.
let params = if origin == Origin::Macro { params } else { literal(&params, None) };
let params = if start.relative && origin == Origin::Recorded {
relative(&params, example, &start.sheet_name, here, &mut used_input, None)
} else {
params
};
Step::command(id, params)
})
.collect();
let mut mac = Macro { name: name.to_string(), steps, ..Macro::default() };
if start.relative && used_input {
mac.inputs = vec![infer_input(wb, start.sheet, example)];
mac.inputs = vec![start.input.clone().unwrap_or_default()];
}
Converted { mac, skipped, cut }
}
@@ -173,10 +219,27 @@ fn selection_params(sel: &Selection) -> Json {
json!({"range": ranges.join(","), "active": sel.active.a1()})
}
/// Plain recorded `params` written so playback reads them as they are: a string in a reference
/// parameter that playback would read as a token, or that holds `{{`, becomes `{"$text": …}`,
/// and `{{` in any other string becomes `{{{{`.
fn literal(v: &Json, key: Option<&str>) -> Json {
match v {
Json::Object(o) => Json::Object(o.iter().map(|(k, x)| (k.clone(), literal(x, Some(k)))).collect()),
Json::Array(a) => Json::Array(a.iter().map(|x| literal(x, key)).collect()),
Json::String(t) if key.is_some_and(super::resolve::is_ref_key) && (t.contains("{{") || super::resolve::reads_as_token(t, &["Input"])) => {
json!({"$text": t})
}
Json::String(t) if t.contains("{{") => json!(t.replace("{{", "{{{{")),
other => other.clone(),
}
}
/// `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 {
// Text kept as text ([`literal`]).
Json::Object(o) if o.len() == 1 && o.contains_key("$text") => v.clone(),
Json::Object(o) => {
// A selection of several areas stays as it is, its active cell with it: a token for
// the active cell alone would put it outside the areas on playback.
@@ -299,7 +362,9 @@ fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) {
if ch == '"' {
in_text = !in_text;
}
let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':'));
// A reference right after `{` stays as it is: `{{` before the token would read as the
// escape of a literal `{{`.
let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':' | b'{'));
if !in_text && prev_ok && ch.is_ascii_alphabetic() {
// The longest run of reference characters here: letters, digits and one `:`.
let n = rest.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b':').count();
@@ -349,7 +414,7 @@ fn formula_token(t: RangeRef, r: RangeRef) -> Option<String> {
/// The example input's shape: a header when the first row is all text over other kinds of
/// values, and each column's type when all its values agree.
fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput {
pub fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput {
let mut input = NamedInput::default();
if r.is_single() {
input.kind = InputKind::Cell;
+35 -2
View File
@@ -16,7 +16,9 @@
//! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole
//! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending
//! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`).
//! Each becomes A1 text before the command runs, so commands never see a token. Plain A1
//! Each becomes A1 text before the command runs, so commands never see a token. Text that would
//! read as a token is written `{"$text": "Output"}`: it is passed as it is, never resolved or
//! interpolated (a defined name called `Output`, or `Dear {{name}}`). Plain A1
//! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor
//! does.
@@ -128,6 +130,15 @@ const MAX_JSON_DEPTH: usize = 64;
/// Whether `text` starts like a token (its base is one this environment knows). A reference on a
/// sheet with such a name (`Output!A1`) is not a token.
pub fn is_token(text: &str, env: &Env) -> bool {
starts_like_token(text, |base| env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)))
}
/// Whether `text` would read as a token in a macro whose inputs are `inputs` (see [`is_token`]).
pub fn reads_as_token(text: &str, inputs: &[&str]) -> bool {
starts_like_token(text, |base| inputs.iter().any(|n| n.eq_ignore_ascii_case(base)))
}
fn starts_like_token(text: &str, is_input: impl Fn(&str) -> bool) -> bool {
let t = text.trim();
if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
return true;
@@ -137,7 +148,7 @@ pub fn is_token(text: &str, env: &Env) -> bool {
if t.get(n..).unwrap_or("").trim_start().starts_with('!') {
return false;
}
is_builtin(base) || env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base))
is_builtin(base) || is_input(base)
}
fn is_builtin(base: &str) -> bool {
@@ -503,6 +514,12 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R<Json> {
{
return Ok(Json::String(a1(resolve_ref(t, env)?, env)));
}
// Text passed as it is: never a token, and no `{{…}}` in it is read.
if o.len() == 1
&& let Some(Json::String(t)) = o.get("$text")
{
return Ok(Json::String(t.clone()));
}
let mut out = Map::new();
for (k, x) in o {
out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?);
@@ -904,6 +921,22 @@ mod tests {
assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error");
}
#[test]
fn text_marked_as_text_is_never_a_token() {
// A defined name called Output or Input in a reference parameter, a Name: prefix and
// braces: `$text` passes each as it is, in any key; unmarked, each reads as a token.
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
for text in ["Output", "Input", "Name:Nowhere", "Row", "Dear {{name}}"] {
let p = json!({"range": {"$text": text}, "input": {"$text": text}});
assert_eq!(resolve_params(&p, &env).unwrap(), json!({"range": text, "input": text}), "{text}");
assert_ne!(resolve_params(&json!({"range": text}), &env).ok(), Some(json!({"range": text})), "{text}");
assert!(reads_as_token(text, &["Input"]) || text.contains("{{"), "{text}");
}
assert!(!reads_as_token("Output!A1", &["Input"]) && !reads_as_token("B2", &["Input"]));
}
#[test]
fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() {
let mut wb = wb();
+20 -4
View File
@@ -50,12 +50,28 @@ pub struct Recorder {
/// The size of `steps` and `ran` serialized as JSON.
bytes: usize,
/// What a step ran, by its index in `steps`, for a command that runs steps stored in the
/// workbook (`automate.runScript`): the commands as they ran, so a recording keeps them even
/// when the script is edited or deleted before the recording stops. A step without an entry
/// is read from the workbook when the recording is used.
/// workbook (`automate.runScript`, `macro.run`): the commands as they ran, so a recording
/// keeps them even when the script or macro is edited or deleted before the recording stops.
/// A run without an entry is left out when the recording is used.
pub ran: BTreeMap<usize, Vec<(String, Json)>>,
}
/// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel
/// records `Bold = True`: playing the step back sets that state rather than flipping it again.
pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json {
if let Some(on @ Json::Bool(_)) = result.get("on") {
if params.is_null() {
params = Json::Object(serde_json::Map::new());
}
if let Json::Object(p) = &mut params
&& !p.contains_key("on")
{
p.insert("on".into(), on.clone());
}
}
params
}
impl Recorder {
/// Adds a step, or counts it as dropped once the buffer is full; `true` when it was kept.
pub fn push(&mut self, step: RecordedStep) -> bool {
@@ -77,7 +93,7 @@ impl Recorder {
}
/// Attaches what the last kept step ran ([`Recorder::ran`]), within the byte budget: past it,
/// the step stays and its script is read from the workbook when the recording is used.
/// the step stays without it, and the recording leaves the run out and lists it as skipped.
pub fn attach_ran(&mut self, steps: Vec<(String, Json)>) {
let Some(index) = self.steps.len().checked_sub(1) else { return };
let size = serde_json::to_vec(&steps).map_or(0, |v| v.len());
+192
View File
@@ -1792,6 +1792,74 @@ fn re_saving_a_large_macro_keeps_the_undo_history_within_its_budget() {
assert!(d.undo.iter().skip(1).all(|e| e.retained_bytes > big.len()), "each kept copy is charged for its parameters");
}
#[test]
fn the_recorded_input_is_inferred_as_it_was_when_recording_started() {
// The recording turns text numbers into numbers; the macro is for data like the data it was
// recorded on, so the input's column types are the ones it had when recording started.
let mut s = s();
for (cell, input) in [("B2", "Name"), ("C2", "Amount"), ("B3", "Ann"), ("C3", "'120"), ("B4", "Bo"), ("C4", "'80")] {
set(&mut s, cell, input);
}
s.execute("selection.set", json!({"range": "B2:C4"})).unwrap();
s.execute("macro.record", json!({})).unwrap();
set(&mut s, "C3", "120");
set(&mut s, "C4", "80");
s.execute("macro.record", json!({"stop": true, "name": "Numbers"})).unwrap();
let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Numbers"})).unwrap()).unwrap();
assert!(!m.inputs[0].col_types.contains(&gridcraft_model::ColType::Number), "{:?}", m.inputs[0]);
// Played back on data like the original, text numbers and all.
for (cell, input) in [("G10", "Who"), ("H10", "Paid"), ("G11", "A"), ("H11", "'1"), ("G12", "B"), ("H12", "'2")] {
set(&mut s, cell, input);
}
s.execute("macro.run", json!({"name": "Numbers", "inputs": "G10:H12", "onConflict": "overwrite"})).unwrap();
assert_eq!(v(&s, "H11"), gridcraft_core::Value::Number(120.0));
}
#[test]
fn a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording() {
// Played back, a menu-style step with no parameters would stop the run at its dialog; the
// script's edit is kept. A run whose steps the recording couldn't keep is left out, not read
// from the workbook as the script stands at the end.
let mut s = s();
let steps = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}, {"command": "home.formatCells", "params": {}}]);
s.execute("automate.newScript", json!({"name": "EditThenAsk", "commands": steps})).unwrap();
s.execute("macro.record", json!({"relative": false})).unwrap();
s.execute("automate.runScript", json!({"name": "EditThenAsk"})).unwrap();
assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))));
s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"B1\",\"input\":\"1\"}"]);
set(&mut s, "B1", "");
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
assert_eq!(v(&s, "B1").display(), "1");
assert!(!s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))));
s.execute("macro.record", json!({"relative": false})).unwrap();
s.execute("automate.runScript", json!({"name": "EditThenAsk"})).unwrap();
s.recorder.as_mut().unwrap().ran.clear();
let r = s.execute("macro.record", json!({"stop": true, "name": "Without"})).unwrap();
assert_eq!(r["skipped"], json!(["automate.runScript"]));
assert!(recorded(&mut s, "Without").is_empty());
}
#[test]
fn a_script_run_records_the_state_its_toggles_set() {
let mut s = s();
s.execute(
"automate.newScript",
json!({"name": "Bolder", "commands": [{"command": "home.bold", "params": {"range": "A1"}}, {"command": "home.italic"}]}),
)
.unwrap();
s.execute("macro.record", json!({"relative": false})).unwrap();
s.execute("automate.runScript", json!({"name": "Bolder"})).unwrap();
s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
assert_eq!(recorded(&mut s, "Rec"), ["home.bold {\"on\":true,\"range\":\"A1\"}", "home.italic {\"on\":true}"]);
// Played back twice, A1 stays bold: the step sets the state rather than flipping it.
for _ in 0..2 {
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
assert!(bold(&s, "A1"));
}
}
#[test]
fn renaming_checks_the_macro_size() {
// A longer name can take a macro past the size a file holds: renaming is refused, as saving is.
@@ -1808,6 +1876,117 @@ fn renaming_checks_the_macro_size() {
crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
}
#[test]
fn a_recorded_macro_run_keeps_the_steps_that_ran() {
// The macro is changed and another deleted before the recording stops: the recording holds
// the steps as they ran.
let mut s = s();
let one = |cell: &str, input: &str| json!({"output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": cell, "input": input}}]});
for (name, cell, input) in [("Changed", "C1", "first"), ("Gone", "C2", "kept")] {
let mut m = one(cell, input);
m["name"] = json!(name);
put(&mut s, m);
}
s.execute("macro.record", json!({"relative": false})).unwrap();
s.execute("macro.run", json!({"name": "Changed"})).unwrap();
s.execute("macro.run", json!({"name": "Gone"})).unwrap();
let mut changed = one("C1", "changed");
changed["name"] = json!("Changed");
s.execute("macro.save", json!({"macro": changed, "replace": true})).unwrap();
s.execute("macro.delete", json!({"name": "Gone"})).unwrap();
let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
assert_eq!(r["skipped"], json!(["macro.save", "macro.delete"]), "{r}");
assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"C1\",\"input\":\"first\"}", "cell.set {\"cell\":\"C2\",\"input\":\"kept\"}"]);
}
#[test]
fn recorded_text_that_reads_as_a_token_plays_back_as_text() {
// Braces typed into a cell and a defined name called Output are text and a name when
// recorded; played back they stay so, instead of being read as tokens.
let mut s = s();
data(&mut s);
s.execute("formulas.defineName", json!({"name": "Output", "refersTo": "=Sheet1!$H$1"})).unwrap();
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
s.execute("macro.record", json!({})).unwrap();
set(&mut s, "A1", "Dear {{name}}");
set(&mut s, "E3", "=SUM(C3:C5)&\"{{x\"");
s.execute("edit.goTo", json!({"reference": "Output"})).unwrap();
s.execute("home.bold", json!({})).unwrap();
s.execute("macro.record", json!({"stop": true, "name": "Letters"})).unwrap();
for cell in ["A1", "E3"] {
set(&mut s, cell, "");
}
s.execute("home.bold", json!({"range": "H1"})).unwrap();
assert!(!bold(&s, "H1"));
s.execute("macro.run", json!({"name": "Letters", "inputs": "B2:C5", "onConflict": "overwrite"})).unwrap();
assert_eq!(v(&s, "A1").display(), "Dear {{name}}");
assert_eq!(v(&s, "E3").display(), "400{{x");
assert!(bold(&s, "H1"));
let steps = recorded(&mut s, "Letters");
assert!(steps.contains(&"edit.goTo {\"reference\":{\"$text\":\"Output\"}}".to_string()), "{steps:?}");
assert!(
steps.iter().any(|st| st.contains("=SUM({{Input.rows(2..).col(2)}})&\\\"{{{{x\\\"")),
"the formula's own reference is still a token: {steps:?}"
);
}
#[test]
fn a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking() {
// The person recorded it: it doesn't ask as a macro that came with the file does.
let mut s = s();
open_with_macros(&mut s, vec![]);
assert_eq!(s.doc().unwrap().trust, crate::Trust::FromFile);
s.execute("macro.record", json!({"relative": false})).unwrap();
set(&mut s, "A1", "mine");
s.execute("macro.record", json!({"stop": true, "name": "Mine"})).unwrap();
set(&mut s, "A1", "");
s.execute("macro.run", json!({"name": "Mine", "onConflict": "overwrite"})).unwrap();
assert_eq!(v(&s, "A1").display(), "mine");
// Changed, it asks again.
s.execute("macro.save", json!({"name": "Mine", "replace": true, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]}))
.unwrap();
assert!(matches!(s.execute("macro.run", json!({"name": "Mine"})), Err(EngineError::Approve(_))));
}
#[test]
fn a_recording_past_the_macro_size_is_cut_and_counted() {
// Three steps of 1.5 million characters each don't fit one macro: the third is left out and
// counted in `dropped`, and the rest is saved.
let mut s = s();
s.execute("macro.record", json!({"relative": false})).unwrap();
let (doc, selection) = (s.doc().unwrap().uid, s.doc().unwrap().selection.clone());
let rec = s.recorder.as_mut().unwrap();
for i in 0..3 {
rec.push(crate::record::RecordedStep {
doc,
sheet: 0,
sheet_name: "Sheet1".into(),
selection: selection.clone(),
id: "cell.set".into(),
params: json!({"cell": format!("A{}", i + 1), "input": "x".repeat(1_500_000)}),
});
}
let r = s.execute("macro.record", json!({"stop": true, "name": "Large"})).unwrap();
assert_eq!((r["steps"].as_u64(), r["dropped"].as_u64()), (Some(2), Some(1)), "{r}");
assert!(!s.is_recording());
crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
}
#[test]
fn record_refuses_parameters_it_would_ignore() {
let mut s = s();
for p in [json!({"name": "Early"}), json!({"description": "d"}), json!({"relative": "no"}), json!({"stop": 1})] {
assert!(s.execute("macro.record", p.clone()).is_err(), "{p}");
assert!(!s.is_recording(), "{p}");
}
s.execute("macro.record", json!({"relative": false, "name": null})).unwrap();
for p in [json!({"relative": true}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] {
assert!(s.execute("macro.record", p.clone()).is_err(), "{p}");
assert!(s.is_recording(), "{p}");
}
assert_eq!(s.execute("macro.record", json!({"cancel": true})).unwrap()["cancelled"], true);
}
#[test]
fn saving_over_a_macro_needs_replace() {
let mut s = s();
@@ -1819,3 +1998,16 @@ fn saving_over_a_macro_needs_replace() {
s.execute("macro.save", json!({"name": "TIDY", "description": "second", "replace": true})).unwrap();
assert_eq!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["description"], "second");
}
#[test]
fn undo_while_recording_keeps_the_undone_step_as_excel_does() {
let mut s = s();
s.execute("macro.record", json!({"relative": false})).unwrap();
set(&mut s, "A1", "typed");
s.execute("edit.undo", json!({})).unwrap();
let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
assert_eq!(r["skipped"], json!(["edit.undo"]), "{r}");
assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}"]);
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
assert_eq!(v(&s, "A1").display(), "typed");
}
+43 -21
View File
@@ -1,6 +1,6 @@
# Macros
> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands)
> **Last updated:** 2026-10-25 · **Change:** minor (recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands)
GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft
command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro
@@ -86,7 +86,9 @@ exists is used and methods follow it.
Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending
in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside
any string, such as a formula; `{{{{` in a string is a literal `{{`. Under the general keys that
any string, such as a formula; `{{{{` in a string is a literal `{{`, and `{"$text": "Output"}`
anywhere is the text as it is, never read as a token or for `{{…}}` (a defined name called
`Output`, or `Dear {{name}}`). Under the general keys that
other commands use for plain words (`to`, `from`, `at`, `area`, `source`, `target`,
`destination`, `location`), a bare `Row` or `Column` stays text (a replacement word, a pivot
area); write `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` there. Plain A1 references stay
@@ -157,23 +159,42 @@ of the step that just ran, and the cells changed so far.
## Recording
`macro.record` toggles recording (`relative: false` for absolute references; `name` and
`description` when stopping; a name that another macro has gets a suffix, such as `Tidy (2)`, so
stopping never replaces a macro; `cancel: true` ends a recording without saving it, the way out of
one whose macro is too large for the workbook). The macro is saved in the workbook where recording started, even if
another one is active; if that workbook was closed, stopping ends the recording without saving it.
When saving the recording fails (a name the check refuses, a workbook at its macro limit, a
macro being debugged in that workbook), recording goes on, so stopping again keeps what was
recorded. A recording keeps up to 100,000 commands and a macro up to 10,000 steps; steps past
either limit are left out and counted in `dropped`. With relative references the selection when
recording starts is the example input: references in and near it 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, also from the steps of a macro or script run while recording; a macro run
while recording is replaced by its steps when it has no inputs, no output and only plain commands
(otherwise it is left out and listed), and an Automate script run by the steps it ran, kept when
it ran: editing or deleting the script before the recording stops changes nothing.
`macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute
references); stopping takes `name` and `description`. A name that another macro has, a kept one
included, gets a suffix, such as `Tidy (2)`, so stopping never replaces a macro. `cancel: true`
ends a recording without saving it, the way out of one whose macro can't be saved. A parameter
the call would ignore is refused: `name` or `description` when starting, `relative` when
stopping, `stop: false` while recording, `stop`, `relative`, `name` or `description` beside
`cancel: true`, and one of the wrong kind. The macro is saved in the workbook where recording started, even if another one is active;
if that workbook was closed, stopping ends the recording without saving it. When saving the
recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged
in that workbook), recording goes on, so stopping again keeps what was recorded. The person
recorded the macro, so in a workbook opened from a file it runs without the approval a macro that
came with the file needs, until it changes.
A recording keeps up to 100,000 commands and 64 MB of them as JSON, and a macro holds up to
10,000 steps and 4,194,304 characters of JSON. The steps past any of these limits, and steps
recorded in another workbook, are left out and counted in `dropped`.
With relative references the selection when recording starts is the example input: references in
and near it 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 from it as it was when recording started,
before the recorded steps changed it. Recorded text that playback would read as a token stays
text: a reference parameter that reads as one (a defined name called `Output`) is written
`{"$text": "Output"}`, and `{{` in a string is written `{{{{`. Toggles such as Bold are recorded
with the state they set (`"on": true`), also inside a script run, so playing them back sets them
rather than flipping them.
Commands a macro may not run are left out and listed in `skipped`, also from the steps of a macro
or script run while recording. A macro run while recording is replaced by its steps when it has
no inputs, no output and only plain commands (otherwise it is left out and listed), and an
Automate script run by the steps it ran, without a step that only opened a dialog (a menu-style
call without its parameters). Both are kept as they ran, so editing or deleting the macro or
script before the recording stops changes nothing; a run whose steps did not fit in the
recording's 64 MB is left out and listed.
Undo while recording works as in Excel: `edit.undo` is left out (a macro never runs it), and the
step it undid stays in the recording, so playing the recording back makes that edit again.
## Other commands and storage
@@ -204,8 +225,9 @@ other macros open; a macro that was left out is not in the saved file.
Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`,
`deleteScript`) are an older, separate feature. They keep their commands, parameters, storage
(`_sc_scripts`) and results, and View › Macros (`view.macros`) still lists them. Macros neither
read nor change them; a workbook can hold a script and a macro with the same name.
(`_sc_scripts`) and results, and View › Macros (`view.macros`) still lists them. Macro commands
neither read nor change their storage; a recording keeps the steps a script run ran while it
recorded. A workbook can hold a script and a macro with the same name.
A VBA project in an `.xlsm` or `.xltm` file is preserved on save and never run. GridCraft macros
are stored apart from it.