mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 21:26:48 -06:00
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:
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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(¶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);
|
||||
}
|
||||
|
||||
@@ -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(¯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 {
|
||||
|
||||
@@ -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, ¶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<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(¶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<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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user