diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 8902b1740..e983d7971 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1088,6 +1088,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { 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 { }); 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 { 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> { - 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 { diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 0023cdaf9..5f8afb716 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -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 { fn run(s: &mut Session, p: &Json) -> Result { 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 { @@ -186,8 +200,9 @@ fn check(s: &mut Session, p: &Json) -> Result { } fn record(s: &mut Session, p: &Json) -> Result { + 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 { 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 { - 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 = 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}), ) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6a1e151b1..8fd54f809 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -459,8 +459,9 @@ pub struct Session { pub recorder: Option, /// 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>, /// 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(¶ms); - 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(¶ms), r), + _ => cmd::journal_params(¶ms), + }; 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); } diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 1fc0ab674..9c8caa349 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -5,7 +5,8 @@ //! GridCraft's hidden workbook data ([`crate::cmd::stash`]), `_gc_macro__`, 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(¯o_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 { diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index 54ff10811..53b8edc30 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -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, +} + +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> { + 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>, 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>) -> 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, ¶ms) { - 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 = 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(¶ms, 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(¶ms, None) }; + let params = if start.relative && origin == Origin::Recorded { + relative(¶ms, 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 { /// 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; diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index 4e84c0965..3825dcc96 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -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 { { 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(); diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index d4b3599c6..3a296d048 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -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>, } +/// `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()); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 6155f8b90..dd3d4acf3 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -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"); +} diff --git a/docs/macros.md b/docs/macros.md index b7c196956..e448ad19e 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -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.