Skip to main content

ravenrs_sheet/engine/macros/
mod.rs

1//! GridCraft macros: typed, declarative command lists, not VBA. The model is plain data in
2//! `crate::model::Macro`; this module reads and writes it and keeps it within its limits.
3//!
4//! Storage: the JSON format keeps `Workbook::macros` as a field. In xlsx each macro is one item of
5//! GridCraft's hidden workbook data ([`crate::engine::cmd::stash`]), `_gc_macro_<n>_<k>`, written when the
6//! file is saved and taken back out of the names when it is opened, so the names never linger in
7//! the open document. Scripts made with Automate › New Script or Record Actions are a separate,
8//! older feature with their own storage; macros neither read nor change it. A recording keeps the
9//! steps a script run ran while it recorded, as the run reported them.
10
11use std::collections::{BTreeMap, HashMap, HashSet};
12
13use crate::model::{DefinedName, Macro, Step, StepKind, Workbook};
14use serde_json::{Value as Json, json};
15
16use crate::engine::cmd::stash;
17
18pub mod debug;
19pub mod policy;
20pub mod recorder;
21pub mod resolve;
22pub mod run;
23
24/// The hidden-data key prefix of a stored macro (`macro_<n>`).
25const MACRO_KEY: &str = "macro_";
26
27/// Reads a macro from JSON, as `macro.save` and files give it. A command step may also use the
28/// short form `{"command": "cell.set", "params": {…}}`.
29pub fn parse_macro(v: &Json) -> Result<Macro, String> {
30    // A newer format is refused before its steps are read, so the message says why.
31    if let Some(found) = v.get("v").and_then(Json::as_u64) {
32        crate::model::check_version(found)?;
33    }
34    let mut v = v.clone();
35    if let Some(steps) = v.get_mut("steps") {
36        normalize_steps(steps, 1)?;
37    }
38    if v.get("v").is_none()
39        && let Some(o) = v.as_object_mut()
40    {
41        o.insert("v".into(), json!(crate::model::MACRO_VERSION));
42    }
43    let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?;
44    m.name = m.name.trim().to_string();
45    check(&m)?;
46    Ok(m)
47}
48
49/// Every check a macro must pass before it is kept in a workbook: the model's limits
50/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro,
51/// from JSON, from a file or built in code, goes through this one check.
52pub fn check(m: &Macro) -> Result<(), String> {
53    m.check_limits()?;
54    check_size(m)
55}
56
57/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of
58/// JSON). Every command that adds or changes a macro calls [`check`], so the size of a macro kept
59/// in the workbook doesn't make a save fail. (A clashing visible name doesn't either:
60/// [`with_names`] stores the macro under another index.)
61fn check_size(m: &Macro) -> Result<(), String> {
62    if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS {
63        return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS));
64    }
65    Ok(())
66}
67
68/// Leaves out the last top-level steps of `m` until it fits the size [`check_size`] allows, and
69/// returns how many steps it left out (a recording cut to fit, as at the step limit). Each step's
70/// JSON is measured once.
71pub fn fit_size(m: &mut Macro) -> usize {
72    let chars = |v: &Json| v.to_string().chars().count();
73    let steps = std::mem::take(&mut m.steps);
74    // The macro without steps holds `"steps":[]`; each step adds its JSON, and a comma after the
75    // first.
76    let mut size = chars(&macro_json(m));
77    let mut kept = 0usize;
78    for (i, st) in steps.iter().enumerate() {
79        let add = chars(&serde_json::to_value(st).unwrap_or(Json::Null)).saturating_add(usize::from(i > 0));
80        if size.saturating_add(add) > stash::MAX_ITEM_CHARS {
81            break;
82        }
83        size = size.saturating_add(add);
84        kept = i.saturating_add(1);
85    }
86    let cut = steps.len().saturating_sub(kept);
87    m.steps = steps;
88    m.steps.truncate(kept);
89    cut
90}
91
92/// How many macros the workbook holds: the readable ones and the ones kept in a newer format
93/// (`Workbook::macros_unread`), which a save writes back and the next open counts too.
94pub fn count(wb: &Workbook) -> usize {
95    wb.macros.len().saturating_add(wb.macros_unread.len())
96}
97
98/// Refuses one more macro in a workbook that holds [`crate::model::MAX_MACROS`] already,
99/// counting the kept ones, so a save never holds more than the next open keeps.
100pub fn check_room(wb: &Workbook) -> Result<(), String> {
101    let max = crate::model::MAX_MACROS;
102    if count(wb) < max {
103        return Ok(());
104    }
105    Err(match wb.macros_unread.len() {
106        0 => format!("A workbook can hold at most {max} macros."),
107        kept => format!(
108            "A workbook can hold at most {max} macros, and {kept} of the {} in this one {} made by a newer version of GridCraft and kept unchanged.",
109            count(wb),
110            if kept == 1 { "was" } else { "were" }
111        ),
112    })
113}
114
115/// The name of the macro kept in a newer format called `name` (compared as [`find`] compares),
116/// as the file held it. Its name is taken: a new or renamed macro with it would clash when the
117/// newer version reads the file.
118pub fn kept_name(wb: &Workbook, name: &str) -> Option<String> {
119    let name = name.trim();
120    wb.macros_unread
121        .iter()
122        .filter_map(|v| v.get("name").and_then(Json::as_str))
123        .map(str::trim)
124        .find(|k| k.eq_ignore_ascii_case(name))
125        .map(str::to_string)
126}
127
128/// Rewrites the short step form into the tagged one, in place, refusing nesting past the limit
129/// before the steps are deserialized.
130fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> {
131    let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) };
132    for st in list {
133        let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) };
134        if !o.contains_key("type")
135            && let Some(id) = o.remove("command")
136        {
137            o.insert("type".into(), json!("command"));
138            o.insert("id".into(), id);
139        }
140        // A step with a body is a Repeat or an If: one at the top is nested 1 deep.
141        let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key));
142        if nested && level > crate::model::MAX_MACRO_DEPTH {
143            return Err(format!("Repeat and If steps can nest at most {} deep.", crate::model::MAX_MACRO_DEPTH));
144        }
145        for key in ["body", "then", "else"] {
146            if let Some(body) = o.get_mut(key) {
147                normalize_steps(body, level + 1)?;
148            }
149        }
150    }
151    Ok(())
152}
153
154/// A macro as JSON (the shape `macro.get` returns and `macro.save` takes).
155pub fn macro_json(m: &Macro) -> Json {
156    serde_json::to_value(m).unwrap_or(Json::Null)
157}
158
159/// The index of the macro called `name` (case-insensitive).
160pub fn find(wb: &Workbook, name: &str) -> Option<usize> {
161    let name = name.trim();
162    wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name))
163}
164
165/// A macro's content as canonical JSON text (object keys sorted): approval to run a macro holds
166/// until the macro changes. It is the text itself rather than a hash, so no two different macros
167/// share it.
168pub fn fingerprint(m: &Macro) -> String {
169    macro_json(m).to_string()
170}
171
172/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the
173/// field, or `None` when there is nothing to change. The macros keep their order; the index `n`
174/// is this build's choice, so it steps around any a person's own visible name already uses.
175pub fn with_names(wb: &Workbook) -> crate::engine::Result<Option<Workbook>> {
176    // Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some
177    // other key stays, as `load` leaves it.
178    let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some());
179    if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) {
180        return Ok(None);
181    }
182    // A macro that skipped `check` (built in code) fails the save, as the JSON save does, rather
183    // than being written and dropped the next time the file opens.
184    for m in wb.macros.iter() {
185        check(m).map_err(crate::engine::EngineError::Other)?;
186    }
187    let mut out = wb.clone();
188    // One pass drops the items of the last save, and one writes the macros (a file can hold a
189    // thousand macros and many thousands of names).
190    out.names.retain(|n| !stored(n));
191    // A visible workbook-scope name of the storage shape is someone's own, and `set_many` won't
192    // write a hidden one of the same name beside it. Such a name (`_GC_MACRO_1_0`, or one a
193    // person defined after the file opened) blocks its item's key, in any chunk, so the macros
194    // take the free indices from 0 up. The keys compare as the stash reads them, so this skips
195    // exactly what `set_many` would refuse, and no arithmetic depends on a number from the file.
196    let blocked: HashSet<String> = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect();
197    let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key));
198    // Macros in a newer format follow the readable ones, written back as the file held them.
199    let texts = wb.macros.iter().map(|m| macro_json(m).to_string()).chain(wb.macros_unread.iter().map(Json::to_string));
200    let items: Vec<(String, String)> = texts.zip(free).map(|(text, key)| (key, text)).collect();
201    stash::set_many(&mut out, &items)?;
202    // An empty list of its own, not a copy of the shared one cleared.
203    out.macros = crate::model::Macros::default();
204    out.macros_unread = std::sync::Arc::default();
205    Ok(Some(out))
206}
207
208/// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into
209/// the `macros` list as the file held them.
210pub fn json_bytes(wb: &Workbook) -> crate::engine::Result<Vec<u8>> {
211    // Every path that adds a macro checks it, so this refuses only one that skipped the check, as
212    // the xlsx save does, instead of writing a macro the next open would drop.
213    for m in &wb.macros {
214        check(m).map_err(crate::engine::EngineError::Other)?;
215    }
216    let bytes = if wb.macros_unread.is_empty() {
217        serde_json::to_vec_pretty(wb)
218    } else {
219        serde_json::to_value(wb).and_then(|mut v| {
220            if let Some(list) = v.as_object_mut().map(|o| o.entry("macros").or_insert_with(|| Json::Array(vec![]))).and_then(Json::as_array_mut) {
221                list.extend(wb.macros_unread.iter().cloned());
222            }
223            serde_json::to_vec_pretty(&v)
224        })
225    };
226    bytes.map_err(|e| crate::engine::EngineError::Other(e.to_string()))
227}
228
229/// Whether `v` is a macro in a newer format than this build reads.
230fn is_newer(v: &Json) -> bool {
231    v.get("v").and_then(Json::as_u64).is_some_and(|found| found > u64::from(crate::model::MACRO_VERSION))
232}
233
234/// Keeps a macro in a newer format unread, if it is small enough to save back
235/// ([`stash::MAX_ITEM_CHARS`] characters of JSON, the size every xlsx save can write).
236fn keep_unread(unread: &mut Vec<Json>, v: Json, bad: &mut usize) {
237    if v.to_string().chars().count() <= stash::MAX_ITEM_CHARS {
238        unread.push(v);
239    } else {
240        *bad = bad.saturating_add(1);
241    }
242}
243
244/// After a file is opened: moves macros out of hidden names into `Workbook::macros`, keeps
245/// macros in a newer format unread (`Workbook::macros_unread`), and drops macros that are damaged
246/// or over the limits. Returns a warning for what was kept unread and one for what was dropped.
247pub fn load(wb: &mut Workbook) -> Vec<String> {
248    let mut warnings = Vec::new();
249    let mut bad = 0usize;
250    let mut unread = Vec::new();
251    // Entries of the JSON format's field, each read on its own so a damaged one drops alone.
252    let read = std::mem::take(&mut wb.macros_read);
253    bad = bad.saturating_add(read.len().saturating_sub(crate::model::MAX_MACROS));
254    for v in read.into_iter().take(crate::model::MAX_MACROS) {
255        if is_newer(&v) {
256            keep_unread(&mut unread, v, &mut bad);
257            continue;
258        }
259        match parse_macro(&v) {
260            Ok(m) => wb.macros.push(m),
261            Err(_) => bad += 1,
262        }
263    }
264    // One pass takes the hidden macro names out of the workbook, grouped by macro in saved order.
265    let mut stored: BTreeMap<(u64, String), Vec<DefinedName>> = BTreeMap::new();
266    let mut kept = Vec::new();
267    for n in std::mem::take(&mut wb.names) {
268        match macro_item(&n) {
269            None => kept.push(n),
270            Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n),
271            // A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is,
272            // unread, and a save keeps it.
273            Some((_, None)) => kept.push(n),
274        }
275    }
276    wb.names = kept;
277    // Past the macro limit, the rest are counted without being read.
278    bad = bad.saturating_add(stored.len().saturating_sub(crate::model::MAX_MACROS));
279    let mut found = Vec::new();
280    let mut item = Workbook::new();
281    for ((_, key), names) in stored.into_iter().take(crate::model::MAX_MACROS) {
282        item.names = names;
283        let v = stash::get_json(&item, &key);
284        if let Some(v) = v.as_ref().filter(|v| is_newer(v)) {
285            keep_unread(&mut unread, v.clone(), &mut bad);
286            continue;
287        }
288        match v.ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) {
289            Ok(m) => found.push(m),
290            Err(_) => bad += 1,
291        }
292    }
293    // All of them go through the same limits, and a clashing name gets a suffix.
294    let mut all = std::mem::take(&mut wb.macros);
295    all.extend(found);
296    let mut taken = Taken::default();
297    for m in all {
298        if wb.macros.len() >= crate::model::MAX_MACROS {
299            bad += 1;
300            continue;
301        }
302        let mut m = m;
303        let checked = m.check_limits().and_then(|()| {
304            m.name = taken.unique(m.name.trim());
305            // The macro as it is kept, renamed, must still pass, or the next save would lose it.
306            check(&m)
307        });
308        match checked {
309            Ok(()) => {
310                taken.insert(&m.name);
311                wb.macros.push(m);
312            }
313            Err(_) => bad += 1,
314        }
315    }
316    // Readable and unread macros together stay within the limit, the readable ones first.
317    let room = crate::model::MAX_MACROS.saturating_sub(wb.macros.len());
318    bad = bad.saturating_add(unread.len().saturating_sub(room));
319    unread.truncate(room);
320    if !unread.is_empty() {
321        let one = unread.len() == 1;
322        warnings.push(format!(
323            "{} macro{} in the workbook {} made by a newer version of GridCraft: this version can't show or run {}, and saving keeps {} unchanged.",
324            unread.len(),
325            if one { "" } else { "s" },
326            if one { "was" } else { "were" },
327            if one { "it" } else { "them" },
328            if one { "it" } else { "them" },
329        ));
330    }
331    wb.macros_unread = std::sync::Arc::new(unread);
332    if bad > 0 {
333        warnings.push(format!(
334            "{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).",
335            if bad == 1 { "" } else { "s" },
336            if bad == 1 { "it is" } else { "they are" },
337            crate::model::MAX_MACROS,
338            crate::model::MAX_MACRO_STEPS,
339            crate::model::MAX_MACRO_DEPTH
340        ));
341    }
342    warnings
343}
344
345/// The item key (`macro_<n>`, lower-cased) of a hidden macro name, and `n` when it is a number.
346fn macro_item(n: &DefinedName) -> Option<(String, Option<u64>)> {
347    // Only a hidden workbook-scope name is macro storage: a person's own visible name that
348    // happens to look like one stays a name.
349    let key = stash::item_key(&n.name).filter(|k| n.hidden && n.scope.is_none() && k.starts_with(MACRO_KEY))?;
350    let i = key.strip_prefix(MACRO_KEY).and_then(|i| i.parse().ok());
351    Some((key, i))
352}
353
354/// `name`, or `name (2)`, `name (3)`… if a macro already has it, a kept one too. A free name comes back as it is;
355/// a renamed one is shortened to make room for the suffix, so it stays within
356/// [`crate::model::MAX_MACRO_NAME`] characters. The result still goes through [`check`].
357pub fn unique_name(wb: &Workbook, name: &str) -> String {
358    let mut taken = Taken::default();
359    for m in &wb.macros {
360        taken.insert(&m.name);
361    }
362    for kept in wb.macros_unread.iter().filter_map(|v| v.get("name").and_then(Json::as_str)) {
363        taken.insert(kept);
364    }
365    taken.unique(name)
366}
367
368/// Macro names in use, compared as [`find`] compares them, and the next suffix to try for each
369/// shortened name and suffix width, so naming many clashing macros takes near-linear work.
370#[derive(Default)]
371struct Taken {
372    names: HashSet<String>,
373    next: HashMap<(String, usize), u64>,
374    /// Name lookups made, for tests.
375    probes: usize,
376}
377
378impl Taken {
379    fn key(name: &str) -> String {
380        name.trim().to_ascii_lowercase()
381    }
382
383    fn insert(&mut self, name: &str) {
384        self.names.insert(Taken::key(name));
385    }
386
387    fn contains(&mut self, name: &str) -> bool {
388        self.probes = self.probes.saturating_add(1);
389        self.names.contains(&Taken::key(name))
390    }
391
392    /// The first of `name`, `name (2)`, `name (3)`… not in use.
393    fn unique(&mut self, name: &str) -> String {
394        let name = if name.is_empty() { "Macro" } else { name };
395        if !self.contains(name) {
396            return name.to_string();
397        }
398        let mut n: u64 = 2;
399        loop {
400            let suffix = format!(" ({n})");
401            let keep = crate::model::MAX_MACRO_NAME.saturating_sub(suffix.chars().count());
402            let base: String = name.chars().take(keep).collect();
403            let base = base.trim_end();
404            // Every candidate for this base and suffix width below `next` is in use already
405            // (names are only added), so the search goes on from there.
406            let key = (Taken::key(base), suffix.len());
407            let next = self.next.get(&key).copied().unwrap_or(n);
408            if next > n {
409                n = next;
410                continue;
411            }
412            let Some(after) = n.checked_add(1) else { return name.to_string() };
413            self.next.insert(key, after);
414            let candidate = format!("{base}{suffix}");
415            if !self.contains(&candidate) {
416                return candidate;
417            }
418            n = after;
419        }
420    }
421}
422
423/// Ctrl+Shift (Cmd+Shift on a Mac) letters that GridCraft already uses, on any platform, which a
424/// macro cannot take.
425pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[
426    ('F', "Format Cells (Font) or Filter"),
427    ('G', "Workbook Statistics"),
428    ('H', "Replace"),
429    ('J', "Ungroup"),
430    ('K', "Group"),
431    ('L', "Filter"),
432    ('R', "Sort"),
433    ('S', "Save As"),
434    ('T', "AutoSum"),
435    ('Z', "Redo"),
436];
437
438/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"Cmd+Shift+M"`, `"⌘⇧M"` and `"m"` all give
439/// `'M'`. `None` when it is not one letter from A to Z, alone or after Ctrl+Shift (Cmd+Shift):
440/// other modifiers (`"Ctrl+Alt+M"`) are refused rather than read as Ctrl+Shift.
441pub fn parse_shortcut(s: &str) -> Option<char> {
442    let letter = |t: &str| {
443        let mut chars = t.trim().chars();
444        match (chars.next(), chars.next()) {
445            (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()),
446            _ => None,
447        }
448    };
449    let t = s.trim().to_ascii_lowercase();
450    ["ctrl+shift+", "cmd+shift+", "⌘⇧"].iter().find_map(|p| t.strip_prefix(p)).map_or_else(|| letter(&t), letter)
451}
452
453/// How a shortcut is stored and shown: `Ctrl+Shift+M`.
454pub fn shortcut_text(letter: char) -> String {
455    format!("Ctrl+Shift+{}", letter.to_ascii_uppercase())
456}
457
458/// Why macro `name` cannot use Ctrl+Shift+`letter`: GridCraft uses it, or another macro has it.
459pub fn shortcut_conflict(wb: &Workbook, name: &str, letter: char) -> Option<String> {
460    let letter = letter.to_ascii_uppercase();
461    if let Some((_, what)) = RESERVED_SHORTCUTS.iter().find(|(c, _)| *c == letter) {
462        return Some(format!("{} is {what}. Choose another letter.", shortcut_text(letter)));
463    }
464    wb.macros
465        .iter()
466        .find(|m| !m.name.eq_ignore_ascii_case(name.trim()) && m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter))
467        .map(|m| format!("{} already runs macro “{}”. Choose another letter.", shortcut_text(letter), m.name))
468}
469
470/// `name` trimmed, if it can name a new macro in `wb`; otherwise why not: it is empty, too long,
471/// holds line breaks or another macro has it.
472pub fn check_new_name(wb: &Workbook, name: &str) -> Result<String, String> {
473    let name = name.trim();
474    let probe = Macro { name: name.to_string(), ..Macro::default() };
475    probe.check_limits()?;
476    if name.chars().any(char::is_control) {
477        return Err("A macro name cannot hold line breaks or other control characters.".into());
478    }
479    if find(wb, name).is_some() {
480        return Err(format!("A macro called “{name}” already exists. Choose another name."));
481    }
482    if let Some(kept) = kept_name(wb, name) {
483        return Err(format!("A macro called “{kept}” is kept from a newer version of GridCraft. Choose another name."));
484    }
485    Ok(name.to_string())
486}
487
488/// The first free `Macro1`, `Macro2`… name, also free among the kept macros ([`kept_name`]).
489pub fn next_name(wb: &Workbook) -> String {
490    (1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none() && kept_name(wb, n).is_none()).unwrap_or_else(|| "Macro".into())
491}
492
493/// Every command step of `steps` (inside Repeat and If too), with its path.
494pub fn command_steps(steps: &[Step]) -> Vec<(String, &str, &Json)> {
495    fn walk<'a>(steps: &'a [Step], prefix: &str, out: &mut Vec<(String, &'a str, &'a Json)>) {
496        for (i, st) in steps.iter().enumerate() {
497            let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) };
498            match &st.kind {
499                StepKind::Command { id, params } => out.push((path, id.as_str(), params)),
500                StepKind::Repeat { body, .. } => walk(body, &path, out),
501                StepKind::If { then, otherwise, .. } => {
502                    walk(then, &format!("{path}.then"), out);
503                    walk(otherwise, &format!("{path}.else"), out);
504                }
505                StepKind::Stop { .. } => {}
506            }
507        }
508    }
509    let mut out = Vec::new();
510    walk(steps, "", &mut out);
511    out
512}
513
514#[cfg(test)]
515mod tests;