diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 102edc2d36..556d41759e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1386,6 +1386,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } }; rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); + cx.edits.push((src_sheet_name.clone(), e)); cx.structural = true; } let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 3c45031afc..0078cb4fe1 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -294,7 +294,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) }; shift_cell_features(sh, &e); let name = sh.name.clone(); - rewrite(&mut cx.wb, &name, &e); + rewrite(cx, &name, &e); let sh = cx.sheet_mut(sheet)?; for (i, c) in (r.start.col..=r.end.col).enumerate() { sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1))); diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs new file mode 100644 index 0000000000..e53482e94c --- /dev/null +++ b/crates/engine/src/cmd/macros.rs @@ -0,0 +1,27 @@ +//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model, +//! tokens, runner and policy. Automate scripts (`automate.*`) are a separate, older feature. + +use serde_json::Value as Json; + +use super::*; + +pub fn specs() -> Vec { + vec![cmd!( + "macro.run", + "Run Macro", + [], + None, + "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight (a shift stops the run as fail does), overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?}", + has_doc, + run + )] +} + +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`")) +} + +fn run(s: &mut Session, p: &Json) -> Result { + let name = name_param("macro.run", p)?; + crate::macros::run::run(s, name, p) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8b2899f27e..55da0377eb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub mod format; pub mod formulas; pub mod insert; pub mod inspect; +pub mod macros; pub mod pivot; pub mod print; pub mod review; @@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(view::specs()); v.extend(inspect::specs()); v.extend(extra::specs()); + v.extend(macros::specs()); v.extend(draw::specs()); v.extend(spelling::specs()); v.extend(solver::specs()); @@ -338,6 +340,9 @@ pub struct Ctx<'a> { /// The command applied sheet protection itself (row/column inserts and deletes the /// protection allows), so cells it shifts aren't refused as edits of locked cells. pub protection_checked: bool, + /// The structural edits the command made, in order (the sheet's name, the edit), as + /// references were rewritten for them. + pub edits: Vec<(String, gridcraft_formula::adjust::Edit)>, } pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> Result { @@ -355,15 +360,26 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) #[allow(clippy::disallowed_methods)] let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) }; let mut sel = d.selection.clone(); - let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false }; + let mut ctx = Ctx { + wb: (*d.wb).clone(), + changed: Vec::new(), + structural: false, + sel: &mut sel, + fit_rows: Vec::new(), + protection_checked: false, + edits: Vec::new(), + }; let r = f(&mut ctx)?; - let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx; + let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx; if !protection_checked { check_protection(&d.wb, &wb, &d.unlocked_ranges)?; } if !structural { check_arrays(&d.wb, &wb)?; } + if let Some(log) = d.edits.as_mut() { + log.extend(edits); + } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index e6caa1dc79..dfab93736b 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -223,7 +223,9 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: } /// Rewrites every formula reference (and chart series) for a structural edit on `target`. -pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { +pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) { + cx.edits.push((target.to_string(), e.clone())); + let wb = &mut cx.wb; super::edit::rewrite_all_formulas(wb, target, e); for si in 0..wb.sheets.len() { let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); @@ -307,7 +309,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { } } } - rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count }); + rewrite(cx, &name, &Edit::Insert { axis, at, count }); cx.structural = true; Ok(Json::Null) })?; @@ -372,7 +374,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { } shift_sheet_features(sh, axis, at, count, false); } - rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count }); + rewrite(cx, &name, &Edit::Delete { axis, at, count }); } cx.structural = true; Ok(Json::Null) @@ -424,7 +426,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result { sh.cells.shift_cols(before, count as i64); shift_sheet_features(sh, Axis::Cols, before, count, true); } - rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count }); + rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count }); let mut touched = Vec::new(); { @@ -462,14 +464,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result { for cell in touched { cx.touch(sheet, cell); } - rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination }); + rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination }); { let sh = cx.sheet_mut(sheet)?; sh.cells.shift_cols(delete_at + count, -(count as i64)); shift_sheet_features(sh, Axis::Cols, delete_at, count, false); } - rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count }); + rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count }); *cx.sel = crate::Selection { active: CellRef::new(active_row, final_start), anchor: CellRef::new(active_row, final_start), @@ -514,7 +516,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { } shift_cell_features(sh, &e); let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); - rewrite(&mut cx.wb, &name, &e); + rewrite(cx, &name, &e); cx.structural = true; Ok(Json::Null) })?; @@ -549,7 +551,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { shift_cell_features(sh, &e); } // References into the deleted cells become #REF!; the rest move. - rewrite(&mut cx.wb, &name, &e); + rewrite(cx, &name, &e); cx.structural = true; Ok(Json::Null) })?; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b8ef1abe92..d47b785203 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -611,7 +611,7 @@ impl Drop for ScriptSandbox { /// thread is being torn down). fn script_sandbox_check(path: &str) -> Result<()> { if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) { - return Err(EngineError::Other(format!("{path}: scripts can't read or write files"))); + return Err(EngineError::Other(format!("{path}: scripts, and macro steps without the permission, can't read or write files"))); } Ok(()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d7aa1bfe1d..9cf2870dca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,12 @@ pub enum EngineError { NoDocument, #[error("{0}")] Other(String), + /// The command would replace something; run it again with `overwrite: true` to go ahead. + #[error("confirm: {0} (run again with `overwrite: true`)")] + Confirm(String), + /// Active content (a macro) asks to run; run it again with `approve: true` to allow it. + #[error("approve: {0} (run again with `approve: true`)")] + Approve(String), #[error("internal error in `{0}` (the workbook was kept as it was): {1}")] Internal(String, String), } @@ -203,6 +209,9 @@ pub struct DocState { /// workbook is opened from a file, carried by content pasted from such a workbook, and never /// changed by a trust decision. pub trust: Trust, + /// While a macro step runs: the structural edits its command made (the sheet's name, the + /// edit), so the macro's inputs, output and Repeat ranges follow their cells as formulas do. + pub edits: Option>, /// The references `edit.goTo` with `record: true` went to in this document, newest first, /// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`). pub go_to_recent: Vec, @@ -296,6 +305,7 @@ impl DocState { content_kind: None, scripts_trusted, trust, + edits: None, go_to_recent: vec![], } } @@ -445,6 +455,9 @@ pub struct Session { pub recorder: Option, /// How many `execute` calls are running (0 between top-level commands). depth: u32, + /// Macros the person allowed to run in this session: (document uid, the macro's content as + /// canonical JSON, from [`macros::fingerprint`]). + pub macro_approvals: std::collections::HashSet<(u64, String)>, /// Windows hidden with View › Hide. pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 8c23fbf0ef..70fda58339 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -14,7 +14,9 @@ use serde_json::{Value as Json, json}; use crate::cmd::stash; +pub mod policy; pub mod resolve; +pub mod run; /// The hidden-data key prefix of a stored macro (`macro_`). const MACRO_KEY: &str = "macro_"; @@ -96,6 +98,13 @@ pub fn find(wb: &Workbook, name: &str) -> Option { wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name)) } +/// A macro's content as canonical JSON text (object keys sorted): approval to run a macro holds +/// until the macro changes. It is the text itself rather than a hash, so no two different macros +/// share it. +pub fn fingerprint(m: &Macro) -> String { + macro_json(m).to_string() +} + /// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro__`) instead of the /// field, or `None` when there is nothing to change. The macros keep their order; the index `n` /// is this build's choice, so it steps around any a person's own visible name already uses. diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs new file mode 100644 index 0000000000..24e7724ad1 --- /dev/null +++ b/crates/engine/src/macros/policy.rs @@ -0,0 +1,141 @@ +//! What a macro step may run. Macros run commands, so this keeps a macro from saving, opening or +//! closing files, driving the app, running other macros, or reading and writing files without the +//! person knowing. It goes by command id and the `path` parameter; the runner also runs every +//! step except an approved file step inside the scripts' file sandbox, so a command that touches a +//! file under another parameter can't either. + +use serde_json::Value as Json; + +use crate::Trust; + +/// Whether a command may run as a macro step. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Policy { + Allow, + /// Allowed once the person approves the macro; the text says why it asks ("came with a + /// workbook you opened"). + Ask(String), + /// Reads or writes files: the macro must list the command in its permissions, and the person + /// approves the macro before it runs. The text says what it does ("reads a file"). + NeedsPermission(String), + /// Never runs in a macro; the text says why. + Deny(String), +} + +/// Prefixes of commands that never run in a macro, with the reason. +const DENIED_PREFIXES: &[(&str, &str)] = &[ + ("file.", "macros don't open, save, close or create workbooks"), + ("app.", "macros don't change the app"), + ("window.", "macros don't switch windows"), + ("automate.", "macros don't record or run scripts or other macros"), + ("macro.", "macros don't record or run other macros"), +]; + +/// Single commands that never run in a macro. +const DENIED: &[(&str, &str)] = &[ + ("edit.undo", "a macro is undone as one step after it runs"), + ("edit.redo", "a macro is undone as one step after it runs"), + ("view.newWindow", "macros don't open, hide or show windows"), + ("view.hideWindow", "macros don't open, hide or show windows"), + ("view.unhideWindow", "macros don't open, hide or show windows"), +]; + +/// Commands under a denied prefix that only change the workbook. +const ALLOWED: &[&str] = &["file.properties"]; + +/// Commands that read a file when given a `path`. +const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"]; + +/// File commands that only produce bytes unless given a `path` (then they write a file). +const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"]; + +/// Whether command `id` with `params` writes a file (an export or print given a `path`). +pub fn writes_file(id: &str, params: &Json) -> bool { + EXPORTS.contains(&id) && params.get("path").is_some_and(|p| !p.is_null()) +} + +/// The policy for running command `id` with `params` as a macro step in a document with `trust`. +/// +/// Any `path` other than `null` counts as touching a file: a token such as `{"$ref": "Input"}` +/// becomes a path when the step runs, and exports write to `""` and `" "` as file names. The +/// runner checks the parameters both as written and as resolved. +pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy { + let has_path = params.get("path").is_some_and(|p| !p.is_null()); + let base = if EXPORTS.contains(&id) { + if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow } + } else if ALLOWED.contains(&id) { + Policy::Allow + } else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) { + Policy::Deny((*why).into()) + } else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) { + Policy::Deny((*why).into()) + } else if READS_FILES.contains(&id) && has_path { + Policy::NeedsPermission("reads a file".into()) + } else { + Policy::Allow + }; + match base { + Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn the_deny_and_permission_lists() { + let none = json!({}); + let path = json!({"path": "/tmp/x"}); + for id in [ + "file.saveAs", + "file.save", + "file.open", + "file.close", + "file.new", + "app.commands", + "window.activate", + "automate.runScript", + "automate.recordActions", + "macro.run", + "macro.record", + "edit.undo", + "edit.redo", + "view.newWindow", + "view.hideWindow", + "view.unhideWindow", + ] { + assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}"); + assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}"); + } + for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] { + assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}"); + assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}"); + } + // Without a path they touch no file. + for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] { + assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); + assert_eq!(macro_policy(id, &json!({"path": null}), Trust::Local), Policy::Allow, "{id}"); + } + // A path that is blank or a token still touches a file once the step runs. + for path in [json!(""), json!(" "), json!({"$ref": "Input"}), json!("{{Input}}.csv"), json!(1)] { + for id in ["file.exportCsv", "data.fromTextCsv"] { + let p = json!({ "path": path }); + assert!(matches!(macro_policy(id, &p, Trust::Local), Policy::NeedsPermission(_)), "{id} {p}"); + } + } + for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] { + assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); + assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}"); + } + } + + #[test] + fn every_listed_command_exists() { + for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) { + assert!(crate::find_command(id).is_some(), "{id}"); + } + } +} diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs new file mode 100644 index 0000000000..917deed74d --- /dev/null +++ b/crates/engine/src/macros/run.rs @@ -0,0 +1,1074 @@ +//! Running a macro. +//! +//! 1. **Gate**: every step's command goes through [`macro_policy`]. Denied commands stop the macro +//! before anything runs; commands that touch files must be listed in the macro's permissions; +//! a macro from a file someone else made, or one that touches files, runs only once the person +//! approves it (once per macro content, for the session). +//! 2. **Inputs**: each declared input is bound to a range (given, its default, or the selection) +//! and checked against its shape and column types, with messages that name the cells. +//! 3. **Conflicts**: unless the macro may overwrite, each step is checked as it runs. A step that +//! writes over data outside the inputs (cells that held data when the run started, sheets and +//! objects it deletes) stops the run, which asks (`ask`) or fails (the other rules), and the +//! run rolls back. Shifting the data to make room (`shiftDown`, `shiftRight`) needs to know +//! where the macro writes before it runs, which this version doesn't work out, so those rules +//! stop the run as `fail` does. +//! 4. **Run**: one undo step (`Session::grouped`); an error at any step rolls everything back and +//! names the step. +//! +//! The steps run on a small machine with an explicit frame stack, so the debugger can stop +//! between any two steps and pick up again on a later frame. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_formula::adjust::Edit; +use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook}; +use serde::Serialize; +use serde_json::{Value as Json, json}; + +use super::policy::{Policy, macro_policy, writes_file}; +use super::resolve::{self, Env, Place, ResolveError}; +use crate::{EngineError, Result, Session, Trust, UiRequest}; + +/// The most steps one run executes (counting each pass through a Repeat). +pub const MAX_EXECUTED_STEPS: u64 = 100_000; +/// How long a run may take, in milliseconds. The web build has no clock; there only the step +/// limit applies. +pub const DEFAULT_TIME_LIMIT_MS: f64 = 60_000.0; +/// The most cells an input may have for its content to be checked. +pub const MAX_INPUT_CELLS: u64 = 1_000_000; +/// The most bytes of parameters one run passes to its commands, all steps together: on the web +/// build, where there is no clock, this and the step limit bound the work a run does. +pub const MAX_PARAM_BYTES: u64 = 256 * 1024 * 1024; +/// The most ranges a list of cells gives per sheet (the counts stay exact). +const MAX_LISTED_RANGES: usize = 500; + +/// An input bound to a range: name, where, whether its first row is a header. +pub type Bound = (String, Place, bool); + +/// Where and why a step failed. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct StepError { + /// The step's path: `3`, `2.1` inside a Repeat, `4.then.1` inside an If. + pub step: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + pub message: String, + /// The step ran past the edge of the sheet. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub edge: bool, + /// The step would write over data and the conflict rule is ask: overwriting answers it. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub confirm: bool, + /// Files the run wrote before the step failed ("step 1 (file.exportCsv)"): rolling back the + /// workbook leaves them written. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub wrote_files: Vec, +} + +impl StepError { + fn new(step: &str, command: Option<&str>, message: impl Into) -> StepError { + StepError { + step: step.to_string(), + command: command.map(str::to_string), + message: message.into(), + edge: false, + confirm: false, + wrote_files: Vec::new(), + } + } + + fn resolve(step: &str, command: Option<&str>, e: ResolveError) -> StepError { + let edge = matches!(e, ResolveError::Edge(_)); + StepError { edge, ..StepError::new(step, command, e.message()) } + } + + /// `Step 3 (cell.set): …` + pub fn describe(&self) -> String { + match &self.command { + Some(c) => format!("Step {} ({c}): {}", self.step, self.message), + None => format!("Step {}: {}", self.step, self.message), + } + } + + pub fn into_engine(self, name: &str) -> EngineError { + let text = format!("Macro '{name}' stopped. {} {}", self.describe(), rolled_back(&self.wrote_files)); + if self.confirm { EngineError::Confirm(text) } else { EngineError::Other(text) } + } +} + +/// What a run that rolled back leaves: "Nothing was changed.", or the files it wrote before. +pub fn rolled_back(wrote_files: &[String]) -> String { + match wrote_files { + [] => "Nothing was changed.".into(), + [one] => format!("The workbook was put back; the file {one} wrote stays."), + many => format!("The workbook was put back; the files {} wrote stay.", join_and(many)), + } +} + +/// The size of `v` as JSON text, near enough to bound how much a run passes to its commands. +fn json_bytes(v: &Json) -> u64 { + match v { + Json::String(t) => t.len() as u64 + 2, + Json::Array(a) => a.iter().map(json_bytes).fold(2, u64::saturating_add), + Json::Object(o) => o.iter().map(|(k, v)| (k.len() as u64 + 3).saturating_add(json_bytes(v))).fold(2, u64::saturating_add), + _ => 8, + } +} + +// ---------------------------------------------------------------- gate + +/// Checks every enabled step against the policy: denied commands and undeclared file access are +/// errors; otherwise returns what the person must approve (empty when nothing). +pub fn gate(m: &Macro, trust: Trust) -> Result> { + let mut asks = BTreeSet::new(); + let mut err = None; + walk_enabled(&m.steps, "", &mut |path, id, params| { + if err.is_some() { + return; + } + match macro_policy(id, params, trust) { + Policy::Allow => {} + Policy::Ask(why) => { + asks.insert(why); + } + Policy::NeedsPermission(what) => { + if m.permissions.iter().any(|p| p == id) { + asks.insert(format!("runs {id}, which {what}")); + } else { + err = Some(format!("Step {path} ({id}) {what}. Add {id} to the macro's permissions to allow it.")); + } + } + Policy::Deny(why) => err = Some(format!("Step {path} ({id}) can't run in a macro: {why}.")), + } + }); + match err { + Some(e) => Err(EngineError::Other(format!("Macro '{}' can't run. {e}", m.name))), + None => Ok(asks.into_iter().collect()), + } +} + +/// Calls `f(path, id, params)` for each enabled command step (skipping disabled steps' bodies). +fn walk_enabled(steps: &[Step], prefix: &str, f: &mut impl FnMut(&str, &str, &Json)) { + for (i, st) in steps.iter().enumerate() { + if !st.enabled { + continue; + } + let path = child_path(prefix, i); + match &st.kind { + StepKind::Command { id, params } => f(&path, id, params), + StepKind::Repeat { body, .. } => walk_enabled(body, &path, f), + StepKind::If { then, otherwise, .. } => { + walk_enabled(then, &format!("{path}.then"), f); + walk_enabled(otherwise, &format!("{path}.else"), f); + } + StepKind::Stop { .. } => {} + } + } +} + +fn child_path(prefix: &str, i: usize) -> String { + if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) } +} + +/// Approves the macro for this session when `approve` is set, or asks (an +/// [`EngineError::Approve`]) when it needs approval it doesn't have. +pub fn approve(s: &mut Session, m: &Macro, doc: u64, asks: &[String], approve: bool) -> Result<()> { + if asks.is_empty() { + return Ok(()); + } + let key = (doc, super::fingerprint(m)); + if s.macro_approvals.contains(&key) { + return Ok(()); + } + if approve { + s.macro_approvals.insert(key); + return Ok(()); + } + Err(EngineError::Approve(format!("Run macro '{}'? It {}.", m.name, join_and(asks)))) +} + +fn join_and(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [init @ .., last] => format!("{} and {last}", init.join(", ")), + } +} + +// ---------------------------------------------------------------- inputs + +/// A macro ready to run: its inputs bound and checked, its output anchor resolved. +#[derive(Clone, Debug)] +pub struct Prepared { + pub mac: Macro, + pub doc: u64, + pub trust: Trust, + pub inputs: Vec, + pub output: Option, +} + +impl Prepared { + pub fn inputs_json(&self, wb: &Workbook) -> Json { + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None }; + Json::Object(self.inputs.iter().map(|(n, p, _)| (n.clone(), json!(resolve::a1(*p, &env)))).collect()) + } +} + +/// Binds the macro's inputs on the active document (`given`: `{"Input": "B2:D9"}`, or a reference +/// for the first input) and checks them. Errors list every problem found. +pub fn prepare(s: &Session, m: &Macro, given: Option<&Json>) -> Result { + let d = s.doc()?; + let wb = &d.wb; + let mut inputs: Vec = Vec::new(); + let mut problems = Vec::new(); + for (i, input) in m.inputs.iter().enumerate() { + let named = given.and_then(Json::as_object).and_then(|o| o.iter().find(|(k, _)| k.eq_ignore_ascii_case(&input.name)).map(|(_, v)| v)); + let text = named.and_then(Json::as_str).or_else(|| if i == 0 { given.and_then(Json::as_str) } else { None }).or(input.default_ref.as_deref()); + let place = match text { + Some(t) => { + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; + match resolve::resolve_ref(t, &env) { + Ok(p) => p, + Err(e) => { + problems.push(format!("{}: {}", input.name, e.message())); + continue; + } + } + } + None if i == 0 => Place { sheet: wb.active_sheet, range: d.selection.current() }, + None => { + problems.push(format!("{0} needs a range: pass inputs: {{\"{0}\": \"A1:B5\"}}.", input.name)); + continue; + } + }; + let place = trim(wb, place); + problems.extend(check_input(wb, input, place)); + inputs.push((input.name.clone(), place, input.header)); + } + if !problems.is_empty() { + return Err(EngineError::Other(format!("Macro '{}' can't run on these inputs. {}", m.name, problems.join(" ")))); + } + let output = match &m.output.anchor { + Some(a) if !a.trim().is_empty() => { + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; + Some( + resolve::resolve_ref(a, &env) + .map_err(|e| EngineError::Other(format!("Macro '{}': its output anchor {}", m.name, lower_first(e.message()))))?, + ) + } + _ => None, + }; + Ok(Prepared { mac: m.clone(), doc: d.uid, trust: d.trust, inputs, output }) +} + +fn lower_first(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_lowercase().chain(c).collect(), + None => String::new(), + } +} + +/// Whole rows or columns shrink to the part of the sheet in use. +fn trim(wb: &Workbook, p: Place) -> Place { + if !(p.range.is_full_cols() || p.range.is_full_rows()) { + return p; + } + let used = wb.sheet(p.sheet).and_then(Sheet::used_range); + let range = used.and_then(|u| p.range.intersection(&u)).unwrap_or(RangeRef::cell(p.range.start)); + Place { sheet: p.sheet, range } +} + +/// What is wrong with `place` as `input`, one sentence each. +fn check_input(wb: &Workbook, input: &gridcraft_model::NamedInput, place: Place) -> Vec { + let mut out = Vec::new(); + let r = place.range; + let name = &input.name; + let at = r.a1(); + if input.kind == InputKind::Cell && !r.is_single() { + out.push(format!("{name} must be a single cell; {at} is {} cells.", r.count())); + return out; + } + let header = u32::from(input.header && r.height() > 0); + let rows = r.height().saturating_sub(header); + let between = |what: &str, n: u32, [min, max]: [u32; 2]| -> Option { + let plural = |k: u32| if k == 1 { what.to_string() } else { format!("{what}s") }; + if max > 0 && min == max && n != min { + Some(format!("exactly {min} {}", plural(min))) + } else if n < min { + Some(format!("at least {min} {}", plural(min))) + } else if max > 0 && n > max { + Some(format!("at most {max} {}", plural(max))) + } else { + None + } + }; + if let Some(want) = between("data row", rows, input.rows) { + out.push(format!("{name} needs {want}; {at} has {rows}.")); + } + if let Some(want) = between("column", r.width(), input.cols) { + out.push(format!("{name} needs {want}; {at} has {}.", r.width())); + } + if r.count() > MAX_INPUT_CELLS { + out.push(format!("{name} is too large to check ({} cells; at most {}).", r.count(), MAX_INPUT_CELLS)); + return out; + } + let Some(sh) = wb.sheet(place.sheet) else { return out }; + let mut blanks = Vec::new(); + for (ci, col) in (r.start.col..=r.end.col).enumerate() { + let ty = input.col_types.get(ci).copied().unwrap_or_default(); + let mut wrong = Vec::new(); + for row in r.start.row + header..=r.end.row { + let c = CellRef::new(row, col); + let v = sh.value(c); + if v.is_empty() || v.as_text() == Some("") { + if input.non_empty { + blanks.push(c); + } + continue; + } + let date = || wb.styles.get(sh.style_id(c)).num_fmt.as_str().to_string(); + let ok = match ty { + gridcraft_model::ColType::Any => true, + gridcraft_model::ColType::Number => v.is_number(), + gridcraft_model::ColType::Text => v.is_text(), + gridcraft_model::ColType::Bool => matches!(v, Value::Bool(_)), + gridcraft_model::ColType::Date => v.is_number() && crate::display::number_format(&date()).is_date(), + }; + if !ok { + wrong.push((c, holds(&v, ty))); + } + } + if !wrong.is_empty() { + let head = if input.header { + Some(crate::display::cell_text(wb, sh, CellRef::new(r.start.row, col))).filter(|h| !h.trim().is_empty()) + } else { + None + }; + let column = match head { + Some(h) => format!("{name} column {} ({})", ci + 1, h.trim()), + None => format!("{name} column {}", ci + 1), + }; + let listed: Vec = wrong.iter().take(3).map(|(c, what)| format!("{} holds {what}", c.a1())).collect(); + let more = if wrong.len() > 3 { format!(" and {} more cells don't", wrong.len() - 3) } else { String::new() }; + out.push(format!("{column} must hold {}; {}{more}.", ty.noun(), listed.join(", "))); + } + } + if let Some(first) = blanks.first() { + let more = if blanks.len() > 1 { format!(" (and {} more)", blanks.len() - 1) } else { String::new() }; + out.push(format!("{name} must have no blank cells; {} is blank{more}.", first.a1())); + } + out +} + +/// What a cell holds, for "C7 holds text". +fn holds(v: &Value, wanted: gridcraft_model::ColType) -> &'static str { + match v { + Value::Number(_) if wanted == gridcraft_model::ColType::Date => "a number that isn't a date", + Value::Number(_) => "a number", + Value::Text(_) => "text", + Value::Bool(_) => "TRUE or FALSE", + Value::Error(_) => "an error", + Value::Array(_) => "an array", + Value::Empty => "nothing", + } +} + +// ---------------------------------------------------------------- the machine + +/// A stopwatch for the time limit. `Instant::now` traps on wasm, so there it measures nothing. +#[derive(Clone, Copy, Debug)] +pub struct Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + start: std::time::Instant, +} + +impl Stopwatch { + pub fn start() -> Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::disallowed_methods)] // never on wasm (see above) + let w = Stopwatch { start: std::time::Instant::now() }; + #[cfg(target_arch = "wasm32")] + let w = Stopwatch {}; + w + } + + /// Milliseconds since the start, or `None` where there is no clock. + fn elapsed_ms(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let ms = Some(self.start.elapsed().as_secs_f64() * 1000.0); + #[cfg(target_arch = "wasm32")] + let ms = None; + ms + } +} + +#[derive(Clone, Debug)] +struct Frame { + steps: Arc>, + next: usize, + /// Path of the step that opened this frame (`""` at the top, `3` for a Repeat's body, + /// `4.then` for an If's branch). + prefix: String, + repeat: Option, +} + +#[derive(Clone, Copy, Debug)] +struct Loop { + of: RepeatOf, + over: Place, + /// The current item (0-based) and how many there are. + at: u32, + count: u32, +} + +impl Loop { + fn item(&self) -> Place { + let r = self.over.range; + let range = match self.of { + RepeatOf::Rows => { + let row = r.start.row.saturating_add(self.at); + RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)) + } + RepeatOf::Columns => { + let col = r.start.col.saturating_add(self.at); + RangeRef::new(CellRef::new(r.start.row, col), CellRef::new(r.end.row, col)) + } + }; + Place { sheet: self.over.sheet, range } + } +} + +/// A macro run in progress: which step is next, and what has run. +#[derive(Clone, Debug)] +pub struct Machine { + pub name: String, + pub doc: u64, + pub inputs: Vec, + pub output: Option, + permissions: Vec, + trust: Trust, + frames: Vec, + /// Steps executed so far (each pass through a Repeat counts). + pub executed: u64, + clock: Stopwatch, + pub time_limit_ms: f64, + /// A Stop step ended the run: its message, and whether it asked for a rollback. + pub stopped: Option<(String, bool)>, + /// The last step's resolved parameters (for the debugger). + pub last_params: Option, + /// Files the run wrote ("step 1 (file.exportCsv)"): rolling the run back doesn't take them back. + pub wrote_files: Vec, + /// Repeat and If bodies by the path of their frame: a body is copied the first time its step + /// runs, not on every pass. + bodies: HashMap>>, + /// Bytes of parameters passed to commands so far, against [`MAX_PARAM_BYTES`]. + param_bytes: u64, + /// Set unless the macro may overwrite: each step is checked for writes over data as it runs. + guard: Option, +} + +/// Checks each step of a run for writes over data the workbook held when the run started. +#[derive(Clone, Debug)] +struct Guard { + /// The workbook when the run started, and for each sheet now, its index there (`None` for a + /// sheet the run added). + start: Arc, + origin: Vec>, + /// The conflict rule is ask. + ask: bool, +} + +impl Guard { + fn new(start: Arc, ask: bool) -> Guard { + Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, ask } + } + + /// What one step wrote over that held data when the run started: cells outside the inputs, + /// sheets and objects it deleted ("B1 on Sheet1; 1 object"), or `""`. + fn check(&mut self, prev: &Workbook, after: &Workbook, inputs: &[Bound]) -> String { + let (pairs, _) = pair_sheets(prev, after); + let mut origin = vec![None; after.sheets.len()]; + let mut parts = Vec::new(); + let mut objects = 0usize; + for (pi, ai) in pairs { + // A sheet the run added holds nothing of the person's. + let Some(si) = self.origin.get(pi).copied().flatten() else { continue }; + let Some(ss) = self.start.sheets.get(si) else { continue }; + let Some(ai) = ai else { + parts.push(format!("the sheet {}", ss.name)); + continue; + }; + if let Some(o) = origin.get_mut(ai) { + *o = Some(si); + } + let (Some(ps), Some(sa)) = (prev.sheets.get(pi), after.sheets.get(ai)) else { continue }; + if Arc::ptr_eq(ps, sa) { + continue; + } + // The inputs have followed their cells, so they are on the sheet's index now. + let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == ai && p.range.contains(c)); + let mut hit: Vec = ps + .cells + .diff(&sa.cells) + .into_iter() + .filter(|&c| !in_input(c) && !recalculated_only(ps.cell(c), sa.cell(c))) + .filter(|&c| ss.cell(c).is_some_and(has_content) && content_differs(ss.cell(c), sa.cell(c))) + .collect(); + if !hit.is_empty() { + let mut listed = ranges(&mut hit); + if listed.len() > 3 { + listed.truncate(3); + listed.push("…".into()); + } + parts.push(format!("{} on {}", listed.join(", "), sa.name)); + } + objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id) + + deleted(&ss.images, &ps.images, &sa.images, |o| o.id) + + deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id); + } + if objects > 0 { + parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" })); + } + self.origin = origin; + parts.join("; ") + } +} + +/// Objects of `prev` that `after` no longer has and that were there at the start. +fn deleted(start: &[T], prev: &[T], after: &[T], id: impl Fn(&T) -> u32) -> usize { + let kept: BTreeSet = after.iter().map(&id).collect(); + let had: BTreeSet = start.iter().map(&id).collect(); + prev.iter().map(&id).filter(|i| !kept.contains(i) && had.contains(i)).count() +} + +impl Machine { + pub fn new(p: &Prepared) -> Machine { + let mut m = Machine { + name: p.mac.name.clone(), + doc: p.doc, + inputs: p.inputs.clone(), + output: p.output, + permissions: p.mac.permissions.clone(), + trust: p.trust, + frames: vec![Frame { steps: Arc::new(p.mac.steps.clone()), next: 0, prefix: String::new(), repeat: None }], + executed: 0, + clock: Stopwatch::start(), + time_limit_ms: DEFAULT_TIME_LIMIT_MS, + stopped: None, + last_params: None, + wrote_files: Vec::new(), + bodies: HashMap::new(), + param_bytes: 0, + guard: None, + }; + m.settle(); + m + } + + /// Moves to the next enabled step: past disabled steps, finished frames and loop passes. + fn settle(&mut self) { + while let Some(f) = self.frames.last_mut() { + if let Some(st) = f.steps.get(f.next) { + if st.enabled { + return; + } + f.next += 1; + continue; + } + if let Some(l) = f.repeat.as_mut() + && l.at.saturating_add(1) < l.count + { + l.at += 1; + f.next = 0; + continue; + } + self.frames.pop(); + } + } + + pub fn is_done(&self) -> bool { + self.frames.is_empty() + } + + /// The step that runs next, with its path. + pub fn current(&self) -> Option<(String, &Step)> { + let f = self.frames.last()?; + f.steps.get(f.next).map(|st| (child_path(&f.prefix, f.next), st)) + } + + /// The innermost Repeat's pass (1-based) and count. + pub fn iteration(&self) -> Option<(u32, u32)> { + self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.at + 1, l.count)) + } + + fn item(&self) -> Option<(RepeatOf, Place)> { + self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.of, l.item())) + } + + /// Runs every remaining step. + pub fn run_to_end(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { + while !self.is_done() { + self.step(s)?; + } + Ok(()) + } + + /// Runs the current step and moves to the next one. + pub fn step(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { + // The frame's steps are shared, so the step (and its bodies) isn't copied to run it. + let Some((path, steps, i)) = self.frames.last().map(|f| (child_path(&f.prefix, f.next), Arc::clone(&f.steps), f.next)) else { return Ok(()) }; + let Some(st) = steps.get(i) else { return Ok(()) }; + if let Some(f) = self.frames.last_mut() { + f.next += 1; + } + let mut r = self.exec(s, &path, st); + if let Err(e) = r.as_mut() { + e.wrote_files.clone_from(&self.wrote_files); + self.frames.clear(); + } + self.settle(); + r + } + + fn exec(&mut self, s: &mut Session, path: &str, st: &Step) -> std::result::Result<(), StepError> { + self.executed += 1; + let at = |msg: String| StepError::new(path, None, msg); + if self.executed > MAX_EXECUTED_STEPS { + return Err(at(format!("The macro ran more than {MAX_EXECUTED_STEPS} steps and was stopped."))); + } + if self.clock.elapsed_ms().is_some_and(|ms| ms > self.time_limit_ms) { + return Err(at(format!("The macro ran longer than {} seconds and was stopped.", (self.time_limit_ms / 1000.0).round()))); + } + // Steps run on the macro's workbook, whichever one is in front. + let Some(i) = s.docs.iter().position(|d| d.uid == self.doc) else { return Err(at("The workbook was closed.".into())) }; + s.set_active(i); + match &st.kind { + StepKind::Command { id, params } => self.command(s, path, id, params), + StepKind::Repeat { over, of, max, body } => { + let place = { + let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; + let env = self.env(wb); + let p = resolve::resolve_ref(over, &env).map_err(|e| StepError::resolve(path, None, e))?; + trim(wb, p) + }; + let items = match of { + RepeatOf::Rows => place.range.height(), + RepeatOf::Columns => place.range.width(), + }; + let cap = (*max).min(MAX_EXECUTED_STEPS as u32); + if items > cap { + let what = if *of == RepeatOf::Rows { "rows" } else { "columns" }; + return Err(at(format!("Repeat over {} has {items} {what}, more than its limit of {cap}.", place.range.a1()))); + } + if items > 0 && body.iter().any(|b| b.enabled) { + let repeat = Some(Loop { of: *of, over: place, at: 0, count: items }); + let steps = self.body(path.to_string(), body); + self.frames.push(Frame { steps, next: 0, prefix: path.to_string(), repeat }); + } + Ok(()) + } + StepKind::If { cell, op, value, then, otherwise } => { + let v = { + let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; + let env = self.env(wb); + let p = resolve::resolve_ref(cell, &env).map_err(|e| StepError::resolve(path, None, e))?; + if !p.range.is_single() { + return Err(at(format!("If looks at one cell; {} is {} cells.", p.range.a1(), p.range.count()))); + } + wb.sheet(p.sheet).map(|sh| sh.value(p.range.start)).unwrap_or_default() + }; + let (branch, name) = if compare(&v, *op, value) { (then, "then") } else { (otherwise, "else") }; + if branch.iter().any(|b| b.enabled) { + let prefix = format!("{path}.{name}"); + let steps = self.body(prefix.clone(), branch); + self.frames.push(Frame { steps, next: 0, prefix, repeat: None }); + } + Ok(()) + } + StepKind::Stop { message, rollback } => { + self.stopped = Some((message.clone(), *rollback)); + self.frames.clear(); + Ok(()) + } + } + } + + /// After a step that inserted, deleted or moved sheets, rows, columns or cells, moves the + /// inputs, the output and the ranges of the Repeats in progress with their cells, as the + /// references in formulas move. A step that deletes one of them, or changes the size of a + /// range a Repeat is going over, stops the run. + fn follow(&mut self, before: &Workbook, after: &Workbook, edits: &[(String, Edit)]) -> std::result::Result<(), String> { + let same_sheets = before.sheets.len() == after.sheets.len() && before.sheets.iter().zip(&after.sheets).all(|(b, a)| b.name == a.name); + if same_sheets && edits.is_empty() { + return Ok(()); + } + let (pairs, _) = pair_sheets(before, after); + let follow = |what: &str, p: Place| follow_place(before, after, &pairs, edits, p).ok_or_else(|| format!("It deleted the cells of {what}.")); + for (name, place, _) in self.inputs.iter_mut() { + *place = follow(name, *place)?; + } + if let Some(o) = self.output { + self.output = Some(follow("the output anchor", o)?); + } + for f in self.frames.iter_mut() { + let Some(l) = f.repeat.as_mut() else { continue }; + let over = follow(&format!("the range Repeat {} goes over", f.prefix), l.over)?; + if (over.range.height(), over.range.width()) != (l.over.range.height(), l.over.range.width()) { + return Err(format!("It changed the size of the range Repeat {} goes over.", f.prefix)); + } + l.over = over; + } + Ok(()) + } + + /// The shared copy of the body that runs under `prefix`. + fn body(&mut self, prefix: String, body: &[Step]) -> Arc> { + Arc::clone(self.bodies.entry(prefix).or_insert_with(|| Arc::new(body.to_vec()))) + } + + fn env<'a>(&'a self, wb: &'a Workbook) -> Env<'a> { + Env { wb, active_sheet: wb.active_sheet, inputs: &self.inputs, output: self.output, item: self.item() } + } + + fn command(&mut self, s: &mut Session, path: &str, id: &str, params: &Json) -> std::result::Result<(), StepError> { + let fail = |msg: String| StepError::new(path, Some(id), msg); + // The policy sees the parameters the command will get, after tokens became A1 text. + let resolved = { + let wb = &s.doc().map_err(|e| fail(e.to_string()))?.wb; + resolve::resolve_params(params, &self.env(wb)).map_err(|e| StepError::resolve(path, Some(id), e))? + }; + match (macro_policy(id, params, self.trust), macro_policy(id, &resolved, self.trust)) { + (_, Policy::Deny(why)) | (Policy::Deny(why), _) => return Err(fail(format!("{id} can't run in a macro: {why}."))), + (written, Policy::NeedsPermission(what)) => { + if !self.permissions.iter().any(|p| p == id) { + return Err(fail(format!("{id} {what}. Add it to the macro's permissions to allow it."))); + } + // The gate asked the person about this step only if it saw the file access. + if !matches!(written, Policy::NeedsPermission(_)) { + return Err(fail(format!("{id} {what} only once its parameters are filled in, so it wasn't approved."))); + } + } + (_, Policy::Allow | Policy::Ask(_)) => {} + } + self.param_bytes = self.param_bytes.saturating_add(json_bytes(&resolved)); + if self.param_bytes > MAX_PARAM_BYTES { + return Err(fail(format!( + "The macro passed more than {} MB of parameters to its steps and was stopped.", + MAX_PARAM_BYTES / (1024 * 1024) + ))); + } + let file_step = matches!(macro_policy(id, &resolved, self.trust), Policy::NeedsPermission(_)); + let writes = writes_file(id, &resolved); + self.last_params = Some(resolved.clone()); + let prev = s.docs.iter_mut().find(|d| d.uid == self.doc).map(|d| { + d.edits = Some(Vec::new()); + d.wb.clone() + }); + let queued = s.ui_requests.len(); + // Only an approved file step reaches the file system: any other step runs as a script's + // does, so a command that reads or writes a file under another parameter name can't. + let sandbox = (!file_step).then(crate::io::ScriptSandbox::enter); + let r = s.execute(id, resolved); + drop(sandbox); + let edits = s.docs.iter_mut().find(|d| d.uid == self.doc).and_then(|d| d.edits.take()).unwrap_or_default(); + // Macros never open dialogs or web pages, and never start editing a cell; messages for + // the person stay queued. + let mut blocked = None; + let asked: Vec = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect(); + for q in asked { + match q { + UiRequest::Dialog(name, _) => blocked = Some(format!("{id} would open the {name} dialog; give the step the parameters it needs.")), + UiRequest::OpenUrl(_) => blocked = Some(format!("{id} would open a web page, which macros don't do.")), + UiRequest::EditCell(_) | UiRequest::InsertName(_) => {} + m @ UiRequest::Message(_) => s.ui_requests.push(m), + } + } + r.map_err(|e| fail(e.to_string()))?; + if writes { + self.wrote_files.push(format!("step {path} ({id})")); + } + if let Some(b) = blocked { + return Err(fail(b)); + } + let after = s.docs.iter().find(|d| d.uid == self.doc).map(|d| d.wb.clone()).ok_or_else(|| fail("The workbook was closed.".into()))?; + if let Some(prev) = &prev { + self.follow(prev, &after, &edits).map_err(fail)?; + } + if let (Some(g), Some(prev)) = (self.guard.as_mut(), prev) { + let what = g.check(&prev, &after, &self.inputs); + if !what.is_empty() { + let msg = format!("It would write over data ({what})."); + return Err(StepError { confirm: g.ask, ..fail(msg) }); + } + } + Ok(()) + } +} + +/// Where the cells of `p` are after a step: on the same sheet wherever it moved (`pairs`, from +/// [`pair_sheets`]), and moved by each structural edit the step made there, or `None` when the +/// step deleted them (or their sheet). +fn follow_place(before: &Workbook, after: &Workbook, pairs: &[(usize, Option)], edits: &[(String, Edit)], p: Place) -> Option { + let sheet = pairs.iter().find(|(b, _)| *b == p.sheet).and_then(|(_, a)| *a)?; + let mut name = after.sheet(sheet)?.name.clone(); + let old_name = before.sheet(p.sheet).map(|s| s.name.as_str()).unwrap_or_default(); + let mut text = format!("{}!{}", resolve::quote_sheet(&name), p.range.a1()); + for (target, e) in edits { + if target != &name && target != old_name { + continue; + } + let expr = gridcraft_formula::parse(&text).ok()?; + text = gridcraft_formula::print(&gridcraft_formula::adjust::adjust(expr, target, target, e)); + if text.contains("#REF!") { + return None; + } + // A cut and paste can take the cells to another sheet. + let env = Env { wb: after, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + name = after.sheet(resolve::resolve_ref(&text, &env).ok()?.sheet)?.name.clone(); + } + let env = Env { wb: after, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + if text.contains('$') { + text = text.replace('$', ""); + } + resolve::resolve_ref(&text, &env).ok() +} + +/// If's comparison of a cell's value with a step's `value`, the way Excel's `=`, `<`… compare. +fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { + let empty = v.is_empty() || v.as_text() == Some(""); + let want_text = || match want { + Json::String(t) => t.clone(), + Json::Null => String::new(), + other => other.to_string(), + }; + let ord = || -> Option { + let w = match want { + Json::Null => Value::Empty, + Json::Bool(b) => Value::Bool(*b), + Json::Number(n) => Value::number(n.as_f64()?), + // "5" compares as a number with a number in the cell. + Json::String(t) => match (v, t.trim().parse::()) { + (Value::Number(_), Ok(n)) => Value::number(n), + _ => Value::text(t.as_str()), + }, + _ => return None, + }; + match (v, &w) { + (Value::Error(e), Value::Text(t)) => { + Some(if e.as_str().eq_ignore_ascii_case(t.trim()) { std::cmp::Ordering::Equal } else { std::cmp::Ordering::Less }) + } + (Value::Error(_), _) | (_, Value::Error(_)) | (Value::Array(_), _) => None, + _ => Some(gridcraft_core::value::compare(v, &w)), + } + }; + use std::cmp::Ordering::*; + match op { + CompareOp::Empty => empty, + CompareOp::NotEmpty => !empty, + CompareOp::Contains => v.display().to_lowercase().contains(&want_text().to_lowercase()), + CompareOp::Eq => ord() == Some(Equal), + CompareOp::Ne => ord() != Some(Equal), + CompareOp::Gt => ord() == Some(Greater), + CompareOp::Ge => matches!(ord(), Some(Greater | Equal)), + CompareOp::Lt => ord() == Some(Less), + CompareOp::Le => matches!(ord(), Some(Less | Equal)), + } +} + +// ---------------------------------------------------------------- comparing workbooks + +/// Each sheet of `before` with its index in `after` (`None` when deleted), and the sheets of +/// `after` that are new. Sheets are matched by name; what's left is matched in order (a renamed +/// sheet). +fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, Option)>, Vec) { + let mut pairs: Vec<(usize, Option)> = before.sheets.iter().enumerate().map(|(bi, sh)| (bi, after.sheet_index(&sh.name))).collect(); + let matched: BTreeSet = pairs.iter().filter_map(|(_, a)| *a).collect(); + let mut spare = (0..after.sheets.len()).filter(|a| !matched.contains(a)); + for (_, a) in pairs.iter_mut().filter(|(_, a)| a.is_none()) { + *a = spare.next(); + } + (pairs, spare.collect()) +} + +/// Only a formula's cached value changed (a recalculation, not a write). +fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool { + match (b, a) { + (Some(b), Some(a)) => match (&b.formula, &a.formula) { + (Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style, + _ => false, + }, + _ => false, + } +} + +fn has_content(c: &Cell) -> bool { + c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some("")) +} + +fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { + match (b, a) { + (Some(b), Some(a)) => match (&b.formula, &a.formula) { + (Some(x), Some(y)) => x.text != y.text, + (None, None) => b.value != a.value, + _ => true, + }, + (Some(b), None) => has_content(b), + _ => false, + } +} + +/// Cells as A1 ranges: runs along each row, then runs with the same columns on following rows. +pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { + cells.sort_unstable_by_key(|c| (c.row, c.col)); + let mut runs: Vec = Vec::new(); + for c in cells.iter() { + match runs.last_mut() { + Some(r) if r.end.row == c.row && r.end.col.checked_add(1) == Some(c.col) => r.end.col = c.col, + _ => runs.push(RangeRef::cell(*c)), + } + } + let mut merged: Vec = Vec::new(); + for r in runs { + // The block above with the same columns that ends on the row before. + match merged + .iter_mut() + .rev() + .take(64) + .find(|m| m.start.col == r.start.col && m.end.col == r.end.col && m.end.row.checked_add(1) == Some(r.start.row)) + { + Some(m) => m.end.row = r.end.row, + None => merged.push(r), + } + } + merged.iter().take(MAX_LISTED_RANGES).map(RangeRef::a1).collect() +} + +// ---------------------------------------------------------------- run + +/// A macro that passed its checks and is about to run. +pub struct Begun { + pub mac: Macro, + pub prepared: Prepared, + pub rule: OnConflict, + pub time_limit_ms: f64, + pub inputs: Json, +} + +/// The checks before a run, on the active workbook: the macro exists, the policy allows it, the +/// person approved it if it needs that, and its inputs are right. +pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let m = super::find(&d.wb, name) + .and_then(|i| d.wb.macros.get(i)) + .cloned() + .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; + check_params(p, &m)?; + let asks = gate(&m, trust)?; + approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?; + let prepared = prepare(s, &m, p.get("inputs"))?; + let rule = conflict_rule(p, &m)?; + let time_limit_ms = time_limit(p); + let inputs = prepared.inputs_json(&s.doc()?.wb); + Ok(Begun { mac: m, prepared, rule, time_limit_ms, inputs }) +} + +/// The output rule, inside the run's undo group: the machine for the run. Unless the macro may +/// overwrite, the machine checks each step as it runs and stops the run at one that writes over +/// data the workbook held when the run started (asking under `ask`, failing otherwise); the run's +/// undo group then rolls back. Shifting needs to know where the macro writes before it runs, so +/// `shiftDown` and `shiftRight` stop the run as `fail` does. +pub fn make_way(s: &mut Session, b: &Begun) -> Result { + let mut machine = Machine::new(&b.prepared); + machine.time_limit_ms = b.time_limit_ms; + if b.rule != OnConflict::Overwrite { + let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone(); + machine.guard = Some(Guard::new(start, b.rule == OnConflict::Ask)); + } + Ok(machine) +} + +/// Runs macro `name` on the active workbook: `{inputs?, onConflict?, overwrite?, approve?, +/// timeLimit?}` (see `macro.run`). +pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { + let b = begin(s, name, p)?; + let label = format!("Run Macro: {}", b.mac.name); + s.grouped(&label, |s| { + let mut machine = make_way(s, &b)?; + let name = &b.mac.name; + machine.run_to_end(s).map_err(|e| e.into_engine(name))?; + match machine.stopped { + Some((msg, true)) => Err(EngineError::Other(format!("Macro '{name}' stopped: {msg} {}", rolled_back(&machine.wrote_files)))), + stopped => { + let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs}); + if let Some((msg, _)) = stopped { + out["stopped"] = json!(msg); + } + Ok(out) + } + } + }) +} + +/// Refuses run parameters that would otherwise be ignored: an input the macro doesn't have, an +/// input given as something other than reference text, and `onConflict`, `overwrite`, `approve` +/// or `timeLimit` of the wrong kind (`null` counts as not given). +pub fn check_params(p: &Json, m: &Macro) -> Result<()> { + let refuse = |msg: String| Err(EngineError::Other(format!("Macro '{}': {msg}", m.name))); + let names = || m.inputs.iter().map(|i| i.name.clone()).collect::>().join(", "); + match p.get("inputs") { + None | Some(Json::Null) => {} + Some(Json::String(_)) if !m.inputs.is_empty() => {} + Some(Json::String(_)) => return refuse("it takes no inputs.".into()), + Some(Json::Object(o)) => { + for (k, v) in o { + if !m.inputs.iter().any(|i| i.name.eq_ignore_ascii_case(k)) { + return refuse(if m.inputs.is_empty() { + format!("it has no input called '{k}'; it takes no inputs.") + } else { + format!("it has no input called '{k}' (its inputs: {}).", names()) + }); + } + if !v.is_string() { + return refuse(format!("give the input {k} as reference text, such as \"B2:D9\".")); + } + } + } + Some(_) => return refuse("give `inputs` as reference text, such as \"B2:D9\", or as {\"Input\": \"B2:D9\"}.".into()), + } + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + if given("onConflict").is_some_and(|v| !v.is_string()) { + return refuse("`onConflict` must be ask, fail, overwrite, shiftDown or shiftRight.".into()); + } + for k in ["overwrite", "approve"] { + if given(k).is_some_and(|v| !v.is_boolean()) { + return refuse(format!("`{k}` must be true or false.")); + } + } + if given("timeLimit").is_some_and(|v| !v.as_f64().is_some_and(|t| t > 0.0)) { + return refuse("`timeLimit` must be a number of seconds above 0.".into()); + } + Ok(()) +} + +/// The run's `timeLimit` (seconds) in milliseconds, or the default. +fn time_limit(p: &Json) -> f64 { + p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS) +} + +/// The conflict rule for this run: `onConflict`, else the macro's own. `overwrite: true` is the +/// answer to the question, so it turns `ask` into `overwrite` whichever way `ask` was given. +fn conflict_rule(p: &Json, m: &Macro) -> Result { + let rule = match p.get("onConflict").and_then(Json::as_str) { + Some(t) => OnConflict::parse(t) + .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'.")))?, + None => m.output.on_conflict, + }; + if rule == OnConflict::Ask && p.get("overwrite").and_then(Json::as_bool) == Some(true) { + return Ok(OnConflict::Overwrite); + } + Ok(rule) +} diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 5f55312075..ea1365e463 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -370,6 +370,11 @@ fn names() { assert_eq!(unique_name(&wb, &long).chars().count(), MAX_MACRO_NAME); assert_eq!(find(&wb, " MACRO1 "), Some(0)); assert_eq!(find(&wb, "Macro2"), None); + let a = sample_macro("A"); + let mut b = a.clone(); + assert_eq!(fingerprint(&a), fingerprint(&b)); + b.steps[0].note = "changed".into(); + assert_ne!(fingerprint(&a), fingerprint(&b)); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3c9e6f65e3..d69216ce4f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4776,4 +4776,5 @@ fn a_script_cannot_switch_windows() { assert_eq!(v(&s, "A1"), Value::number(2.0)); } +mod macros; mod seams; diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs new file mode 100644 index 0000000000..a4871f2f23 --- /dev/null +++ b/crates/engine/src/tests/macros.rs @@ -0,0 +1,549 @@ +//! Macros: running, input checks, checking each step for writes over data, the policy and the +//! limits. + +use std::sync::Arc; + +use gridcraft_core::CellRef; +use serde_json::{Value as Json, json}; + +use super::{s, v}; +use crate::{EngineError, Session}; + +fn undo_labels(s: &Session) -> Vec { + s.doc().unwrap().undo.iter().map(|e| e.label.clone()).collect() +} + +fn set(s: &mut Session, cell: &str, input: &str) { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); +} + +/// Puts a macro into the active workbook (replacing one with the same name). +pub(super) fn put(s: &mut Session, m: Json) { + let m = crate::macros::parse_macro(&m).unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.retain(|x| !x.name.eq_ignore_ascii_case(&m.name)); + wb.macros.push(m); + d.wb = Arc::new(wb); + d.saved = d.wb.clone(); +} + +/// Names in B2:B5 and amounts in C2:C5 under a header row. +fn data(s: &mut Session) { + for (cell, input) in [("B2", "Name"), ("C2", "Amount"), ("B3", "Ann"), ("C3", "120"), ("B4", "Bo"), ("C4", "80"), ("B5", "Cy"), ("C5", "200")] { + set(s, cell, input); + } +} + +/// A macro that writes a label and a total next to its input and bolds the header. +fn totals() -> Json { + json!({ + "name": "Totals", + "inputs": [{"name": "Input", "header": true, "cols": [2, 2], "colTypes": ["text", "number"]}], + "output": {"anchor": "Input.right(2).cell(1,2)"}, + "steps": [ + {"command": "cell.set", "params": {"cell": "Output", "input": "Total"}}, + {"command": "cell.set", "params": {"cell": "Output.offset(1,0)", "input": "=SUM({{Input.data.col(2)}})"}}, + {"command": "home.bold", "params": {"range": "Input.row(1)"}} + ] + }) +} + +fn bold(s: &Session, a: &str) -> bool { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(gridcraft_core::CellRef::parse(a).unwrap())).font.bold +} + +#[test] +fn a_run_resolves_tokens_and_is_one_undo_step() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + let before = undo_labels(&s); + let r = s.execute("macro.run", json!({"name": "totals", "inputs": {"Input": "B2:C5"}})).unwrap(); + assert_eq!(r["ran"], 3); + assert_eq!(r["inputs"], json!({"Input": "B2:C5"})); + assert_eq!(v(&s, "E2").display(), "Total"); + assert_eq!(v(&s, "E3").display(), "400"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().input_text(gridcraft_core::CellRef::parse("E3").unwrap()), "=SUM(C3:C5)"); + assert!(bold(&s, "B2") && bold(&s, "C2")); + let after = undo_labels(&s); + assert_eq!(after.len(), before.len() + 1); + assert_eq!(after.last().unwrap(), "Run Macro: Totals"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E2").is_empty() && v(&s, "E3").is_empty() && !bold(&s, "B2")); + assert_eq!(undo_labels(&s), before); + // The selection is the default input. + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.run", json!({"name": "Totals"})).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); +} + +#[test] +fn inputs_are_checked_with_messages_that_name_the_cells() { + let mut s = s(); + data(&mut s); + set(&mut s, "C4", "eighty"); + set(&mut s, "C5", "TRUE"); + put(&mut s, totals()); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.contains("Input column 2 (Amount) must hold numbers; C4 holds text, C5 holds TRUE or FALSE."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing ran"); + + // Shape: width, rows, a single cell, blanks, dates. + let mut m = totals(); + m["inputs"][0] = json!({"name": "Input", "header": true, "rows": [1, 2], "cols": [3, 3], "nonEmpty": true, "colTypes": ["any", "date"]}); + put(&mut s, m); + set(&mut s, "B4", ""); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + for needle in [ + "Input needs at most 2 data rows; B2:C5 has 3.", + "Input needs exactly 3 columns; B2:C5 has 2.", + "Input column 2 (Amount) must hold dates; C3 holds a number that isn't a date, C4 holds text, C5 holds TRUE or FALSE.", + "Input must have no blank cells; B4 is blank.", + ] { + assert!(err.contains(needle), "missing `{needle}` in {err}"); + } + let mut m = totals(); + m["inputs"][0] = json!({"name": "Input", "kind": "cell"}); + put(&mut s, m); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.contains("Input must be a single cell; B2:C5 is 8 cells."), "{err}"); + // A second input with no range, and a bad reference. + put(&mut s, json!({"name": "Two", "inputs": [{"name": "Input"}, {"name": "Rates"}], "steps": []})); + let err = s.execute("macro.run", json!({"name": "Two"})).unwrap_err().to_string(); + assert!(err.contains("Rates needs a range"), "{err}"); + let err = s.execute("macro.run", json!({"name": "Two", "inputs": {"Rates": "Nowhere!A1"}})).unwrap_err().to_string(); + assert!(err.contains("Rates: There is no sheet named 'Nowhere'."), "{err}"); + // Whole columns shrink to the cells in use. + put( + &mut s, + json!({"name": "Col", "inputs": [{"name": "Input", "rows": [1, 10]}], "steps": [{"command": "cell.set", "params": {"cell": "Input.below", "input": "end"}}], "output": {"onConflict": "overwrite"}}), + ); + s.execute("macro.run", json!({"name": "Col", "inputs": "B:B"})).unwrap(); + assert_eq!(v(&s, "B6").display(), "end"); + assert!(s.execute("macro.run", json!({"name": "nope"})).unwrap_err().to_string().contains("no macro called 'nope'")); +} + +#[test] +fn an_error_rolls_everything_back_and_names_the_step() { + let mut s = s(); + data(&mut s); + let mut m = totals(); + m["output"]["onConflict"] = json!("overwrite"); + m["steps"][2] = json!({"type": "repeat", "over": "Input.data", "body": [ + {"command": "home.italic", "params": {"range": "Row"}}, + {"command": "cell.set", "params": {"cell": "Row.cell(1,9)", "input": "=1/"}} + ]}); + put(&mut s, m); + let (before, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s)); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.starts_with("Macro 'Totals' stopped. Step 3.2 (cell.set):"), "{err}"); + assert!(err.ends_with("Nothing was changed."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(undo_labels(&s), undo); + assert!(s.doc().unwrap().redo.is_empty()); +} + +#[test] +fn conflicts_ask_fail_or_overwrite() { + let mut s = s(); + data(&mut s); + set(&mut s, "E3", "keep me"); + // A formula that reads what the macro writes: its value changes, but it isn't written. + set(&mut s, "H1", "=E3"); + put(&mut s, totals()); + let start = s.doc().unwrap().wb.clone(); + let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert!(q.contains("Step 2 (cell.set): It would write over data (E3 on Sheet1)"), "{q}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "programmatic runs default to failing"); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).unwrap_err().to_string(); + assert!(err.contains("Nothing was changed"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + // Shifting needs to know where the macro writes before it runs: it stops as fail does. + for rule in ["shiftDown", "shiftRight"] { + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": rule})).unwrap_err().to_string(); + assert!(err.contains("would write over data (E3 on Sheet1)"), "{rule}: {err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{rule}"); + } + assert!(s.execute("macro.run", json!({"name": "Totals", "onConflict": "sideways"})).is_err()); + + // The answer to the question overwrites. + s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "overwrite": true})).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); + assert_eq!(v(&s, "H1").display(), "400"); +} + +/// The value of `cell` on the sheet called `sheet`. +fn on(s: &Session, sheet: &str, cell: &str) -> String { + let wb = &s.doc().unwrap().wb; + wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap()).display() +} + +#[test] +fn inputs_and_the_output_follow_their_cells_when_a_step_moves_them() { + // A sheet inserted in front: the input is still on Sheet1, not on whatever sheet is first now. + let mut s = s(); + let steps = json!([ + {"command": "home.insertSheet", "params": {"name": "Summary", "before": 0}}, + {"command": "cell.set", "params": {"cell": "Input", "input": "x"}} + ]); + put( + &mut s, + json!({"name": "Front", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "overwrite"}, "steps": steps.clone()}), + ); + s.execute("macro.run", json!({"name": "Front", "inputs": "Sheet1!B2"})).unwrap(); + assert_eq!((on(&s, "Sheet1", "B2"), on(&s, "Summary", "B2")), ("x".into(), "".into())); + + // Data in the input: the step writes into its own input, which isn't data written over, on + // the input's sheet wherever it moved. + let mut s = super::s(); + set(&mut s, "B2", "held"); + put(&mut s, json!({"name": "Front", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "fail"}, "steps": steps})); + s.execute("macro.run", json!({"name": "Front", "inputs": "Sheet1!B2"})).unwrap(); + assert_eq!(on(&s, "Sheet1", "B2"), "x"); + + // Rows inserted above: the input and the output move down with their cells. + let mut s = super::s(); + set(&mut s, "B5", "in"); + let steps = json!([ + {"command": "home.insertRows", "params": {"rows": "2:3"}}, + {"command": "cell.set", "params": {"cell": "Input", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "Output", "input": "y"}} + ]); + put( + &mut s, + json!({"name": "Down", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"anchor": "D5", "onConflict": "overwrite"}, "steps": steps}), + ); + s.execute("macro.run", json!({"name": "Down", "inputs": "B5"})).unwrap(); + assert_eq!((v(&s, "B7").display(), v(&s, "D7").display()), ("x".into(), "y".into())); + assert_eq!((v(&s, "B5").display(), v(&s, "D5").display()), ("".into(), "".into())); + + // A step that deletes an input's cells stops the run. + let mut s = super::s(); + set(&mut s, "B5", "in"); + let steps = json!([{"command": "home.deleteRows", "params": {"rows": "5"}}, {"command": "cell.set", "params": {"cell": "Input", "input": "x"}}]); + put(&mut s, json!({"name": "Gone", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "overwrite"}, "steps": steps})); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Gone", "inputs": "B5"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (home.deleteRows): It deleted the cells of Input."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + + // A step inside a Repeat that changes the size of the range it goes over stops the run. + let mut s = super::s(); + let body = json!([{"command": "home.insertRows", "params": {"rows": "2"}}]); + put(&mut s, json!({"name": "Grow", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A3", "body": body}]})); + let err = s.execute("macro.run", json!({"name": "Grow"})).unwrap_err().to_string(); + assert!(err.contains("Step 1.1 (home.insertRows): It changed the size of the range Repeat 1 goes over."), "{err}"); +} + +#[test] +fn each_step_is_checked_for_writes_over_data() { + let dir = std::env::temp_dir().join(format!("gc-macro-guard-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("out.csv").to_string_lossy().to_string(); + let export = json!({"command": "file.exportCsv", "params": {"path": csv}}); + let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}}); + let late = json!({"command": "cell.set", "params": {"cell": "B1", "input": "over"}}); + // A file step or Solver, then a step that writes over B1: the run stops there. + for first in [&export, &solver] { + let mut s = super::s(); + set(&mut s, "A1", "1"); + set(&mut s, "C1", "=A1"); + set(&mut s, "B1", "keep"); + put(&mut s, json!({"name": "Late", "inputs": [{"name": "Input"}], "permissions": ["file.exportCsv"], "steps": [first, late]})); + let start = s.doc().unwrap().wb.clone(); + for rule in ["fail", "ask"] { + let r = s.execute("macro.run", json!({"name": "Late", "inputs": "A1", "onConflict": rule, "approve": true})); + let e = match (rule, r) { + ("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e, + (_, r) => panic!("{first} {rule}: {r:?}"), + }; + assert!(e.contains("Step 2 (cell.set): It would write over data (B1 on Sheet1)"), "{e}"); + // The workbook is put back; a file the run wrote stays, and the message says so. + let left = + if first == &export { "The workbook was put back; the file step 1 (file.exportCsv) wrote stays." } else { "Nothing was changed." }; + assert!(e.ends_with(left), "{e}"); + assert_eq!(v(&s, "B1").display(), "keep", "{first} {rule}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{first} {rule}: rolled back"); + } + // The answer to the question overwrites. + s.execute("macro.run", json!({"name": "Late", "inputs": "A1", "overwrite": true})).unwrap(); + assert_eq!(v(&s, "B1").display(), "over"); + } + // A step that writes over what the macro itself wrote isn't a conflict. + let mut s = super::s(); + let again = |input: &str| json!({"command": "cell.set", "params": {"cell": "E1", "input": input}}); + put( + &mut s, + json!({"name": "Twice", "permissions": ["file.exportCsv"], "output": {"onConflict": "fail"}, "steps": [again("a"), export, again("b")]}), + ); + s.execute("macro.run", json!({"name": "Twice", "approve": true})).unwrap(); + assert_eq!(v(&s, "E1").display(), "b"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn run_parameters_that_would_be_ignored_are_refused() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + for (p, says) in [ + (json!({"inputs": {"Inptu": "B2:C5"}}), "it has no input called 'Inptu' (its inputs: Input)."), + (json!({"inputs": {"Input": 5}}), "give the input Input as reference text"), + (json!({"inputs": ["B2:C5"]}), "give `inputs` as reference text"), + (json!({"inputs": "B2:C5", "onConflict": 1}), "`onConflict` must be"), + (json!({"inputs": "B2:C5", "overwrite": "yes"}), "`overwrite` must be true or false."), + (json!({"inputs": "B2:C5", "approve": 1}), "`approve` must be true or false."), + (json!({"inputs": "B2:C5", "timeLimit": "30"}), "`timeLimit` must be a number of seconds above 0."), + (json!({"inputs": "B2:C5", "timeLimit": 0}), "`timeLimit` must be a number of seconds above 0."), + ] { + let mut p = p; + p["name"] = json!("Totals"); + let err = s.execute("macro.run", p.clone()).unwrap_err().to_string(); + assert!(err.contains(&format!("Macro 'Totals': {says}")), "{p}: {err}"); + } + put(&mut s, json!({"name": "None", "steps": []})); + let err = s.execute("macro.run", json!({"name": "None", "inputs": "A1"})).unwrap_err().to_string(); + assert!(err.contains("Macro 'None': it takes no inputs."), "{err}"); + // `null` is the same as not given, and an input's name matches in any case. + let p = json!({"name": "Totals", "inputs": {"input": "B2:C5"}, "onConflict": null, "overwrite": true, "timeLimit": null}); + s.execute("macro.run", p).unwrap(); +} + +#[test] +fn a_run_passes_a_bounded_amount_of_parameters() { + // A step with a 3 MB parameter in a Repeat of 100 rows would hand its command 300 MB; the run + // stops at 256 MB, which matters on the web build, where there is no time limit. + let mut s = s(); + let big = "x".repeat(3_000_000); + let body = json!([{"command": "home.bold", "params": {"range": "A1", "note": big}}]); + put(&mut s, json!({"name": "Big", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A100", "body": body}]})); + let err = s.execute("macro.run", json!({"name": "Big"})).unwrap_err().to_string(); + assert!(err.contains("passed more than 256 MB of parameters to its steps"), "{err}"); +} + +#[test] +fn the_answer_to_the_question_counts_however_ask_was_given() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + set(&mut s, "E3", "taken"); + let p = json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "ask"}); + assert!(matches!(s.execute("macro.run", p.clone()), Err(EngineError::Confirm(_)))); + let mut answered = p; + answered["overwrite"] = json!(true); + s.execute("macro.run", answered).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); +} + +#[test] +fn repeat_if_and_stop() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Flag", "inputs": [{"name": "Input", "header": true}], "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "repeat", "over": "Input.data", "body": [ + {"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, + "then": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "big"}}], + "else": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "small"}}]}, + {"type": "if", "cell": "Row.cell(1,1)", "op": "eq", "value": "bo", "then": [{"type": "stop", "message": "Found Bo."}]} + ]}, + {"command": "cell.set", "params": {"cell": "A1", "input": "not reached"}} + ]}), + ); + let r = s.execute("macro.run", json!({"name": "Flag", "inputs": "B2:C5"})).unwrap(); + assert_eq!(r["stopped"], "Found Bo."); + assert_eq!((v(&s, "D3").display(), v(&s, "D4").display()), ("big".into(), "small".into())); + assert!(v(&s, "D5").is_empty() && v(&s, "A1").is_empty()); + assert_eq!(undo_labels(&s).last().unwrap(), "Run Macro: Flag", "a stop keeps the changes"); + // A Stop that rolls back is an error that changes nothing. + put( + &mut s, + json!({"name": "Guard", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"type": "if", "cell": "C3", "op": "notEmpty", "then": [{"type": "stop", "message": "C3 must be empty.", "rollback": true}]} + ]}), + ); + let err = s.execute("macro.run", json!({"name": "Guard"})).unwrap_err().to_string(); + assert_eq!(err, "Macro 'Guard' stopped: C3 must be empty. Nothing was changed."); + assert!(v(&s, "A1").is_empty()); + // Comparisons. + for (op, value, want) in [ + ("eq", json!(120), true), + ("eq", json!("120"), true), + ("ne", json!(120), false), + ("ge", json!(120), true), + ("lt", json!(120.5), true), + ("le", json!(100), false), + ("contains", json!("12"), true), + ("empty", json!(null), false), + ] { + put( + &mut s, + json!({"name": "Cmp", "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "if", "cell": "C3", "op": op, "value": value, "then": [{"command": "cell.set", "params": {"cell": "A9", "input": "yes"}}], "else": [{"command": "cell.set", "params": {"cell": "A9", "input": "no"}}]} + ]}), + ); + s.execute("macro.run", json!({"name": "Cmp"})).unwrap(); + assert_eq!(v(&s, "A9").display(), if want { "yes" } else { "no" }, "{op} {value}"); + } + // If needs one cell. + put(&mut s, json!({"name": "Many", "steps": [{"type": "if", "cell": "A1:A2", "op": "empty"}]})); + assert!(s.execute("macro.run", json!({"name": "Many"})).unwrap_err().to_string().contains("If looks at one cell")); + // Disabled steps are skipped. + put( + &mut s, + json!({"name": "Off", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A8", "input": "x"}, "enabled": false}]}), + ); + assert_eq!(s.execute("macro.run", json!({"name": "Off"})).unwrap()["ran"], 0); +} + +#[test] +fn macros_are_bounded() { + let mut s = s(); + put(&mut s, json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A20", "max": 10, "body": [{"type": "stop"}]}]})); + let err = s.execute("macro.run", json!({"name": "Long"})).unwrap_err().to_string(); + assert!(err.contains("Repeat over A1:A20 has 20 rows, more than its limit of 10."), "{err}"); + // 400 × 400 passes is over the step limit. + put( + &mut s, + json!({"name": "Spin", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [ + {"type": "repeat", "over": "A1:A400", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); + assert!(err.contains("ran more than 100000 steps"), "{err}"); + let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string(); + assert!(err.contains("ran longer than"), "{err}"); + // A run that checks each step keeps to the time limit too. + put( + &mut s, + json!({"name": "Spin", "output": {"onConflict": "fail"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [ + {"type": "repeat", "over": "A1:A400", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string(); + assert!(err.contains("ran longer than"), "{err}"); +} + +#[test] +fn a_token_in_a_path_needs_the_permission_and_approval() { + let mut s = s(); + let dir = std::env::temp_dir().join(format!("gc-macro-token-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().count(); + let in_dir = format!("{}/{{{{Input}}}}", dir.to_string_lossy()); + for path in [json!({"$ref": "Input"}), json!(in_dir), json!(" ")] { + let step = json!({"command": "file.exportCsv", "params": {"path": path}}); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step]})); + // Without the permission nothing runs. + let err = s.execute("macro.run", json!({"name": "Export"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (file.exportCsv) writes a file. Add file.exportCsv to the macro's permissions"), "{path}: {err}"); + // With it, the person approves first. + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step], "permissions": ["file.exportCsv"]})); + let r = s.execute("macro.run", json!({"name": "Export"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{path}: {r:?}") }; + assert!(q.contains("file.exportCsv, which writes a file"), "{q}"); + assert_eq!(files(&dir), 0, "{path}"); + assert!(!std::path::Path::new("A1.csv").exists() && !std::path::Path::new(" .csv").exists()); + } + // Once approved, the run writes the file. + let step = json!({"command": "file.exportCsv", "params": {"path": in_dir}}); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step], "permissions": ["file.exportCsv"]})); + s.execute("macro.run", json!({"name": "Export", "approve": true, "inputs": "B2"})).unwrap(); + assert_eq!(files(&dir), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_policy_keeps_macros_away_from_files_and_the_app() { + let mut s = s(); + for (id, params) in [ + ("file.saveAs", json!({"path": "/tmp/x.xlsx"})), + ("file.close", json!({})), + ("app.language.set", json!({"language": "de"})), + ("automate.runScript", json!({"name": "Script 1"})), + ("edit.undo", json!({})), + ("macro.run", json!({"name": "Self"})), + ("view.newWindow", json!({})), + ("view.hideWindow", json!({})), + ] { + put( + &mut s, + json!({"name": "Bad", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, {"command": id, "params": params}]}), + ); + let err = s.execute("macro.run", json!({"name": "Bad"})).unwrap_err().to_string(); + assert!(err.contains(&format!("Step 2 ({id}) can't run in a macro")), "{err}"); + assert!(v(&s, "A1").is_empty(), "{id}: nothing ran"); + } + // A disabled step isn't checked. + put(&mut s, json!({"name": "Bad", "steps": [{"command": "file.close", "enabled": false}]})); + s.execute("macro.run", json!({"name": "Bad"})).unwrap(); + + // Reading a file needs the permission, then the person's approval (once per macro content). + let dir = std::env::temp_dir().join(format!("gc-macro-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("in.csv"); + std::fs::write(&csv, "a,b\n1,2\n").unwrap(); + let step = json!({"command": "data.fromTextCsv", "params": {"path": csv.to_string_lossy(), "sheet": "Imported"}}); + put(&mut s, json!({"name": "Import", "steps": [step]})); + let err = s.execute("macro.run", json!({"name": "Import"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (data.fromTextCsv) reads a file. Add data.fromTextCsv to the macro's permissions"), "{err}"); + put(&mut s, json!({"name": "Import", "steps": [step], "permissions": ["data.fromTextCsv"]})); + let r = s.execute("macro.run", json!({"name": "Import"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") }; + assert_eq!(q, "Run macro 'Import'? It runs data.fromTextCsv, which reads a file."); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); + s.execute("macro.run", json!({"name": "Import", "approve": true})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("macro.run", json!({"name": "Import"})).unwrap(); + // Changing the macro asks again. + put(&mut s, json!({"name": "Import", "description": "changed", "steps": [step], "permissions": ["data.fromTextCsv"]})); + assert!(matches!(s.execute("macro.run", json!({"name": "Import"})), Err(EngineError::Approve(_)))); + let _ = std::fs::remove_dir_all(&dir); + + // Steps that would open a dialog fail instead. + put(&mut s, json!({"name": "Pic", "output": {"onConflict": "overwrite"}, "steps": [{"command": "insert.picture"}]})); + let err = s.execute("macro.run", json!({"name": "Pic"})).unwrap_err().to_string(); + assert!(err.contains("would open the insertPicture dialog"), "{err}"); + assert!(s.ui_requests.is_empty()); + // A step that would start editing a cell, or put a name in the edit, runs without it. + let (use_name, insert) = (json!({"name": "Rate"}), json!({"name": "SUM"})); + let steps = json!([{"command": "formulas.useInFormula", "params": use_name}, {"command": "formulas.insertFunction", "params": insert}]); + put(&mut s, json!({"name": "Edit", "output": {"onConflict": "overwrite"}, "steps": steps})); + s.execute("macro.run", json!({"name": "Edit"})).unwrap(); + assert!(s.ui_requests.is_empty()); +} + +#[test] +fn macros_from_a_file_ask_before_running() { + let mut s = s(); + let mut wb = gridcraft_model::Workbook::new(); + wb.macros.push(crate::macros::parse_macro(&json!({"name": "Theirs", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "ran"}}]})).unwrap()); + let bytes = serde_json::to_vec(&wb).unwrap(); + s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert_eq!(s.doc().unwrap().trust, crate::Trust::FromFile); + let r = s.execute("macro.run", json!({"name": "Theirs"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") }; + assert_eq!(q, "Run macro 'Theirs'? It came with a workbook you opened."); + assert!(v(&s, "A1").is_empty()); + s.execute("macro.run", json!({"name": "Theirs", "approve": true})).unwrap(); + assert_eq!(v(&s, "A1").display(), "ran"); + // Approved once per macro content: the second run doesn't ask. + s.execute("macro.run", json!({"name": "Theirs"})).unwrap(); + // The approval holds the macro's whole content, not a hash two macros could share. + let m = s.doc().unwrap().wb.macros[0].clone(); + let doc = s.doc().unwrap().uid; + let key: (u64, String) = (doc, crate::macros::macro_json(&m).to_string()); + assert!(s.macro_approvals.contains(&key)); + // The JSON is canonical: the same macro with its keys in another order is the same macro. + let a = crate::macros::parse_macro(&json!({"name": "M", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap(); + let b = crate::macros::parse_macro(&json!({"steps": [{"params": {"input": "x", "cell": "A1"}, "command": "cell.set"}], "name": "M"})).unwrap(); + assert_eq!(crate::macros::fingerprint(&a), crate::macros::fingerprint(&b)); +} diff --git a/docs/macros.md b/docs/macros.md index c0db953f90..d4f1cc43e3 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,13 +1,18 @@ # Macros -> **Last updated:** 2026-10-24 · **Change:** minor (macro tokens) +> **Last updated:** 2026-10-25 · **Change:** minor (running macros: `macro.run`) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro -declares the inputs it works on. +declares the inputs it works on, runs as one undo step, rolls all workbook changes back on an +error (a file it already wrote stays), stops before it writes over data it may not, and does not +touch files without permission. +Agents run macros with `macro.run` like any other command. -This page describes the macro format, the tokens its steps use and how macros are saved in a -workbook. The engine has no command that runs macros in this version. +This page describes the macro format, its tokens, and running macros. `macro.run` checks the +macro, its inputs, its parameters and the policy before it runs, and each step as it runs; this +version has no command that checks a macro without running it, no macro debugger, recorder or +management commands, and no macro interface. ## A macro @@ -43,8 +48,9 @@ workbook. The engine has no command that runs macros in this version. reference (`Q1`), so a step written with plain A1 text keeps meaning the cell. The declaration is checked as a whole: neither `rows` nor `cols` may have a least above its most, a `cell` input has one row and one column (`[1, 1]`, or the default `[1, 0]`), no header and at most one column - type, and no input lists more column types than it allows columns. This version stores and - checks the declaration; it does not bind inputs or run macros. + type, and no input lists more column types than it allows columns. A run binds each input + to the range it is given, its default, or (for the first input) the selection, and refuses + with messages that name the cells: "Input column 2 (Amount) must hold numbers; C4 holds text." - **Limits**: 1,000 macros a workbook, 10,000 steps a macro, Repeat and If nested 4 deep, and 64 inputs a macro. Names (of macros and inputs) and command ids are at most 255 characters; descriptions, step notes, Stop messages and If values (as JSON) 4,096; references and tokens @@ -52,9 +58,12 @@ workbook. The engine has no command that runs macros in this version. macro lists at most 256 permissions, each a command id, and an input at most 16,384 column types. One-line fields hold no line breaks or other control characters, and a command id is letters, digits, `.` and `_`. A command step's `params` are bounded only by the size of the - whole macro (4,194,304 characters of JSON). How many times a Repeat runs (`max`) is the - runner's to bound. A macro read from a file that breaks any of these is left out with a - warning; it is never changed to fit. + whole macro (4,194,304 characters of JSON). A macro read from a file that breaks any of these is + left out with a warning; it is never changed to fit. When a macro runs, a Repeat whose range has + more rows or columns than its `max` is refused, and a run executes at most 100,000 steps (each + pass through a Repeat counts), passes its commands at most 256 MB of parameters, all steps + together, and takes at most 60 seconds (`timeLimit` to change the time; the web build has no + clock, so there only the step and parameter limits apply). ## Tokens @@ -86,6 +95,43 @@ so `Output!A1` is a cell on a sheet called Output, even when a token or input ha `Input.data` on a range that is only a header row, and `Name:` of a table with no data rows, are refused rather than read as the header. +## Running + +`macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`: + +1. **Policy.** File commands other than Properties, exports and printing (such as Open, Save + and Close), app, window (including New Window, Hide and Unhide), automate and macro commands, + and Undo and Redo, never run in a macro. File › Properties runs, and so do exports and + printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`) + that only produce bytes. Reading or writing a file (`data.getData`, `data.fromTextCsv`, + `insert.picture` or `pageLayout.background` with a `path`, or an export or print with a + `path`) needs the command in the macro's `permissions`. Any `path` other than `null` counts, + including a blank one and a token, and the policy is checked again on the parameters as they + are when the step runs. A macro that touches files, or that came with a workbook opened from + a file, runs once the person approves it: the call fails with `approve: …` and runs with + `approve: true`. Approval lasts for the session, until the macro changes. Every step other + than an approved file step runs inside the file sandbox that scripts use, so no other + parameter reaches a file either. +2. **Conflicts.** Unless the conflict rule is `overwrite`, each step is checked as it runs. A + step that writes over cells outside the inputs that held data when the run started, or deletes + sheets or objects that were there, stops the run, which rolls the workbook back (a file already + written stays). Moving such cells, as inserting or deleting rows or cells above them does, + counts as writing over them, and so does a formula whose references such a move rewrites: the + check compares cells by place, so it may ask about data that only moved, never the other way. The rule is `onConflict` (or + the macro's `output.onConflict`): `ask` (the call fails with `confirm: …`; `overwrite: true` + answers it), `fail` or `overwrite`. Shifting the data to make room (`shiftDown`, + `shiftRight`) needs to know where the macro writes before it runs, which this version doesn't + work out, so those rules stop the run as `fail` does. +3. **Run.** One undo step, "Run Macro: Totals". Any error rolls the whole run back and names the + step: `Step 3.1 (cell.set): …` (paths are 1-based; `2.1` is inside a Repeat, `4.then.1` inside + an If). The message ends "Nothing was changed.", or names the files the run already wrote, + which stay. An input the macro doesn't have, an input that isn't reference text, and + `onConflict`, `overwrite`, `approve` or `timeLimit` of the wrong kind are refused rather than + ignored. When a step inserts, deletes or moves sheets, rows, columns or cells, the inputs, the + output and the range of a Repeat in progress move with their cells, as references in formulas + do; a step that deletes one of them, or changes the size of a range a Repeat is going over, + stops the run. + ## Storage Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden