ravenrs_sheet/engine/macros/
mod.rs1use 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
24const MACRO_KEY: &str = "macro_";
26
27pub fn parse_macro(v: &Json) -> Result<Macro, String> {
30 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
49pub fn check(m: &Macro) -> Result<(), String> {
53 m.check_limits()?;
54 check_size(m)
55}
56
57fn 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
68pub 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 let mut size = chars(¯o_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
92pub fn count(wb: &Workbook) -> usize {
95 wb.macros.len().saturating_add(wb.macros_unread.len())
96}
97
98pub 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
115pub 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
128fn 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 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
154pub fn macro_json(m: &Macro) -> Json {
156 serde_json::to_value(m).unwrap_or(Json::Null)
157}
158
159pub 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
165pub fn fingerprint(m: &Macro) -> String {
169 macro_json(m).to_string()
170}
171
172pub fn with_names(wb: &Workbook) -> crate::engine::Result<Option<Workbook>> {
176 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 for m in wb.macros.iter() {
185 check(m).map_err(crate::engine::EngineError::Other)?;
186 }
187 let mut out = wb.clone();
188 out.names.retain(|n| !stored(n));
191 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 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 out.macros = crate::model::Macros::default();
204 out.macros_unread = std::sync::Arc::default();
205 Ok(Some(out))
206}
207
208pub fn json_bytes(wb: &Workbook) -> crate::engine::Result<Vec<u8>> {
211 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
229fn 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
234fn 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
244pub 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 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 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 Some((_, None)) => kept.push(n),
274 }
275 }
276 wb.names = kept;
277 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 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 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 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
345fn macro_item(n: &DefinedName) -> Option<(String, Option<u64>)> {
347 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
354pub 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#[derive(Default)]
371struct Taken {
372 names: HashSet<String>,
373 next: HashMap<(String, usize), u64>,
374 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 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 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
423pub 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
438pub 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
453pub fn shortcut_text(letter: char) -> String {
455 format!("Ctrl+Shift+{}", letter.to_ascii_uppercase())
456}
457
458pub 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
470pub 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
488pub 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
493pub 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;