1use 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
69fn 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 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
137fn 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 crate::engine::macros::check(&renamed).map_err(EngineError::Other)?;
173 let from = std::mem::replace(m, renamed).name;
174 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 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 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 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
231fn 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
268fn 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
306fn 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
329fn 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 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 let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
339 let (doc, wb) = (d.uid, d.wb.clone());
340 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 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 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 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 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}