mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 13:16:50 -06:00
Six fixes from a review of the recorder, each with a regression test (four
fail against the old recorder; the other two bound work that grew with the
input):
- A step that names another sheet with its sheet parameter (cell.set and
about fifty other commands) was tokenized as if on the input's sheet, so a
recorded cell.set {sheet: Two, cell: C4} ran on Two at the input's
relative place. Such a step keeps its references, like a sheet-qualified
one.
- Inlining recorded runs of other macros and scripts stops at the macro's
step limit and counts the rest in dropped, instead of expanding every run
first (a thousand runs of a long macro made millions of steps).
- macro.record {cancel: true} ends a recording without saving it: the way
out of a recording whose macro is too large for the workbook, which could
neither be saved nor ended.
- Formula tokenization reads each reference in the formula and matches it,
instead of listing every column and row of the example (an example as
large as a sheet made a million candidates per formula).
- A selection of several areas keeps its active cell as it is; a token for
the active cell alone put it outside the areas on playback.
- Steps recorded in another workbook are counted in dropped.
257 lines
12 KiB
Rust
257 lines
12 KiB
Rust
//! 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.
|
|
|
|
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",
|
|
[],
|
|
None,
|
|
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
|
|
has_doc,
|
|
run
|
|
),
|
|
cmd!(
|
|
query "macro.check",
|
|
"Check Macro",
|
|
[],
|
|
None,
|
|
"{name, inputs?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}",
|
|
has_doc,
|
|
check
|
|
),
|
|
cmd!(
|
|
noundo "macro.record",
|
|
"Record Macro",
|
|
["View", "Macros"],
|
|
None,
|
|
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, description? (when stopping; a taken name gets a suffix)} 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",
|
|
[],
|
|
None,
|
|
"{action: start|step|continue|runTo|stop|state, name? and inputs?, onConflict?, approve? (start), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"], breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time as one undo step; edits of the workbooks it touched wait until it finishes or stops (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed, atBreakpoint?, error?}",
|
|
has_doc,
|
|
|s, p| crate::macros::debug::debug(s, p)
|
|
),
|
|
]
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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> {
|
|
// `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).
|
|
if bool_param(p, "cancel").unwrap_or(false) {
|
|
let was = s.stop_recording().is_some();
|
|
s.macro_recording = None;
|
|
return Ok(json!({"recording": false, "cancelled": was}));
|
|
}
|
|
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, in the workbook where recording
|
|
/// started. When saving fails (a bad name, a full workbook, a macro being debugged there) the
|
|
/// recording goes on, so stopping again keeps what was recorded; when that workbook was closed,
|
|
/// the recording ends unsaved.
|
|
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 at = match &start {
|
|
Some(st) => s
|
|
.docs
|
|
.iter()
|
|
.position(|d| d.uid == st.doc)
|
|
.ok_or_else(|| EngineError::Other("The workbook where recording started was closed, so the recording was not saved.".into()))?,
|
|
None => s.active_index(),
|
|
};
|
|
let saved = save_recording(s, p, &rec, start.clone(), at);
|
|
if saved.is_err() {
|
|
s.recorder = Some(rec);
|
|
s.macro_recording = start;
|
|
}
|
|
saved
|
|
}
|
|
|
|
/// Saves the recording into the workbook at index `at`.
|
|
fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option<RecordingStart>, at: usize) -> Result<Json> {
|
|
let d = s.docs.get(at).ok_or(EngineError::NoDocument)?;
|
|
// A macro being debugged there could still roll the saved macro back with its run.
|
|
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 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, &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));
|
|
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();
|
|
// Saved into that workbook even when another one is active now.
|
|
let active = s.active_index();
|
|
s.set_active(at);
|
|
let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false}));
|
|
s.set_active(active);
|
|
saved?;
|
|
Ok(
|
|
json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}),
|
|
)
|
|
}
|