Skip to main content

ravenrs_sheet/engine/cmd/
macros.rs

1//! Macros: typed command lists that run as one undo step. See [`crate::engine::macros`] for the model,
2//! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate,
3//! older feature with their own storage; these commands neither read nor change it. A recording
4//! keeps the steps a script run ran while it recorded, as the run reported them.
5
6use serde_json::{Value as Json, json};
7
8use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param};
9use crate::engine::macros::recorder::RecordingStart;
10
11pub fn specs() -> Vec<CommandSpec> {
12    vec![
13        cmd!(query "macro.list", "List Macros", [], None, "{} → [{name, description, shortcut?, steps, inputs: [names], onConflict, permissions}]", has_doc, list),
14        cmd!(query "macro.get", "Get Macro", [], None, "{name} → the macro: {v, name, description, shortcut?, inputs, steps, output, permissions}", has_doc, get),
15        cmd!(
16            "macro.save",
17            "Save Macro",
18            [],
19            None,
20            "{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 false: saving over a macro with the name is refused)} → {name, steps}",
21            has_doc,
22            save
23        ),
24        cmd!("macro.delete", "Delete Macro", [], None, "{name}", has_doc, delete),
25        cmd!("macro.rename", "Rename Macro", [], None, "{name, to}", has_doc, rename),
26        cmd!(
27            "macro.run",
28            "Run Macro",
29            [],
30            None,
31            "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), 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?}",
32            has_doc,
33            run
34        ),
35        cmd!(
36            query "macro.check",
37            "Check Macro",
38            [],
39            None,
40            "{name, inputs?, output?: \"F2\" (as macro.run), onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
41            has_doc,
42            check
43        ),
44        cmd!(
45            noundo "macro.record",
46            "Record Macro",
47            ["View", "Macros"],
48            None,
49            "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}",
50            has_doc,
51            record
52        ),
53        cmd!(
54            "macro.debug",
55            "Step Into",
56            [],
57            None,
58            "{action: start|step|continue|runTo|stop|state, name, inputs?, output?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
59            has_doc,
60            |s, p| crate::engine::macros::debug::debug(s, p)
61        ),
62    ]
63}
64
65fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
66    str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
67}
68
69/// The index of macro `name` in the active workbook, or an error that says there is none.
70fn index_of(s: &Session, name: &str) -> Result<usize> {
71    crate::engine::macros::find(&s.doc()?.wb, name).ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))
72}
73
74fn list(s: &mut Session, _: &Json) -> Result<Json> {
75    let wb = &s.doc()?.wb;
76    Ok(Json::Array(
77        wb.macros
78            .iter()
79            .map(|m| {
80                let mut o = json!({
81                    "name": m.name,
82                    "description": m.description,
83                    "steps": m.step_count(),
84                    "inputs": m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>(),
85                    "onConflict": m.output.on_conflict.name(),
86                    "permissions": m.permissions,
87                });
88                if let Some(k) = &m.shortcut {
89                    o["shortcut"] = json!(k);
90                }
91                o
92            })
93            .collect(),
94    ))
95}
96
97fn get(s: &mut Session, p: &Json) -> Result<Json> {
98    let i = index_of(s, name_param("macro.get", p)?)?;
99    let wb = &s.doc()?.wb;
100    Ok(wb.macros.get(i).map(crate::engine::macros::macro_json).unwrap_or(Json::Null))
101}
102
103fn save(s: &mut Session, p: &Json) -> Result<Json> {
104    let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p);
105    let m = crate::engine::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?;
106    // Replacing is asked for, never assumed: a save with a name in use is refused.
107    let replace = match p.get("replace") {
108        None | Some(Json::Null) => false,
109        Some(Json::Bool(b)) => *b,
110        Some(_) => return Err(bad("macro.save", "`replace` must be true or false")),
111    };
112    let (name, steps) = (m.name.clone(), m.step_count());
113    edit(s, |cx| {
114        if let Some(kept) = crate::engine::macros::kept_name(&cx.wb, &m.name) {
115            return Err(EngineError::Other(taken_by_kept(&kept)));
116        }
117        match crate::engine::macros::find(&cx.wb, &m.name) {
118            Some(i) if !replace => {
119                let existing = cx.wb.macros.get(i).map_or(m.name.as_str(), |o| o.name.as_str());
120                return Err(EngineError::Other(format!("A macro called '{existing}' already exists. Pass `replace: true` to replace it.")));
121            }
122            Some(i) => {
123                if let Some(old) = cx.wb.macros.get_mut(i) {
124                    *old = m;
125                }
126            }
127            None => {
128                crate::engine::macros::check_room(&cx.wb).map_err(EngineError::Other)?;
129                cx.wb.macros.push(m);
130            }
131        }
132        Ok(())
133    })?;
134    Ok(json!({"name": name, "steps": steps}))
135}
136
137/// The refusal of a name that a macro kept in a newer format has.
138fn taken_by_kept(name: &str) -> String {
139    format!(
140        "A macro called '{name}' made by a newer version of GridCraft is kept in this workbook; this version can't change it. Choose another name."
141    )
142}
143
144fn delete(s: &mut Session, p: &Json) -> Result<Json> {
145    let i = index_of(s, name_param("macro.delete", p)?)?;
146    edit(s, |cx| {
147        if i < cx.wb.macros.len() {
148            cx.wb.macros.remove(i);
149        }
150        Ok(Json::Null)
151    })
152}
153
154fn rename(s: &mut Session, p: &Json) -> Result<Json> {
155    let i = index_of(s, name_param("macro.rename", p)?)?;
156    let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("macro.rename", "missing `to`"))?.to_string();
157    if to.chars().any(char::is_control) {
158        return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters"));
159    }
160    let wb = &s.doc()?.wb;
161    if crate::engine::macros::find(wb, &to).is_some_and(|j| j != i) {
162        return Err(EngineError::Other(format!("A macro called '{to}' already exists.")));
163    }
164    if let Some(kept) = crate::engine::macros::kept_name(wb, &to) {
165        return Err(EngineError::Other(taken_by_kept(&kept)));
166    }
167    edit(s, |cx| {
168        let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) };
169        let mut renamed = m.clone();
170        renamed.name = to.clone();
171        // The whole check, size included: a longer name can take a macro past what a file holds.
172        crate::engine::macros::check(&renamed).map_err(EngineError::Other)?;
173        let from = std::mem::replace(m, renamed).name;
174        // Buttons that run the macro follow the new name.
175        let runs = |c: &crate::model::FormControl| matches!(&c.action, Some(crate::model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from));
176        for si in 0..cx.wb.sheets.len() {
177            if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) {
178                continue;
179            }
180            for c in cx.sheet_mut(si)?.controls.iter_mut() {
181                if let Some(crate::model::ControlAction::Macro { name, .. }) = &mut c.action
182                    && name.eq_ignore_ascii_case(&from)
183                {
184                    *name = to.clone();
185                }
186            }
187        }
188        Ok(())
189    })?;
190    Ok(json!({"name": to}))
191}
192
193fn run(s: &mut Session, p: &Json) -> Result<Json> {
194    let name = name_param("macro.run", p)?;
195    // While recording: the steps a recording keeps for this run, taken from the macro as it runs,
196    // so saving or deleting the macro before the recording stops doesn't change what it saved.
197    let inline = if s.is_recording() {
198        let wb = &s.doc()?.wb;
199        crate::engine::macros::find(wb, name).and_then(|i| wb.macros.get(i)).and_then(crate::engine::macros::recorder::inline_steps)
200    } else {
201        None
202    };
203    let out = crate::engine::macros::run::run(s, name, p)?;
204    if s.is_recording() {
205        // `None` when the macro's steps don't stand on their own: the recording leaves the run out.
206        s.script_ran = inline;
207    }
208    Ok(out)
209}
210
211fn check(s: &mut Session, p: &Json) -> Result<Json> {
212    let name = name_param("macro.check", p)?;
213    crate::engine::macros::run::check(s, name, p)
214}
215
216fn record(s: &mut Session, p: &Json) -> Result<Json> {
217    check_record_params(s.is_recording(), p)?;
218    // `cancel` ends a recording without saving it: the way out of a recording whose macro
219    // cannot be saved (a name the check refuses, a full workbook).
220    if bool_param(p, "cancel").unwrap_or(false) {
221        let was = s.stop_recording().is_some();
222        s.macro_recording = None;
223        return Ok(json!({"recording": false, "cancelled": was}));
224    }
225    if bool_param(p, "stop").unwrap_or(false) || s.is_recording() {
226        return stop_recording(s, p);
227    }
228    start_recording(s, p, bool_param(p, "relative").unwrap_or(true))
229}
230
231/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `relative` and
232/// `shortcut` when stopping (they are chosen when recording starts), `stop: false` while
233/// recording (the call stops it), anything beside `cancel: true`, and parameters of the wrong
234/// kind. `name` and `description` count when starting and when stopping (stopping wins).
235fn check_record_params(recording: bool, p: &Json) -> Result<()> {
236    let given = |k: &str| p.get(k).filter(|v| !v.is_null());
237    for k in ["stop", "cancel", "relative"] {
238        if given(k).is_some_and(|v| !v.is_boolean()) {
239            return Err(bad("macro.record", format!("`{k}` must be true or false")));
240        }
241    }
242    for k in ["name", "description", "shortcut"] {
243        if given(k).is_some_and(|v| !v.is_string()) {
244            return Err(bad("macro.record", format!("`{k}` must be text")));
245        }
246    }
247    if given("cancel").and_then(Json::as_bool) == Some(true) {
248        if let Some(k) = ["stop", "relative", "name", "description", "shortcut"].into_iter().find(|k| given(k).is_some()) {
249            return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored")));
250        }
251        return Ok(());
252    }
253    let stop = given("stop").and_then(Json::as_bool);
254    if recording && stop == Some(false) {
255        return Err(bad(
256            "macro.record",
257            "a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved",
258        ));
259    }
260    if (recording || stop == Some(true))
261        && let Some(k) = ["relative", "shortcut"].into_iter().find(|k| given(k).is_some())
262    {
263        return Err(bad("macro.record", format!("`{k}` is chosen when recording starts")));
264    }
265    Ok(())
266}
267
268/// Starts recording. `name`, `shortcut` and `description` (from the Record Macro dialog) are
269/// checked now, so a bad name or a taken shortcut is refused before anything is recorded.
270fn start_recording(s: &mut Session, p: &Json, relative: bool) -> Result<Json> {
271    let d = s.doc()?;
272    let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
273        Some(n) => Some(crate::engine::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?),
274        None => None,
275    };
276    let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) {
277        Some(k) => {
278            let letter = crate::engine::macros::parse_shortcut(k)
279                .ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?;
280            if let Some(why) = crate::engine::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) {
281                return Err(bad("macro.record", why));
282            }
283            Some(crate::engine::macros::shortcut_text(letter))
284        }
285        None => None,
286    };
287    let description = match str_param(p, "description") {
288        Some(t) if t.chars().count() > crate::model::MAX_MACRO_TEXT => {
289            return Err(bad("macro.record", format!("`description` can be at most {} characters long", crate::model::MAX_MACRO_TEXT)));
290        }
291        Some(t) => Some(t.to_string()),
292        None => None,
293    };
294    let mut start = RecordingStart::new(d, relative);
295    (start.name, start.shortcut, start.description) = (name.clone(), shortcut, description);
296    let input = start.selection.current().a1();
297    s.start_recording();
298    s.macro_recording = Some(start);
299    let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) };
300    if let Some(n) = name {
301        out["name"] = json!(n);
302    }
303    Ok(out)
304}
305
306/// Stops recording and saves what was recorded as a macro, in the workbook where recording
307/// started. When saving fails (a bad name, a full workbook, a macro being debugged there) the
308/// recording goes on, so stopping again keeps what was recorded; when that workbook was closed,
309/// the recording ends unsaved.
310fn stop_recording(s: &mut Session, p: &Json) -> Result<Json> {
311    let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) };
312    let start = s.macro_recording.take();
313    let at = match &start {
314        Some(st) => s
315            .docs
316            .iter()
317            .position(|d| d.uid == st.doc)
318            .ok_or_else(|| EngineError::Other("The workbook where recording started was closed, so the recording was not saved.".into()))?,
319        None => s.active_index(),
320    };
321    let saved = save_recording(s, p, &rec, start.clone(), at);
322    if saved.is_err() {
323        s.recorder = Some(rec);
324        s.macro_recording = start;
325    }
326    saved
327}
328
329/// Saves the recording into the workbook at index `at`.
330fn save_recording(s: &mut Session, p: &Json, rec: &crate::engine::record::Recorder, start: Option<RecordingStart>, at: usize) -> Result<Json> {
331    let d = s.docs.get(at).ok_or(EngineError::NoDocument)?;
332    // A macro being debugged there could still roll the saved macro back with its run.
333    if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) {
334        return Err(EngineError::Disabled("macro.record".into(), crate::engine::macros::debug::BUSY.into()));
335    }
336    // A recording started without `macro.record` (`Session::start_recording`) takes its example
337    // input from the workbook as it is now.
338    let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
339    let (doc, wb) = (d.uid, d.wb.clone());
340    // A name given when stopping wins over the one given when starting; either one, if another
341    // macro has it, gets a suffix, so the save never replaces a macro.
342    let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) {
343        Some(n) => crate::engine::macros::unique_name(&wb, n),
344        None => crate::engine::macros::next_name(&wb),
345    };
346    let mut converted = crate::engine::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran);
347    // Steps past the macro limit or its size, and ones recorded in another workbook, are left
348    // out and counted with the ones the recorder dropped.
349    let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(crate::model::MAX_MACRO_STEPS));
350    converted.mac.steps.truncate(crate::model::MAX_MACRO_STEPS);
351    if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) {
352        converted.mac.description = desc.chars().take(crate::model::MAX_MACRO_TEXT).collect();
353    }
354    cut = cut.saturating_add(crate::engine::macros::fit_size(&mut converted.mac));
355    // The shortcut, unless another macro took it while recording; then it is left off and the
356    // result says so.
357    let mut shortcut_dropped = None;
358    if let Some(k) = &start.shortcut {
359        if crate::engine::macros::parse_shortcut(k).is_some_and(|c| crate::engine::macros::shortcut_conflict(&wb, &name, c).is_none()) {
360            converted.mac.shortcut = Some(k.clone());
361        } else {
362            shortcut_dropped = Some(k.clone());
363        }
364    }
365    let steps = converted.mac.steps.len();
366    let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
367    // Saved into that workbook even when another one is active now.
368    let active = s.active_index();
369    s.set_active(at);
370    let saved = s.execute("macro.save", json!({"macro": crate::engine::macros::macro_json(&converted.mac), "replace": false}));
371    s.set_active(active);
372    saved?;
373    // The person recorded it, so it runs without the approval a macro that came with a file
374    // needs, until it changes.
375    if let Some(d) = s.docs.iter().find(|d| d.uid == doc)
376        && let Some(m) = crate::engine::macros::find(&d.wb, &name).and_then(|i| d.wb.macros.get(i))
377    {
378        let key = (doc, crate::engine::macros::fingerprint(m));
379        s.macro_approvals.insert(key);
380    }
381    let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs});
382    if let Some(k) = shortcut_dropped {
383        out["shortcutDropped"] = json!(k);
384    }
385    Ok(out)
386}