Engine: macro recorder and macro.list, get, save, delete, rename, record

Commands: macro.list, macro.get, macro.save (a macro object or its
fields; replace defaults to true), macro.delete, macro.rename and
macro.record, beside macro.run, macro.check and macro.debug. Saving,
renaming and deleting are undoable. The catalog gains View › Macros ›
Record Macro (parity 275 / 291).

The recorder builds on the recording buffer of top-level commands, so
nested commands are never recorded twice and trimming the journal loses
nothing. When it stops, commands a macro may not run are left out and
listed; a macro run while recording is replaced by its steps when it
has no inputs, and an Automate script run by the script's steps;
sheet.activate and selection.set steps are added where the sheet or
selection changed between commands without one, and runs of them
collapse to the last. With relative references (the default) the
selection when recording started is the example input: references in
and near it become tokens (Input.col(2), Input.rows(2..),
Input.col(2).below, Input.row(4).right, Input.cell(r,c)), exact
references in formulas become {{...}}, and the input's width, header
and column types are inferred from the example. A recording replayed on
a longer block of data writes in the matching places.

A toggle such as Bold is recorded with the state it set ("on": true),
as Excel records Bold = True, so a replay sets it instead of flipping
it again.

Automate scripts are unchanged: automate.* keep their commands,
parameters, storage and results, and View › Macros still lists the
scripts and a preserved VBA project.
This commit is contained in:
prxnvt
2026-10-22 02:26:32 -04:00
parent 6a004bf610
commit b2740bdf33
9 changed files with 805 additions and 7 deletions
+1
View File
@@ -269,6 +269,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("view.unhideWindow", "View", "Window", "Unhide"),
("view.switchWindows", "View", "Window", "Switch Windows"),
("view.macros", "View", "Macros", "Macros"),
("macro.record", "View", "Macros", "Record Macro"),
// Automate
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
+17
View File
@@ -999,6 +999,23 @@ 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(),
)
}
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| {
+163 -2
View File
@@ -1,12 +1,27 @@
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
//! tokens, runner, debugger and policy. Automate scripts (`automate.*`) are a separate, older feature.
//! 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.
use serde_json::Value as Json;
use serde_json::{Value as Json, json};
use super::*;
use crate::macros::recorder::RecordingStart;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "macro.list", "List Macros", [], None, "{} → [{name, description, shortcut?, steps, inputs: [names], onConflict, permissions}]", has_doc, list),
cmd!(query "macro.get", "Get Macro", [], None, "{name} → the macro: {v, name, description, shortcut?, inputs, steps, output, permissions}", has_doc, get),
cmd!(
"macro.save",
"Save Macro",
[],
None,
"{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default true)} → {name, steps}",
has_doc,
save
),
cmd!("macro.delete", "Delete Macro", [], None, "{name}", has_doc, delete),
cmd!("macro.rename", "Rename Macro", [], None, "{name, to}", has_doc, rename),
cmd!(
"macro.run",
"Run Macro",
@@ -25,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
check
),
cmd!(
noundo "macro.record",
"Record Macro",
["View", "Macros"],
None,
"{stop?: bool, relative?: bool (default true; when starting), name?, description? (when stopping)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}",
has_doc,
record
),
cmd!(
"macro.debug",
"Step Into",
@@ -41,6 +65,93 @@ fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
}
/// The index of macro `name` in the active workbook, or an error that says there is none.
fn index_of(s: &Session, name: &str) -> Result<usize> {
crate::macros::find(&s.doc()?.wb, name).ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))
}
fn list(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
Ok(Json::Array(
wb.macros
.iter()
.map(|m| {
let mut o = json!({
"name": m.name,
"description": m.description,
"steps": m.step_count(),
"inputs": m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>(),
"onConflict": m.output.on_conflict.name(),
"permissions": m.permissions,
});
if let Some(k) = &m.shortcut {
o["shortcut"] = json!(k);
}
o
})
.collect(),
))
}
fn get(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.get", p)?)?;
let wb = &s.doc()?.wb;
Ok(wb.macros.get(i).map(crate::macros::macro_json).unwrap_or(Json::Null))
}
fn save(s: &mut Session, p: &Json) -> Result<Json> {
let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p);
let m = crate::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?;
let replace = bool_param(p, "replace").unwrap_or(true);
let (name, steps) = (m.name.clone(), m.step_count());
edit(s, |cx| {
match crate::macros::find(&cx.wb, &m.name) {
Some(_) if !replace => return Err(EngineError::Other(format!("A macro called '{}' already exists.", m.name))),
Some(i) => {
if let Some(old) = cx.wb.macros.get_mut(i) {
*old = m;
}
}
None if cx.wb.macros.len() >= gridcraft_model::MAX_MACROS => {
return Err(EngineError::Other(format!("A workbook can hold at most {} macros.", gridcraft_model::MAX_MACROS)));
}
None => cx.wb.macros.push(m),
}
Ok(())
})?;
Ok(json!({"name": name, "steps": steps}))
}
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.delete", p)?)?;
edit(s, |cx| {
if i < cx.wb.macros.len() {
cx.wb.macros.remove(i);
}
Ok(Json::Null)
})
}
fn rename(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.rename", p)?)?;
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("macro.rename", "missing `to`"))?.to_string();
if to.chars().any(char::is_control) {
return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters"));
}
if crate::macros::find(&s.doc()?.wb, &to).is_some_and(|j| j != i) {
return Err(EngineError::Other(format!("A macro called '{to}' already exists.")));
}
edit(s, |cx| {
let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) };
let mut renamed = m.clone();
renamed.name = to.clone();
renamed.check_limits().map_err(EngineError::Other)?;
*m = renamed;
Ok(())
})?;
Ok(json!({"name": to}))
}
fn run(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.run", p)?;
crate::macros::run::run(s, name, p)
@@ -50,3 +161,53 @@ fn check(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.check", p)?;
crate::macros::run::check(s, name, p)
}
fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.is_recording() {
return stop_recording(s, p);
}
start_recording(s, bool_param(p, "relative").unwrap_or(true))
}
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 input = start.selection.current().a1();
s.start_recording();
s.macro_recording = Some(start);
Ok(if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) })
}
/// Stops recording and saves what was recorded as a macro.
fn stop_recording(s: &mut Session, p: &Json) -> Result<Json> {
let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) };
let start = s.macro_recording.take();
let d = s.doc()?;
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();
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
None => crate::macros::next_name(&wb),
};
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &wb);
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();
}
let steps = converted.mac.steps.len();
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac)}))?;
Ok(json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped, "skipped": converted.skipped, "inputs": inputs}))
}
+12 -1
View File
@@ -327,6 +327,8 @@ pub struct Session {
pub macro_approvals: std::collections::HashSet<(u64, String)>,
/// The macro being debugged, between debugger actions.
pub macro_debug: Option<macros::debug::Debugger>,
/// Where the macro recorder started (beside `recorder`, its buffer).
pub macro_recording: Option<macros::recorder::RecordingStart>,
/// Windows hidden with View › Hide.
pub hidden_windows: Vec<usize>,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
@@ -462,7 +464,16 @@ impl Session {
if let Some((doc, sheet, sheet_name, selection)) = place
&& let Some(rec) = self.recorder.as_mut()
{
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) });
// A toggle (Bold) is recorded with the state it set, as Excel records
// `Bold = True`: playing it back sets that state rather than flipping it again.
let mut params = cmd::journal_params(&params);
if let (Ok(Json::Object(r)), Json::Object(p)) = (&result, &mut params)
&& !p.contains_key("on")
&& let Some(on @ Json::Bool(_)) = r.get("on")
{
p.insert("on".into(), on.clone());
}
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
}
self.journal.push((id.to_string(), cmd::journal_params(&params)));
if self.journal.len() > 10_000 {
+28 -1
View File
@@ -9,13 +9,14 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use gridcraft_model::{DefinedName, Macro, Workbook};
use gridcraft_model::{DefinedName, Macro, Step, StepKind, Workbook};
use serde_json::{Value as Json, json};
use crate::cmd::stash;
pub mod debug;
pub mod policy;
pub mod recorder;
pub mod resolve;
pub mod run;
@@ -261,5 +262,31 @@ impl Taken {
}
}
/// The first free `Macro1`, `Macro2`… name.
pub fn next_name(wb: &Workbook) -> String {
(1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none()).unwrap_or_else(|| "Macro".into())
}
/// Every command step of `steps` (inside Repeat and If too), with its path.
pub fn command_steps(steps: &[Step]) -> Vec<(String, &str, &Json)> {
fn walk<'a>(steps: &'a [Step], prefix: &str, out: &mut Vec<(String, &'a str, &'a Json)>) {
for (i, st) in steps.iter().enumerate() {
let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) };
match &st.kind {
StepKind::Command { id, params } => out.push((path, id.as_str(), params)),
StepKind::Repeat { body, .. } => walk(body, &path, out),
StepKind::If { then, otherwise, .. } => {
walk(then, &format!("{path}.then"), out);
walk(otherwise, &format!("{path}.else"), out);
}
StepKind::Stop { .. } => {}
}
}
}
let mut out = Vec::new();
walk(steps, "", &mut out);
out
}
#[cfg(test)]
mod tests;
+391
View File
@@ -0,0 +1,391 @@
//! Turning a recording into a macro.
//!
//! The recording buffer (`crate::record`) holds the top-level commands run while recording, each
//! with the sheet and selection it ran in. When recording stops:
//!
//! - 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, and an
//! Automate script run while recording by the script's steps;
//! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed
//! between commands without one, and runs of `selection.set` collapse to the last;
//! - with relative references (the default), the selection when recording started is the example
//! input: references in, next to or near it become tokens (`Input.col(2)`, `Input.below`,
//! `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's shape,
//! header and column types are inferred from it.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{ColType, InputKind, Macro, NamedInput, Step, Workbook};
use serde_json::{Map, Value as Json, json};
use super::policy::{Policy, macro_policy};
use crate::Selection;
use crate::record::RecordedStep;
/// How far outside the example input a reference may be and still be made relative to it.
const NEAR_ROWS: u32 = 50;
const NEAR_COLS: u32 = 26;
/// Where recording started, kept beside the buffer.
#[derive(Clone, Debug)]
pub struct RecordingStart {
pub doc: u64,
pub sheet: usize,
pub sheet_name: String,
pub selection: Selection,
pub relative: bool,
}
/// A recording turned into a macro, with what was left out.
pub struct Converted {
pub mac: Macro,
/// Commands left out (ids), in order.
pub skipped: Vec<String>,
}
/// Builds a macro called `name` from the recorded steps.
pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &Workbook) -> 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).
let mut cmds: Vec<(String, Json, usize, String, Selection, bool)> = Vec::new();
for st in steps.iter().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.
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() => {
for (_, cid, params) in super::command_steps(&m.steps) {
cmds.push((cid.to_string(), 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 the script runs them.
match st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)) {
Some(steps) => {
for (cid, params) in steps {
cmds.push((cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true));
}
}
None => skipped.push(id.to_string()),
}
continue;
}
if matches!(macro_policy(id, &st.params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)) {
skipped.push(id.to_string());
continue;
}
cmds.push((id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), false));
}
// Sheet and selection changes made without a command: where a command ran on another sheet,
// or with another selection, than the one before it, say so (a command that moved the
// selection itself does it again when played back, so the extra step is harmless).
let mut out: Vec<(String, Json, bool)> = Vec::new();
let (mut sheet, mut sel) = (start.sheet, start.selection.clone());
for (id, params, at_sheet, sheet_name, at_sel, kept) in cmds {
if at_sheet != sheet {
out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), false));
}
// A recorded selection.set just before already put the selection there.
let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set");
if (at_sel != sel || at_sheet != sheet) && !selected {
out.push(("selection.set".into(), selection_params(&at_sel), false));
}
(sheet, sel) = (at_sheet, at_sel);
out.push((id, params, kept));
}
// Runs of selection.set (or sheet.activate): the last one wins.
let mut collapsed: Vec<(String, Json, bool)> = Vec::new();
for (id, params, kept) in out {
if matches!(id.as_str(), "selection.set" | "sheet.activate")
&& let Some((last, ..)) = collapsed.last()
&& *last == id
{
collapsed.pop();
}
collapsed.push((id, params, kept));
}
// Relative references.
let example = start.selection.current();
let mut used_input = false;
let steps: Vec<Step> = collapsed
.into_iter()
.map(|(id, params, kept)| {
let params = if start.relative && !kept { relative(&params, example, &start.sheet_name, &mut used_input, None) } else { params };
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)];
}
Converted { mac, skipped }
}
fn selection_params(sel: &Selection) -> Json {
let ranges: Vec<String> = sel.ranges.iter().map(RangeRef::a1).collect();
json!({"range": ranges.join(","), "active": sel.active.a1()})
}
/// `params` with references near `example` made into tokens.
fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Option<&str>) -> Json {
match v {
Json::Object(o) => {
let mut out = Map::new();
for (k, x) in o {
out.insert(k.clone(), relative(x, example, sheet, used, Some(k)));
}
Json::Object(out)
}
Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, used, key)).collect()),
Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => {
// A list of areas ("A1:B2,D4") stays as it is unless every part has a token.
let parts: Option<Vec<String>> = t.split(',').map(|part| ref_token(part, example, sheet)).collect();
match parts {
Some(p) if p.len() == 1 => {
*used = true;
json!(p.join(""))
}
_ => v.clone(),
}
}
Json::String(t) if t.starts_with('=') && example.count() > 1 => {
let (f, changed) = formula_tokens(t, example);
*used |= changed;
json!(f)
}
other => other.clone(),
}
}
/// The token for an A1 reference (on the example's sheet) near the example input.
fn ref_token(text: &str, example: RangeRef, sheet: &str) -> Option<String> {
let (on, body) = crate::cmd::split_sheet(text.trim());
if on.is_some_and(|s| !s.eq_ignore_ascii_case(sheet)) {
return None;
}
let t = RangeRef::parse(body)?;
if t.is_full_cols() || t.is_full_rows() {
return None;
}
token(t, example)
}
/// `t` as a token relative to `r`.
fn token(t: RangeRef, r: RangeRef) -> Option<String> {
if t == r {
return Some("Input".into());
}
let rel_row = |row: u32| i64::from(row) - i64::from(r.start.row) + 1;
let rel_col = |col: u32| i64::from(col) - i64::from(r.start.col) + 1;
let cols = |a: u32, b: u32| if a == b { format!("col({})", rel_col(a)) } else { format!("cols({}..{})", rel_col(a), rel_col(b)) };
if r.contains_range(&t) {
let full_rows = t.start.row == r.start.row && t.end.row == r.end.row;
let full_cols = t.start.col == r.start.col && t.end.col == r.end.col;
return Some(if full_rows {
format!("Input.{}", cols(t.start.col, t.end.col))
} else if full_cols && t.start.row == t.end.row {
format!("Input.row({})", rel_row(t.start.row))
} else if full_cols && t.end.row == r.end.row {
format!("Input.rows({}..)", rel_row(t.start.row))
} else if full_cols {
format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row))
} else if t.is_single() {
format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col))
} else {
format!("Input.cell({},{}).resize({},{})", rel_row(t.start.row), rel_col(t.start.col), t.height(), t.width())
});
}
let near = t.start.row.saturating_add(NEAR_ROWS) >= r.start.row
&& t.end.row <= r.end.row.saturating_add(NEAR_ROWS)
&& t.start.col.saturating_add(NEAR_COLS) >= r.start.col
&& t.end.col <= r.end.col.saturating_add(NEAR_COLS);
if !near {
return None;
}
// Just below the input, in its columns: follows the input's length.
if t.start.row == r.end.row + 1 && t.start.col >= r.start.col && t.end.col <= r.end.col {
let base = if t.start.col == r.start.col && t.end.col == r.end.col {
"Input".to_string()
} else {
format!("Input.{}", cols(t.start.col, t.end.col))
};
return Some(if t.height() == 1 { format!("{base}.below") } else { format!("{base}.below({})", t.height()) });
}
// Just right of the input, in its rows: follows its width.
if t.start.col == r.end.col + 1 && t.start.row >= r.start.row && t.end.row <= r.end.row {
let base = if t.start.row == r.start.row && t.end.row == r.end.row {
"Input".to_string()
} else if t.start.row == t.end.row {
format!("Input.row({})", rel_row(t.start.row))
} else {
format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row))
};
return Some(if t.width() == 1 { format!("{base}.right") } else { format!("{base}.right({})", t.width()) });
}
let cell = format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col));
Some(if t.is_single() { cell } else { format!("{cell}.resize({},{})", t.height(), t.width()) })
}
/// Replaces references in a formula that are exactly the example input, one of its columns or
/// rows, or a column without its first row, with `{{token}}`.
fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) {
let mut candidates: Vec<(String, String)> = vec![(r.a1(), "Input".into())];
for (i, col) in (r.start.col..=r.end.col).enumerate() {
let n = i + 1;
candidates.push((RangeRef::new(CellRef::new(r.start.row, col), CellRef::new(r.end.row, col)).a1(), format!("Input.col({n})")));
if r.height() > 2 {
candidates
.push((RangeRef::new(CellRef::new(r.start.row + 1, col), CellRef::new(r.end.row, col)).a1(), format!("Input.rows(2..).col({n})")));
}
}
if r.width() > 1 {
for (i, row) in (r.start.row..=r.end.row).enumerate() {
candidates.push((RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), format!("Input.row({})", i + 1)));
}
}
// Longest first, so `B2:B10` isn't taken for a `B2` cell reference.
candidates.sort_by_key(|(a1, _)| std::cmp::Reverse(a1.len()));
let mut out = String::with_capacity(f.len());
let mut changed = false;
let (mut i, mut in_text) = (0, false);
let bytes = f.as_bytes();
while i < f.len() {
let Some(rest) = f.get(i..) else { break };
let ch = rest.chars().next().unwrap_or(' ');
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':'));
if !in_text && prev_ok {
let hit = candidates.iter().find(|(a1, _)| {
rest.starts_with(a1.as_str())
&& !rest
.get(a1.len()..)
.and_then(|x| x.chars().next())
.is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '!' | '(' | '_'))
});
if let Some((a1, tok)) = hit {
out.push_str("{{");
out.push_str(tok);
out.push_str("}}");
i += a1.len();
changed = true;
continue;
}
}
out.push(ch);
i += ch.len_utf8();
}
(out, changed)
}
/// 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 {
let mut input = NamedInput::default();
if r.is_single() {
input.kind = InputKind::Cell;
input.rows = [1, 1];
input.cols = [1, 1];
return input;
}
input.cols = [r.width(), r.width()];
let Some(sh) = wb.sheet(sheet) else { return input };
if r.count() > 100_000 {
return input;
}
let kind = |c: CellRef| -> Option<ColType> {
let v = sh.value(c);
Some(match v {
Value::Empty => return None,
Value::Text(t) if t.is_empty() => return None,
Value::Number(_) if crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).is_date() => ColType::Date,
Value::Number(_) => ColType::Number,
Value::Text(_) => ColType::Text,
Value::Bool(_) => ColType::Bool,
_ => ColType::Any,
})
};
let column = |col: u32, from: u32| -> ColType {
let mut seen: Option<ColType> = None;
for row in from..=r.end.row {
match (seen, kind(CellRef::new(row, col))) {
(_, None) => {}
(None, k) => seen = k,
(Some(a), Some(b)) if a != b => return ColType::Any,
_ => {}
}
}
seen.unwrap_or(ColType::Any)
};
let first_text = (r.start.col..=r.end.col).all(|c| kind(CellRef::new(r.start.row, c)) == Some(ColType::Text));
let below = (r.start.col..=r.end.col).map(|c| column(c, r.start.row + 1)).collect::<Vec<_>>();
input.header = r.height() > 1 && first_text && below.iter().any(|k| !matches!(k, ColType::Text | ColType::Any));
input.col_types = if input.header { below } else { (r.start.col..=r.end.col).map(|c| column(c, r.start.row)).collect() };
if input.col_types.iter().all(|k| *k == ColType::Any) {
input.col_types.clear();
}
input.rows = [1, 0];
input
}
#[cfg(test)]
mod tests {
use super::*;
fn r(a1: &str) -> RangeRef {
RangeRef::parse(a1).unwrap()
}
#[test]
fn references_near_the_example_become_tokens() {
let ex = r("B2:D11");
for (t, want) in [
("B2:D11", Some("Input")),
("C2:C11", Some("Input.col(2)")),
("C2:D11", Some("Input.cols(2..3)")),
("B2:D2", Some("Input.row(1)")),
("B3:D11", Some("Input.rows(2..)")),
("B3:D5", Some("Input.rows(2..4)")),
("C4", Some("Input.cell(3,2)")),
("C4:D5", Some("Input.cell(3,2).resize(2,2)")),
("B12:D12", Some("Input.below")),
("C12", Some("Input.col(2).below")),
("C12:C13", Some("Input.col(2).below(2)")),
("E2:E11", Some("Input.right")),
("E5", Some("Input.row(4).right")),
("E3:F4", Some("Input.rows(2..3).right(2)")),
("G2", Some("Input.cell(1,6)")),
("A1", Some("Input.cell(0,0)")),
("Z500", None),
("A:A", None),
] {
assert_eq!(ref_token(t, ex, "Sheet1").as_deref(), want, "{t}");
}
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1").as_deref(), Some("Input.cell(3,2)"));
assert_eq!(ref_token("Other!C4", ex, "Sheet1"), None);
}
#[test]
fn formulas_get_tokens_for_exact_references() {
let ex = r("B2:C5");
for (f, want) in [
("=SUM(C3:C5)", "=SUM({{Input.rows(2..).col(2)}})"),
("=SUM(C2:C5)+COUNT(B2:C5)", "=SUM({{Input.col(2)}})+COUNT({{Input}})"),
("=SUM($C$3:$C$5)", "=SUM($C$3:$C$5)"),
("=SUM(Other!C3:C5)", "=SUM(Other!C3:C5)"),
("=\"C3:C5\"&C3:C5", "=\"C3:C5\"&{{Input.rows(2..).col(2)}}"),
("=SUM(AC3:C5)", "=SUM(AC3:C5)"),
("=B2", "=B2"),
] {
assert_eq!(formula_tokens(f, ex).0, want, "{f}");
}
}
}
+3
View File
@@ -218,6 +218,9 @@ fn names() {
assert_eq!(fingerprint(&a), fingerprint(&b));
b.steps[0].note = "changed".into();
assert_ne!(fingerprint(&a), fingerprint(&b));
assert_eq!(next_name(&wb), "Macro2");
let paths: Vec<(String, &str)> = command_steps(&a.steps).into_iter().map(|(p, id, _)| (p, id)).collect();
assert_eq!(paths, [("1".into(), "cell.set"), ("2.1.then.1".into(), "home.bold"), ("3".into(), "home.italic")]);
}
#[test]
+186
View File
@@ -752,6 +752,192 @@ fn a_failing_step_ends_the_debugged_run_and_rolls_it_back() {
assert_eq!(debug(&mut s, json!({"name": "Empty"}))["state"], "finished");
}
// ---------------------------------------------------------------- commands and the recorder
#[test]
fn macros_are_saved_listed_renamed_and_deleted() {
let mut s = s();
s.execute("macro.save", json!({"macro": totals()})).unwrap();
// The fields can also come at the top level.
let r = s
.execute("macro.save", json!({"name": "Second", "description": "two", "shortcut": "Ctrl+Shift+Q", "steps": [{"command": "home.bold"}]}))
.unwrap();
assert_eq!(r, json!({"name": "Second", "steps": 1}));
let list = s.execute("macro.list", json!({})).unwrap();
assert_eq!(list[0], json!({"name": "Totals", "description": "", "steps": 3, "inputs": ["Input"], "onConflict": "ask", "permissions": []}));
assert_eq!(list[1]["shortcut"], "Ctrl+Shift+Q");
let got = s.execute("macro.get", json!({"name": "totals"})).unwrap();
assert_eq!(crate::macros::parse_macro(&got).unwrap(), crate::macros::parse_macro(&totals()).unwrap());
assert!(s.execute("macro.save", json!({"macro": totals(), "replace": false})).unwrap_err().to_string().contains("already exists"));
assert!(s.execute("macro.save", json!({"macro": {"name": "Bad", "steps": [{"type": "warp"}]}})).is_err());
assert!(s.execute("macro.rename", json!({"name": "Second", "to": "TOTALS"})).unwrap_err().to_string().contains("already exists"));
s.execute("macro.rename", json!({"name": "Second", "to": "Third"})).unwrap();
s.execute("macro.rename", json!({"name": "third", "to": "THIRD"})).unwrap();
s.execute("macro.delete", json!({"name": "Totals"})).unwrap();
let names: Vec<String> =
s.execute("macro.list", json!({})).unwrap().as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap().to_string()).collect();
assert_eq!(names, ["THIRD"]);
assert!(s.execute("macro.delete", json!({"name": "Totals"})).unwrap_err().to_string().contains("no macro called"));
// Saving, renaming and deleting are undoable.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("macro.list", json!({})).unwrap().as_array().unwrap().len(), 2);
// Macros are kept apart from Automate scripts: View › Macros still lists the scripts.
assert_eq!(s.execute("view.macros", json!({})).unwrap(), json!([]));
assert!(s.execute("macro.rename", json!({"name": "THIRD", "to": "a\nb"})).is_err());
}
#[test]
fn macros_and_automate_scripts_live_side_by_side() {
let mut s = s();
let steps = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "from script"}}]);
s.execute("automate.newScript", json!({"name": "Inner", "commands": steps})).unwrap();
s.execute("macro.save", json!({"name": "Inner", "steps": [{"command": "cell.set", "params": {"cell": "C1", "input": "from macro"}}], "output": {"onConflict": "overwrite"}})).unwrap();
// Recording a script run keeps the script's steps, so the macro stands on its own.
s.execute("macro.record", json!({"relative": false})).unwrap();
set(&mut s, "A1", "typed");
s.execute("automate.runScript", json!({"name": "Inner"})).unwrap();
s.execute("automate.recordActions", json!({})).unwrap();
s.execute("automate.recordActions", json!({"stop": true, "name": "Empty"})).unwrap();
let r = s.execute("macro.record", json!({"stop": true, "name": "Recorded"})).unwrap();
assert_eq!(r["skipped"], json!(["automate.recordActions", "automate.recordActions"]));
let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Recorded"})).unwrap()).unwrap();
let ids: Vec<String> = crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect();
assert_eq!(ids, ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}", "cell.set {\"cell\":\"B1\",\"input\":\"from script\"}"]);
// Both lists keep their own entries, under the same name if need be.
let scripts = s.execute("automate.allScripts", json!({})).unwrap();
assert_eq!(scripts, json!([{"name": "Empty", "steps": 0}, {"name": "Inner", "steps": 1}]));
let macros: Vec<Json> = s.execute("macro.list", json!({})).unwrap().as_array().unwrap().iter().map(|m| m["name"].clone()).collect();
assert_eq!(macros, [json!("Inner"), json!("Recorded")]);
s.execute("home.insertSheet", json!({})).unwrap();
s.execute("automate.runScript", json!({"name": "Inner"})).unwrap();
s.execute("macro.run", json!({"name": "Inner"})).unwrap();
assert_eq!((v(&s, "B1").display(), v(&s, "C1").display()), ("from script".into(), "from macro".into()));
// Both survive an xlsx round trip unchanged.
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "both.xlsx", "base64": b64})).unwrap();
assert_eq!(s.execute("automate.allScripts", json!({})).unwrap(), scripts);
assert_eq!(s.execute("macro.list", json!({})).unwrap().as_array().unwrap().len(), 2);
}
#[test]
fn a_relative_recording_plays_back_on_another_block() {
let mut s = s();
data(&mut s);
put(
&mut s,
json!({"name": "Plain", "steps": [{"command": "cell.set", "params": {"cell": "A20", "input": "plain"}}], "output": {"onConflict": "overwrite"}}),
);
put(&mut s, json!({"name": "Shaped", "inputs": [{"name": "Input"}], "steps": []}));
s.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
let r = s.execute("macro.record", json!({})).unwrap();
assert_eq!(r, json!({"recording": true, "relative": true, "input": "B2:C5"}));
// Clicking a cell and typing, a formula over the amounts, bold header, italic on a selection.
s.execute("selection.set", json!({"cell": "E2"})).unwrap();
set(&mut s, "E2", "Total");
set(&mut s, "E3", "=SUM(C3:C5)");
s.execute("home.bold", json!({"range": "B2:C2"})).unwrap();
s.execute("selection.set", json!({"cell": "C3"})).unwrap();
s.execute("selection.set", json!({"cell": "C4"})).unwrap();
s.execute("home.italic", json!({})).unwrap();
set(&mut s, "C6", "end");
// Not kept: commands macros don't run, and a macro with inputs; a plain macro's steps are
// inlined.
s.execute("window.activate", json!({"index": 0})).unwrap();
s.execute("macro.run", json!({"name": "Plain"})).unwrap();
s.execute("macro.run", json!({"name": "Shaped", "inputs": "A1"})).unwrap();
// The journal can be trimmed meanwhile.
s.journal.clear();
let r = s.execute("macro.record", json!({"stop": true, "name": "Sum it", "description": "made by recording"})).unwrap();
assert!(!s.is_recording());
assert_eq!(r["saved"], "Sum it");
assert_eq!(r["skipped"], json!(["window.activate", "macro.run"]));
assert_eq!(r["inputs"], json!(["Input"]));
let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Sum it"})).unwrap()).unwrap();
assert_eq!(m.description, "made by recording");
let input = &m.inputs[0];
assert!(input.header);
assert_eq!((input.cols, input.col_types.clone()), ([2, 2], vec![gridcraft_model::ColType::Text, gridcraft_model::ColType::Number]));
let steps: Vec<(String, Json)> = m
.steps
.iter()
.map(|st| match &st.kind {
gridcraft_model::StepKind::Command { id, params } => (id.clone(), params.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
steps,
vec![
("selection.set".to_string(), json!({"cell": "Input.cell(1,4)"})),
("cell.set".into(), json!({"cell": "Input.cell(1,4)", "input": "Total"})),
("cell.set".into(), json!({"cell": "Input.cell(2,4)", "input": "=SUM({{Input.rows(2..).col(2)}})"})),
("home.bold".into(), json!({"range": "Input.row(1)", "on": true})),
("selection.set".into(), json!({"cell": "Input.cell(3,2)"})),
("home.italic".into(), json!({"on": true})),
("cell.set".into(), json!({"cell": "Input.col(2).below", "input": "end"})),
("cell.set".into(), json!({"cell": "A20", "input": "plain"})),
]
);
// Play it back on a longer block somewhere else.
for (cell, input) in [
("G10", "Who"),
("H10", "Paid"),
("G11", "A"),
("H11", "1"),
("G12", "B"),
("H12", "2"),
("G13", "C"),
("H13", "3"),
("G14", "D"),
("H14", "4"),
] {
set(&mut s, cell, input);
}
s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14", "onConflict": "overwrite"})).unwrap();
assert_eq!((v(&s, "J10").display(), v(&s, "J11").display()), ("Total".into(), "10".into()));
assert!(bold(&s, "G10") && bold(&s, "H10"));
assert_eq!(v(&s, "H15").display(), "end");
// Toggles were recorded with the state they set: a second run keeps the header bold.
s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14", "onConflict": "overwrite"})).unwrap();
assert!(bold(&s, "G10") && bold(&s, "H10"));
let d = s.doc().unwrap();
assert!(d.wb.styles.get(d.wb.active().unwrap().style_id(gridcraft_core::CellRef::parse("H12").unwrap())).font.italic);
// The input is checked like any other: text in the amounts is refused.
set(&mut s, "H12", "two");
let err = s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14"})).unwrap_err().to_string();
assert!(err.contains("Input column 2 (Paid) must hold numbers; H12 holds text."), "{err}");
}
#[test]
fn an_absolute_recording_keeps_its_references() {
let mut s = s();
s.execute("selection.set", json!({"range": "B2:C3"})).unwrap();
s.execute("macro.record", json!({"relative": false})).unwrap();
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
set(&mut s, "B2", "x");
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
set(&mut s, "C3", "y");
let r = s.execute("macro.record", json!({})).unwrap();
assert_eq!(r["saved"], "Macro1");
let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Macro1"})).unwrap()).unwrap();
assert!(m.inputs.is_empty());
let ids: Vec<String> = crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect();
assert_eq!(
ids,
[
"home.insertSheet {\"name\":\"Two\"}",
"sheet.activate {\"sheet\":\"Two\"}",
"selection.set {\"active\":\"A1\",\"range\":\"A1\"}",
"cell.set {\"cell\":\"B2\",\"input\":\"x\"}",
"sheet.activate {\"sheet\":\"Sheet1\"}",
"selection.set {\"active\":\"A1\",\"range\":\"A1\"}",
"cell.set {\"cell\":\"C3\",\"input\":\"y\"}",
]
);
// Stopping when nothing records says so.
assert_eq!(s.execute("macro.record", json!({"stop": true})).unwrap(), json!({"recording": false}));
}
#[test]
fn only_the_workbooks_a_debugged_run_touched_wait() {
let mut s = s();
+4 -3
View File
@@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 274 / 290 commands implemented (94.5%).**
**Overall: 275 / 291 commands implemented (94.5%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@@ -16,7 +16,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Formulas | 23 | 23 | 100% |
| Data | 23 | 28 | 82% |
| Review | 15 | 18 | 83% |
| View | 20 | 20 | 100% |
| View | 21 | 21 | 100% |
| Automate | 3 | 3 | 100% |
| Table Design | 13 | 13 | 100% |
| Chart Design | 8 | 8 | 100% |
@@ -299,7 +299,7 @@ Missing:
- `review.translate` Translate (Language)
- `review.showChanges` Show Changes (Changes)
## View (20 / 20, 100%)
## View (21 / 21, 100%)
Implemented:
@@ -323,6 +323,7 @@ Implemented:
- `view.unhideWindow` Unhide (Window)
- `view.switchWindows` Switch Windows (Window)
- `view.macros` Macros (Macros)
- `macro.record` Record Macro (Macros)
## Automate (3 / 3, 100%)