From acfd38f3092f4c91a2ff3cbe8d4aa0502b04d960 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:03:39 -0400 Subject: [PATCH 001/166] Engine: undo groups make several commands one undo step Session::grouped(label, f) runs f as one undo step on the active document. On success the group leaves exactly one undo entry holding the workbook from before the group; on error it restores the workbook, selection, undo and redo stacks as they were and returns the error. group_begin, group_end and group_rollback do the same across separate calls, for an action that runs one step per call. While a group is open on a document, undoable commands on it add no undo steps of their own, so a long group neither grows the history nor pushes older entries past the undo limit, and Undo and Redo are disabled there. Groups nest: the outermost one makes the step, and a failing inner group rolls back only its own changes. Closing a document drops its groups, and a command that panics drops the groups it opened. history.list reports whether a group is open. Insert Copied Cells folds its nested steps into one undo step by counting revisions; inside a group the nested steps push no entries, so it now leaves the history alone there instead of removing entries from before the group. --- crates/engine/src/cmd/inspect.rs | 10 +- crates/engine/src/cmd/mod.rs | 7 ++ crates/engine/src/cmd/sheet.rs | 8 +- crates/engine/src/lib.rs | 149 ++++++++++++++++++++++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/seams.rs | 203 +++++++++++++++++++++++++++++++ 6 files changed, 366 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/tests/seams.rs diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f92b48bd9..366d8e6e6 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read), cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get), cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats), - cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels", has_doc, history), + cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history), cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands), ] } @@ -148,9 +148,11 @@ fn sel_stats(s: &mut Session, _: &Json) -> Result { fn history(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; - Ok( - json!({"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::>(), "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::>()}), - ) + Ok(json!({ + "undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::>(), + "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::>(), + "grouping": s.in_group(d.uid), + })) } fn list_commands(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..d8abfac61 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -96,12 +96,19 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> { } pub fn can_undo(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; + not_grouping(s)?; if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } } pub fn can_redo(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; + not_grouping(s)?; if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) } } +/// Undo and Redo wait while an undo group is open on the active document (an action that spans +/// several calls is still running). +fn not_grouping(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first".into()) } else { Ok(()) } +} pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 7870dee4e..c42b3abd4 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -554,11 +554,15 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result { } let revision = d.revision; let before = (d.wb.clone(), d.selection.clone()); + // Inside an undo group the nested steps add no undo entries of their own. + let nested_entries = !s.in_group(d.uid); let result = insert_copied_steps(s, &clip, sheet, at, shift, cut); let d = s.doc_mut()?; // The nested steps collapse into this command's single undo step. - for _ in 0..d.revision.saturating_sub(revision) { - d.undo.pop(); + if nested_entries { + for _ in 0..d.revision.saturating_sub(revision) { + d.undo.pop(); + } } match result { Ok((final_range, out)) => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb72..e8e3d73d6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -63,6 +63,36 @@ pub struct HistoryEntry { pub selection: Selection, } +/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`] +/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all). +#[derive(Debug, PartialEq, Eq)] +#[must_use = "an undo group stays open until it is ended or rolled back"] +pub struct GroupToken { + id: u64, + doc: u64, +} + +impl GroupToken { + /// The uid of the document the group belongs to. + pub fn doc(&self) -> u64 { + self.doc + } +} + +/// An open undo group: what runs on `doc` until it ends becomes one undo step. +#[derive(Clone, Debug)] +struct OpenGroup { + id: u64, + doc: u64, + label: String, + /// Undo depth when the group began; the group's own steps are cut back to it. + start: usize, + wb: Arc, + selection: Selection, + /// The redo stack when the group began (a rollback puts it back). + redo: Vec, +} + /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. #[derive(Clone, Debug)] pub struct Clipboard { @@ -256,6 +286,11 @@ pub struct Session { /// Review › Notes › Show All Notes: every note is displayed. An application display setting /// (like Excel's comment indicator option), so it's not saved in the file. pub show_all_notes: bool, + /// Open undo groups, innermost last. + groups: Vec, + next_group: u64, + /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. + group_epoch: u64, } impl Session { @@ -292,7 +327,8 @@ impl Session { } pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { - self.docs.remove(i); + let d = self.docs.remove(i); + self.groups.retain(|g| g.doc != d.uid); if self.active >= self.docs.len() { self.active = self.docs.len().saturating_sub(1); } @@ -313,7 +349,8 @@ impl Session { command_specs().iter().map(|c| c.info(self)).collect() } - /// Runs a command by id. Undoable commands record a history step when the workbook changed. + /// Runs a command by id. Undoable commands record a history step when the workbook changed, + /// unless an undo group is open on the document (the group then makes the step). /// Panics inside commands are caught and reported; the workbook is restored. pub fn execute(&mut self, id: &str, params: Json) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; @@ -322,13 +359,15 @@ impl Session { } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); let params = if params.is_null() { Json::Object(Default::default()) } else { params }; + let (first_group, group_epoch) = (self.next_group, self.group_epoch); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))); let result = match result { Ok(r) => r, Err(p) => { let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); - // Restore the workbook as it was. + // Restore the workbook as it was, and drop the undo groups the command opened. + self.groups.retain(|g| g.id < first_group); if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) { @@ -345,14 +384,18 @@ impl Session { self.journal.drain(..5_000); } } + // Inside an undo group, or when the command opened or closed one, the group owns the step. + let grouped = before.as_ref().is_some_and(|(uid, ..)| self.group_epoch != group_epoch || self.in_group(*uid)); if spec.undoable && let Some((uid, wb, sel)) = before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&d.wb, &wb) { - d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel }); - if d.undo.len() > UNDO_LIMIT { - d.undo.remove(0); + if !grouped { + d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel }); + if d.undo.len() > UNDO_LIMIT { + d.undo.remove(0); + } } d.redo.clear(); d.revision += 1; @@ -360,6 +403,100 @@ impl Session { result } + /// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f` + /// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made + /// is rolled back and its error is returned. Groups nest; the outermost one makes the step. + pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + let token = self.group_begin(label)?; + match f(self) { + Ok(r) => { + self.group_end(token)?; + Ok(r) + } + Err(e) => { + // The error from `f` is the one to report. A rollback fails only when the group + // has already ended, and then there is nothing left to undo. + let _ = self.group_rollback(token); + Err(e) + } + } + } + + /// Opens an undo group on the active document that can span several calls (for example a + /// debugger that runs one step per call). Until [`Session::group_end`] or + /// [`Session::group_rollback`], undoable commands on that document add no undo steps of their + /// own, and Undo and Redo are unavailable there. + pub fn group_begin(&mut self, label: &str) -> Result { + let d = self.doc()?; + let g = OpenGroup { + id: self.next_group, + doc: d.uid, + label: label.trim_end_matches('…').to_string(), + start: d.undo.len(), + wb: d.wb.clone(), + selection: d.selection.clone(), + redo: d.redo.clone(), + }; + let token = GroupToken { id: g.id, doc: g.doc }; + self.next_group = self.next_group.saturating_add(1); + self.group_epoch = self.group_epoch.wrapping_add(1); + self.groups.push(g); + Ok(token) + } + + /// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is + /// nested in another open group on the same document, which then owns the step. Groups opened + /// inside it and still open end with it. Returns whether the workbook changed. + pub fn group_end(&mut self, token: GroupToken) -> Result { + let g = self.take_group(&token)?; + let outer = self.in_group(g.doc); + let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) }; + // Steps pushed directly while the group was open fold into the group's step. + d.undo.truncate(g.start); + let changed = !Arc::ptr_eq(&d.wb, &g.wb); + if changed && !outer { + d.undo.push(HistoryEntry { label: g.label, wb: g.wb, selection: g.selection }); + while d.undo.len() > UNDO_LIMIT { + d.undo.remove(0); + } + d.redo.clear(); + d.revision += 1; + } + Ok(changed) + } + + /// Ends an undo group by putting back the workbook, selection, undo and redo history as they + /// were when it began. + pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> { + let g = self.take_group(&token)?; + let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) }; + if !Arc::ptr_eq(&d.wb, &g.wb) { + d.wb = g.wb; + d.calc.rebuild(&d.wb); + } + d.selection = g.selection; + d.undo.truncate(g.start); + d.redo = g.redo; + d.revision += 1; + Ok(()) + } + + /// Whether an undo group is open on the document with this uid. + pub fn in_group(&self, doc: u64) -> bool { + self.groups.iter().any(|g| g.doc == doc) + } + + /// Removes the token's group and any group on the same document opened after it. + fn take_group(&mut self, token: &GroupToken) -> Result { + let ended = || EngineError::Other("That undo group has already ended.".into()); + let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?; + let mut later = self.groups.split_off(at).into_iter(); + let g = later.next().ok_or_else(ended)?; + self.groups.extend(later.filter(|o| o.doc != g.doc)); + self.group_epoch = self.group_epoch.wrapping_add(1); + Ok(g) + } + pub fn run(&mut self, id: &str, params: Json) -> std::result::Result { self.execute(id, params).map_err(|e| e.to_string()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..b5ac30d43 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3344,3 +3344,4 @@ fn csv_as_text_keeps_identifier_columns_as_written() { let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); } +mod seams; diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs new file mode 100644 index 000000000..aec2c8544 --- /dev/null +++ b/crates/engine/src/tests/seams.rs @@ -0,0 +1,203 @@ +//! Engine building blocks for multi-command actions such as macros: undo groups, the recording +//! buffer, document trust and chunked storage of workbook data in hidden names. + +use serde_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(); +} + +// ---------------------------------------------------------------- undo groups + +#[test] +fn grouped_commands_are_one_undo_step() { + let mut s = s(); + set(&mut s, "A1", "before"); + let r = s.grouped("Fill Three", |s| { + set(s, "A1", "1"); + set(s, "A2", "2"); + s.execute("home.bold", json!({"range": "A1:A2"}))?; + Ok(7) + }); + assert_eq!(r.unwrap(), 7); + assert_eq!(undo_labels(&s), ["Enter Cell", "Fill Three"]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "before"); + assert!(v(&s, "A2").is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A2").display(), "2"); + assert!(!s.in_group(s.doc().unwrap().uid)); +} + +#[test] +fn a_failing_group_rolls_everything_back() { + let mut s = s(); + set(&mut s, "B1", "base"); + set(&mut s, "A1", "undone"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "C3"})).unwrap(); + let redo_before = s.doc().unwrap().redo.len(); + assert_eq!(redo_before, 1); + let r: crate::Result<()> = s.grouped("Doomed", |s| { + set(s, "B1", "changed"); + s.execute("selection.set", json!({"range": "D4"}))?; + set(s, "B2", "new"); + Err(EngineError::Other("step 3 failed".into())) + }); + assert!(r.unwrap_err().to_string().contains("step 3 failed")); + assert_eq!(v(&s, "B1").display(), "base"); + assert!(v(&s, "B2").is_empty()); + assert_eq!(s.doc().unwrap().selection.a1(), "C3"); + assert_eq!(undo_labels(&s), ["Enter Cell"]); + assert_eq!(s.doc().unwrap().redo.len(), redo_before, "the redo stack comes back too"); + // The recalculation follows the restored workbook. + set(&mut s, "C1", "=B1&\"!\""); + assert_eq!(v(&s, "C1").display(), "base!"); +} + +#[test] +fn groups_nest_and_the_outermost_makes_the_step() { + let mut s = s(); + let r = s.grouped("Outer", |s| { + set(s, "A1", "1"); + s.grouped("Inner", |s| { + set(s, "A2", "2"); + Ok(()) + })?; + // A failing inner group rolls back only its own changes; the outer one carries on. + let inner: crate::Result<()> = s.grouped("Inner fails", |s| { + set(s, "A3", "3"); + Err(EngineError::Other("no".into())) + }); + assert!(inner.is_err()); + assert!(v(s, "A3").is_empty()); + set(s, "A4", "4"); + Ok(()) + }); + r.unwrap(); + assert_eq!(undo_labels(&s), ["Outer"]); + assert_eq!(v(&s, "A2").display(), "2"); + s.execute("edit.undo", json!({})).unwrap(); + for c in ["A1", "A2", "A3", "A4"] { + assert!(v(&s, c).is_empty(), "{c}"); + } +} + +#[test] +fn an_unchanged_group_adds_no_step() { + let mut s = s(); + set(&mut s, "A1", "x"); + s.grouped("Look Around", |s| { + s.execute("selection.set", json!({"range": "B2"}))?; + s.execute("cell.get", json!({"cell": "A1"}))?; + Ok(()) + }) + .unwrap(); + assert_eq!(undo_labels(&s), ["Enter Cell"]); +} + +#[test] +fn a_group_spans_calls_and_blocks_undo_until_it_ends() { + let mut s = s(); + set(&mut s, "A1", "start"); + let token = s.group_begin("Debug Macro").unwrap(); + // Each step is its own call, as a debugger makes them. + set(&mut s, "A1", "step 1"); + set(&mut s, "A2", "step 2"); + let undo = s.execute("edit.undo", json!({})); + assert!(matches!(undo, Err(EngineError::Disabled(..))), "{undo:?}"); + assert!(matches!(s.execute("edit.redo", json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.execute("history.list", json!({})).unwrap()["grouping"], json!(true)); + assert!(s.group_end(token).unwrap()); + assert_eq!(s.execute("history.list", json!({})).unwrap()["grouping"], json!(false)); + assert_eq!(undo_labels(&s), ["Enter Cell", "Debug Macro"]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "start"); + assert!(v(&s, "A2").is_empty()); +} + +#[test] +fn rolling_back_a_spanning_group_restores_the_start() { + let mut s = s(); + set(&mut s, "A1", "start"); + let token = s.group_begin("Debug Macro").unwrap(); + set(&mut s, "A1", "step 1"); + s.execute("home.insertSheet", json!({})).unwrap(); + s.group_rollback(token).unwrap(); + assert_eq!(v(&s, "A1").display(), "start"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); + assert_eq!(undo_labels(&s), ["Enter Cell"]); +} + +#[test] +fn ending_an_outer_group_ends_the_inner_ones() { + let mut s = s(); + let outer = s.group_begin("Outer").unwrap(); + let inner = s.group_begin("Inner").unwrap(); + set(&mut s, "A1", "1"); + s.group_end(outer).unwrap(); + assert_eq!(undo_labels(&s), ["Outer"]); + assert!(s.group_end(inner).is_err(), "the inner group ended with its outer group"); + assert!(!s.in_group(s.doc().unwrap().uid)); +} + +#[test] +fn a_long_group_keeps_the_history_before_it() { + let mut s = s(); + for i in 0..100 { + set(&mut s, "A1", &i.to_string()); + } + let first = s.doc().unwrap().undo.first().map(|e| e.wb.clone()).unwrap(); + s.grouped("Many Steps", |s| { + for i in 0..250 { + set(s, "B1", &i.to_string()); + } + Ok(()) + }) + .unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.undo.len(), 100, "the undo limit still holds"); + assert_eq!(d.undo.last().map(|e| e.label.as_str()), Some("Many Steps")); + // Only the oldest entry made room for the group's one step. + assert!(!d.undo.iter().any(|e| std::sync::Arc::ptr_eq(&e.wb, &first))); + assert_eq!(d.undo.iter().filter(|e| e.label == "Enter Cell").count(), 99); +} + +#[test] +fn closing_a_document_drops_its_groups() { + let mut s = s(); + let token = s.group_begin("Debug Macro").unwrap(); + let uid = token.doc(); + s.close_document(s.active_index()); + assert!(!s.in_group(uid)); + assert!(s.group_end(token).is_err()); + assert!(matches!(s.group_begin("x"), Err(EngineError::NoDocument))); +} + +#[test] +fn a_group_owns_the_steps_of_commands_that_run_commands() { + // Insert Copied Cells runs nested steps and folds them into its own undo step; inside a group + // it must leave the history from before the group alone. + let mut s = s(); + set(&mut s, "A1", "kept"); + set(&mut s, "A5", "copied"); + s.execute("edit.copy", json!({"range": "5:5"})).unwrap(); + s.grouped("Insert Inside", |s| { + s.execute("selection.set", json!({"range": "A2"}))?; + s.execute("home.insertCopiedCells", json!({}))?; + Ok(()) + }) + .unwrap(); + assert_eq!(v(&s, "A2").display(), "copied"); + assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell", "Insert Inside"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A2").is_empty()); + assert_eq!(v(&s, "A1").display(), "kept"); +} From d4f1e61c994e80f860761c0a6a83bafaf199d2a6 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:04:33 -0400 Subject: [PATCH 002/166] Engine: a recording buffer of top-level commands, apart from the journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A macro recorder needs more than a journal index: the journal is trimmed past 10,000 entries, it also holds the commands that other commands run (so playback would run them twice), and it keeps no selection, which relative references need. Session now tracks how deeply execute calls are nested. While recording, a buffer (record.rs) keeps each successful, journaled top-level call with the document uid, the active sheet (index and name) and the selection it ran in, then its id and parameters without secrets, as the journal stores them. Queries, failed commands and the command that starts recording are not kept. The buffer holds up to 100,000 steps and counts any it drops. Session::start_recording, stop_recording, is_recording and depth are the interface. Automate › Record Actions keeps recording through the journal as before. --- crates/engine/src/lib.rs | 44 ++++++++++++++- crates/engine/src/record.rs | 50 +++++++++++++++++ crates/engine/src/tests/seams.rs | 92 ++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/record.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e8e3d73d6..03e710a45 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod outline; pub mod paper; pub mod pivot; pub mod pivot_calc; +pub mod record; pub mod sample; pub mod selection; pub mod tables; @@ -273,8 +274,13 @@ pub struct Session { pub last_find: Option, /// View › Show › Ruler is off (rulers in Page Layout view; an application option). pub hide_ruler: bool, - /// Journal position where action recording started. + /// Journal position where Automate › Record Actions started. pub recording: Option, + /// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level + /// commands with the place they ran in, kept apart from the journal. + pub recorder: Option, + /// How many `execute` calls are running (0 between top-level commands). + depth: u32, /// Windows hidden with View › Hide. pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. @@ -360,7 +366,14 @@ impl Session { let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); let params = if params.is_null() { Json::Object(Default::default()) } else { params }; let (first_group, group_epoch) = (self.next_group, self.group_epoch); + // The recorder keeps top-level calls with the place they ran in. + let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() { + Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), + None => (0, 0, String::new(), Selection::default()), + }); + self.depth = self.depth.saturating_add(1); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))); + self.depth = self.depth.saturating_sub(1); let result = match result { Ok(r) => r, Err(p) => { @@ -379,6 +392,13 @@ impl Session { } }; if spec.journal && result.is_ok() { + // A command that started recording is not part of the recording; one that stopped it + // finds no recorder. + if let Some((doc, sheet, sheet_name, selection)) = place + && let Some(rec) = self.recorder.as_mut() + { + rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); + } self.journal.push((id.to_string(), cmd::journal_params(¶ms))); if self.journal.len() > 10_000 { self.journal.drain(..5_000); @@ -481,6 +501,28 @@ impl Session { Ok(()) } + /// Starts recording top-level commands into an empty buffer, replacing any recording in + /// progress. Queries, failed commands and commands run by other commands are not recorded. + pub fn start_recording(&mut self) { + self.recorder = Some(record::Recorder::default()); + } + + /// Stops recording and returns what was recorded (`None` when nothing was recording). + pub fn stop_recording(&mut self) -> Option { + self.recorder.take() + } + + /// Whether the recording buffer is collecting commands. + pub fn is_recording(&self) -> bool { + self.recorder.is_some() + } + + /// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a + /// command run by a command, and so on. + pub fn depth(&self) -> u32 { + self.depth + } + /// Whether an undo group is open on the document with this uid. pub fn in_group(&self, doc: u64) -> bool { self.groups.iter().any(|g| g.doc == doc) diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs new file mode 100644 index 000000000..dbbc1a8af --- /dev/null +++ b/crates/engine/src/record.rs @@ -0,0 +1,50 @@ +//! The recording buffer: the commands a person (or an agent) ran while recording, each with the +//! document, sheet and selection it ran in. +//! +//! Only top-level calls are kept. A command that runs other commands (Custom Views › Show, a +//! script) is one step, so playing the recording back does not run the inner commands twice. The +//! buffer is separate from the journal, so trimming the journal never loses recorded steps, and +//! secret parameters are left out as they are in the journal ([`crate::cmd::journal_params`]). + +use serde::Serialize; +use serde_json::Value as Json; + +use crate::Selection; + +/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`]. +pub const RECORD_LIMIT: usize = 100_000; + +/// One recorded command and where it ran. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordedStep { + /// Uid of the document that was active when the command ran (0 when none was open). + pub doc: u64, + /// That document's active sheet (index and name) just before the command ran. + pub sheet: usize, + pub sheet_name: String, + /// That document's selection just before the command ran. + pub selection: Selection, + /// The command id and its parameters, without secrets. + pub id: String, + pub params: Json, +} + +/// The steps recorded since recording started. +#[derive(Clone, Debug, Default)] +pub struct Recorder { + pub steps: Vec, + /// Steps not kept because the recording reached [`RECORD_LIMIT`]. + pub dropped: usize, +} + +impl Recorder { + /// Adds a step, or counts it as dropped once the buffer is full. + pub fn push(&mut self, step: RecordedStep) { + if self.steps.len() < RECORD_LIMIT { + self.steps.push(step); + } else { + self.dropped = self.dropped.saturating_add(1); + } + } +} diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index aec2c8544..005d700a6 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -201,3 +201,95 @@ fn a_group_owns_the_steps_of_commands_that_run_commands() { assert!(v(&s, "A2").is_empty()); assert_eq!(v(&s, "A1").display(), "kept"); } + +// ---------------------------------------------------------------- recording buffer + +fn recorded_ids(s: &Session) -> Vec { + s.recorder.as_ref().unwrap().steps.iter().map(|st| st.id.clone()).collect() +} + +#[test] +fn the_recorder_keeps_top_level_calls_with_where_they_ran() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + s.start_recording(); + assert!(s.is_recording()); + assert_eq!(s.depth(), 0); + set(&mut s, "B2", "10"); + s.execute("cell.get", json!({"cell": "B2"})).unwrap(); // a query: not recorded + assert!(s.execute("sheet.move", json!({})).is_err()); // failed: not recorded + s.execute("view.customViews", json!({"save": "Mine"})).unwrap(); + // Show runs sheet.activate, selection.set and view.zoom inside it: one step. + s.execute("view.customViews", json!({"show": "Mine"})).unwrap(); + assert_eq!(recorded_ids(&s), ["cell.set", "view.customViews", "view.customViews"]); + let first = &s.recorder.as_ref().unwrap().steps[0]; + let d = s.doc().unwrap(); + assert_eq!((first.doc, first.sheet), (d.uid, d.wb.active_sheet)); + assert_eq!(first.sheet_name, d.wb.active().unwrap().name); + assert_eq!(first.selection.a1(), "B2:C3", "the selection before the command ran"); + assert_eq!(first.params, json!({"cell": "B2", "input": "10"})); + assert_eq!(s.depth(), 0); + let rec = s.stop_recording().unwrap(); + assert_eq!(rec.dropped, 0); + set(&mut s, "A1", "after"); + assert!(s.recorder.is_none() && !s.is_recording()); +} + +#[test] +fn the_recorder_survives_journal_trimming() { + let mut s = s(); + s.start_recording(); + for i in 0..10_050u32 { + s.execute("selection.set", json!({"range": format!("A{}", i % 50 + 1)})).unwrap(); + } + set(&mut s, "C1", "last"); + assert!(s.journal.len() < 10_051, "the journal was trimmed"); + let steps = &s.recorder.as_ref().unwrap().steps; + assert_eq!(steps.len(), 10_051); + assert_eq!(steps[0].params, json!({"range": "A1"})); + assert_eq!(steps[10_050].id, "cell.set"); +} + +#[test] +fn the_recorder_has_a_cap() { + let mut rec = crate::record::Recorder::default(); + let step = crate::record::RecordedStep { + doc: 1, + sheet: 0, + sheet_name: "Sheet1".into(), + selection: crate::Selection::default(), + id: "cell.set".into(), + params: json!({}), + }; + for _ in 0..crate::record::RECORD_LIMIT + 3 { + rec.push(step.clone()); + } + assert_eq!(rec.steps.len(), crate::record::RECORD_LIMIT); + assert_eq!(rec.dropped, 3); +} + +#[test] +fn passwords_stay_out_of_the_recording_buffer() { + const PW: &str = "synthetic-pw-5521"; + let mut s = s(); + s.start_recording(); + s.execute("review.protectSheet", json!({"password": PW, "formatCells": true})).unwrap(); + s.execute("review.unprotectSheet", json!({"password": PW})).unwrap(); + let rec = s.stop_recording().unwrap(); + let text = serde_json::to_string(&rec.steps).unwrap(); + assert!(!text.contains(PW), "{text}"); + assert_eq!(rec.steps[0].params, json!({"formatCells": true})); +} + +#[test] +fn record_actions_is_unchanged_by_the_buffer() { + // Automate › Record Actions keeps recording through the journal; the buffer is separate. + let mut s = s(); + s.execute("automate.recordActions", json!({})).unwrap(); + assert!(!s.is_recording()); + set(&mut s, "A1", "typed"); + let r = s.execute("automate.recordActions", json!({"stop": true, "name": "Typed"})).unwrap(); + assert_eq!(r["steps"], 1); + assert!(s.recorder.is_none()); +} From b7bce3bc09aa973f2128ef8a39725a38c30b6513 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:05:42 -0400 Subject: [PATCH 003/166] Engine: document trust for workbooks opened from files Active content such as macros must not run only because a workbook was opened. DocState gets a trust state: Local for workbooks made in the session (new workbooks and samples) and FromFile for anything opened with file.open. Active content in a FromFile workbook asks before it runs. A second window on a workbook shares its trust. The clipboard carries the trust of its source document. Pasting formulas, formats (conditional formats hold formulas) or validation from a FromFile workbook into another workbook makes that workbook FromFile too; pasting values, column widths or notes carries nothing active and changes nothing. Sheet moves and copies stay inside one workbook, so its trust is unchanged. document.inspect reports the trust. --- crates/engine/src/cmd/edit.rs | 8 +++- crates/engine/src/cmd/extra.rs | 1 + crates/engine/src/cmd/file.rs | 2 + crates/engine/src/cmd/inspect.rs | 3 +- crates/engine/src/lib.rs | 30 ++++++++++++++ crates/engine/src/tests/seams.rs | 67 +++++++++++++++++++++++++++++++- 6 files changed, 108 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fe7f1fe8b..496ca811a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -676,7 +676,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { @@ -1183,6 +1183,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; Ok(()) })?; + // Formulas, formats (conditional formats hold formulas) and validation from an untrusted file + // leave the destination untrusted; plain values, column widths and notes carry nothing active. + if !same_doc && !matches!(what, What::Values | What::ValuesAndNumberFormats | What::ColumnWidths | What::Comments) { + let d = s.doc_mut()?; + d.trust = d.trust.receive(clip.trust); + } if cut_move { s.clipboard = None; } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 01e2ac6e6..6d6516f50 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -782,6 +782,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result { let d = s.doc()?.clone(); let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title())); copy.selection = d.selection.clone(); + copy.trust = d.trust; let i = s.add_document(copy); Ok(json!({"index": i})) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9ddc05611..b4dec84c1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -80,6 +80,8 @@ fn open(s: &mut Session, p: &Json) -> Result { if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { doc.content_kind = Some(crate::io::FileKind::Xlsx); } + // Someone else may have made the file: its active content asks before it runs. + doc.trust = crate::Trust::FromFile; let i = s.add_document(doc); // The decrypted-workbook notice is for the info bar, not a message box. let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 366d8e6e6..de65ac206 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -7,7 +7,7 @@ use super::*; pub fn specs() -> Vec { vec![ - cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect), + cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect), cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read), cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get), cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats), @@ -63,6 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "title": d.display_title(), "path": d.path, "dirty": d.is_dirty(), + "trust": d.trust, "activeSheet": wb.active_sheet, "sheets": sheets, "selection": d.selection.a1(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03e710a45..b57c474a1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,6 +112,33 @@ pub struct Clipboard { /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid /// together into one block at `range`. Empty for a single-range copy. pub areas: Vec, + /// Trust of the source document: pasting content from a file someone else made carries it. + pub trust: Trust, +} + +/// Whether a document's active content (macros) may run without asking the person first. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Trust { + /// Made in this session: a new workbook or a sample. + #[default] + Local, + /// Opened from a file, or holding content pasted from one: active content asks before it + /// runs. + FromFile, +} + +impl Trust { + /// Whether active content may run without asking. + pub fn allows_code(self) -> bool { + self == Trust::Local + } + + /// The trust of a document after it receives content from a document with trust `source`: + /// content from an untrusted file leaves the receiver untrusted. + pub fn receive(self, source: Trust) -> Trust { + if source == Trust::FromFile { Trust::FromFile } else { self } + } } /// In-cell edit mode as shown in the status bar. @@ -143,6 +170,8 @@ pub struct DocState { /// The format `path`'s content was recognised as when its extension doesn't name one (an /// XLSX called `budget.dat`): Save writes it back in that format. pub content_kind: Option, + /// Whether the workbook's active content may run without asking. + pub trust: Trust, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -168,6 +197,7 @@ impl DocState { sheet_selections: vec![], unlocked_ranges: vec![], content_kind: None, + trust: Trust::Local, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 005d700a6..55c35d76c 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -4,7 +4,7 @@ use serde_json::json; use super::{s, v}; -use crate::{EngineError, Session}; +use crate::{EngineError, Session, Trust}; fn undo_labels(s: &Session) -> Vec { s.doc().unwrap().undo.iter().map(|e| e.label.clone()).collect() @@ -293,3 +293,68 @@ fn record_actions_is_unchanged_by_the_buffer() { assert_eq!(r["steps"], 1); assert!(s.recorder.is_none()); } + +// ---------------------------------------------------------------- trust + +fn open_json(s: &mut Session, wb: &gridcraft_model::Workbook) { + let bytes = serde_json::to_vec(wb).unwrap(); + s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)})).unwrap(); +} + +fn trust(s: &Session) -> Trust { + s.doc().unwrap().trust +} + +#[test] +fn documents_know_where_they_came_from() { + let mut s = s(); + assert_eq!(trust(&s), Trust::Local); + assert!(Trust::Local.allows_code() && !Trust::FromFile.allows_code()); + s.execute("file.new", json!({"sample": "sales"})).unwrap(); + assert_eq!(trust(&s), Trust::Local); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["trust"], "local"); + open_json(&mut s, &gridcraft_model::Workbook::new()); + assert_eq!(trust(&s), Trust::FromFile); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["trust"], "fromFile"); + // A second window on the same workbook is the same workbook. + s.execute("view.newWindow", json!({})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile); + // Moving and copying sheets inside it changes nothing. + s.execute("sheet.move", json!({"to": 0, "copy": true})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile); +} + +#[test] +fn pasting_from_a_file_carries_its_trust() { + let mut s = s(); // document 0: Local + open_json(&mut s, &gridcraft_model::Workbook::new()); // document 1: FromFile + let theirs = s.active_index(); + set(&mut s, "A1", "=1+1"); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.new_workbook(); + let mine = s.active_index(); + // Values carry nothing that runs. + s.execute("edit.pasteSpecial", json!({"what": "values", "at": "B1"})).unwrap(); + assert_eq!(trust(&s), Trust::Local); + s.execute("edit.paste", json!({"at": "C1"})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile); + assert_eq!(v(&s, "C1").display(), "2"); + + // Pasting within a workbook changes nothing, and content from a local workbook leaves the + // destination as it was. + s.set_active(theirs); + s.execute("edit.paste", json!({"at": "A3"})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile, "pasting within a workbook"); + s.new_workbook(); + set(&mut s, "A1", "=3"); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.set_active(mine); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile, "still untrusted from the earlier paste"); + s.new_workbook(); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + assert_eq!(trust(&s), Trust::Local); + assert_eq!(Trust::Local.receive(Trust::FromFile), Trust::FromFile); + assert_eq!(Trust::FromFile.receive(Trust::Local), Trust::FromFile); + assert_eq!(Trust::Local.receive(Trust::Local), Trust::Local); +} From 7d1fee8c78e38ead757edd4cbba7b554066c9187 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:06:27 -0400 Subject: [PATCH 004/166] Engine: workbook data in chunked hidden names (cmd/stash.rs) GridCraft keeps workbook data in hidden defined names so it travels inside the xlsx file. The existing items write one name holding one string literal, which past Excel's 255-character literal and 8,192-character formula limits gives a file Excel does not accept. cmd/stash.rs stores an item `key` as hidden workbook-scope names _gc__0, _gc__1, ..., each a formula of string literals of at most 255 characters (UTF-16 units, as Excel counts them) joined with &, at most 8,192 characters per name. Reading concatenates the chunks in order and stops at a gap; it also accepts the older single _sc_ name. Names are matched case-insensitively and only at workbook scope. Keys are limited to letters, digits, _ and ., items to 4,194,304 characters, and junk around or inside the literals, duplicate or out-of-order chunks, non-JSON and over-deep JSON all read safely. keys(prefix) lists the stored items that start with a prefix. Custom views, scripts, the watch list and scenarios keep their current storage; nothing reads or writes the new names yet. docs/architecture.md describes the engine building blocks added in this series: undo groups, the recording buffer, document trust and this storage. --- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/stash.rs | 335 +++++++++++++++++++++++++++++++++ docs/architecture.md | 24 ++- 3 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/stash.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d8abfac61..1db65c20c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub mod sheet; pub mod slicer; pub mod solver; pub mod spelling; +pub mod stash; pub mod view; pub mod whatif; diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs new file mode 100644 index 000000000..a31d58752 --- /dev/null +++ b/crates/engine/src/cmd/stash.rs @@ -0,0 +1,335 @@ +//! GridCraft data kept in hidden workbook-scope defined names, so that it travels inside the xlsx +//! file. +//! +//! An item `key` is stored as names `_gc__0`, `_gc__1`, …, each a formula of string +//! literals joined with `&`: `"…"&"…"`. Excel limits a string literal to 255 characters and a +//! formula to 8,192, so a long value is split over as many names as it needs and the file stays +//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep +//! one name each, `_sc_`; reading accepts that form as well, so those items can move to this +//! format later, and a write of the same key replaces it. + +use std::collections::BTreeMap; + +use gridcraft_model::{DefinedName, Workbook}; +use serde_json::Value as Json; + +use crate::{EngineError, Result}; + +const PREFIX: &str = "_gc_"; +const LEGACY_PREFIX: &str = "_sc_"; +/// Characters (UTF-16 units, as Excel counts them) in one string literal. +const LITERAL_UNITS: usize = 255; +/// Characters in one name's formula. +const FORMULA_UNITS: usize = 8_192; +/// The longest key: `_gc__` stays well inside Excel's 255-character names. +const MAX_KEY: usize = 200; +/// The longest item: 4,194,304 characters. +pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; + +/// The item text stored under `key`, or `None` when there is none (or it is over the size limit). +pub fn get(wb: &Workbook, key: &str) -> Option { + let mut chunks: BTreeMap = BTreeMap::new(); + let mut legacy = None; + for n in wb.names.iter().filter(|n| n.scope.is_none()) { + match parse_name(&n.name) { + Some((k, Some(i))) if k.eq_ignore_ascii_case(key) => { + chunks.entry(i).or_insert(&n.formula); + } + Some((k, None)) if k.eq_ignore_ascii_case(key) => legacy = legacy.or(Some(&n.formula)), + _ => {} + } + } + let mut out = String::new(); + if chunks.is_empty() { + out = string_literals(legacy?); + } else { + // Chunks run 0, 1, 2, …; anything after a gap isn't part of the item. + for (want, (i, f)) in chunks.iter().enumerate() { + if *i != want as u64 { + break; + } + out.push_str(&string_literals(f)); + if out.len() > MAX_ITEM_CHARS.saturating_mul(4) { + return None; + } + } + } + (out.chars().count() <= MAX_ITEM_CHARS).then_some(out) +} + +/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it. +pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { + check_key(key)?; + if text.chars().count() > MAX_ITEM_CHARS { + return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024)))); + } + remove(wb, key); + for (i, formula) in chunk_formulas(text).into_iter().enumerate() { + wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); + } + Ok(()) +} + +/// Removes the item stored under `key`. +pub fn remove(wb: &mut Workbook, key: &str) { + wb.names.retain(|n| n.scope.is_some() || !parse_name(&n.name).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key))); +} + +/// The item under `key` as JSON (`None` when missing or not JSON). +pub fn get_json(wb: &Workbook, key: &str) -> Option { + serde_json::from_str(&get(wb, key)?).ok() +} + +pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> { + set(wb, key, &v.to_string()) +} + +/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted. +pub fn keys(wb: &Workbook, prefix: &str) -> Vec { + let mut keys: Vec = wb + .names + .iter() + .filter(|n| n.scope.is_none()) + .filter_map(|n| parse_name(&n.name).map(|(k, _)| k.to_ascii_lowercase())) + .filter(|k| k.starts_with(&prefix.to_ascii_lowercase())) + .collect(); + keys.sort(); + keys.dedup(); + keys +} + +/// The item a hidden name belongs to, lower-cased: `_gc__` and `_sc_` give ``. +pub fn item_key(name: &str) -> Option { + parse_name(name).map(|(k, _)| k.to_ascii_lowercase()) +} + +/// `_gc__` → (`key`, `Some(k)`); `_sc_` → (`key`, `None`). +fn parse_name(name: &str) -> Option<(&str, Option)> { + if let Some(rest) = strip_prefix_ci(name, PREFIX) { + let (key, k) = rest.rsplit_once('_')?; + let all_digits = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()); + if key.is_empty() || !all_digits { + return None; + } + return k.parse().ok().map(|i| (key, Some(i))); + } + strip_prefix_ci(name, LEGACY_PREFIX).filter(|k| !k.is_empty()).map(|k| (k, None)) +} + +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..)).flatten() +} + +fn check_key(key: &str) -> Result<()> { + let ok = !key.is_empty() && key.len() <= MAX_KEY && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.'); + if ok { Ok(()) } else { Err(EngineError::Other(format!("`{key}` can't name workbook data: use letters, digits, `_` and `.`"))) } +} + +/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula +/// at most 8,192 characters. Concatenating [`string_literals`] of each gives `text` back. +pub fn chunk_formulas(text: &str) -> Vec { + let mut formulas = Vec::new(); + let (mut formula, mut units) = (String::new(), 0usize); + let (mut literal, mut lit_units) = (String::from("\""), 0usize); + let mut flush_literal = |literal: &mut String, lit_units: &mut usize, formula: &mut String, units: &mut usize| { + literal.push('"'); + let cost = literal.encode_utf16().count() + usize::from(!formula.is_empty()); + if *units + cost > FORMULA_UNITS && !formula.is_empty() { + formulas.push(std::mem::take(formula)); + *units = 0; + } + if !formula.is_empty() { + formula.push('&'); + *units += 1; + } + *units += literal.encode_utf16().count(); + formula.push_str(literal); + *literal = String::from("\""); + *lit_units = 0; + }; + for ch in text.chars() { + if lit_units + ch.len_utf16() > LITERAL_UNITS { + flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); + } + lit_units += ch.len_utf16(); + if ch == '"' { + literal.push('"'); + } + literal.push(ch); + } + if lit_units > 0 { + flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); + } + if !formula.is_empty() { + formulas.push(formula); + } + formulas +} + +/// Concatenates the string literals of a `"a"&"b""c"` formula (anything outside them is ignored). +pub fn string_literals(f: &str) -> String { + let mut out = String::new(); + let mut it = f.chars().peekable(); + let mut in_str = false; + while let Some(c) = it.next() { + if !in_str { + if c == '"' { + in_str = true; + } + continue; + } + if c == '"' { + if it.peek() == Some(&'"') { + it.next(); + out.push('"'); + } else { + in_str = false; + } + } else { + out.push(c); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn name(n: &str, formula: &str) -> DefinedName { + DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: true } + } + + fn excel_limits_hold(wb: &Workbook) { + for n in &wb.names { + assert!(n.formula.encode_utf16().count() <= FORMULA_UNITS, "{} is {} long", n.name, n.formula.len()); + // Every literal (quotes undoubled) is at most 255 characters. + for lit in n.formula.split("\"&\"") { + let inner = lit.trim_start_matches('"').trim_end_matches('"').replace("\"\"", "\""); + assert!(inner.encode_utf16().count() <= LITERAL_UNITS, "{}", inner.len()); + } + } + } + + #[test] + fn long_values_split_over_names_and_come_back() { + let mut wb = Workbook::new(); + // Quotes double when written; characters outside the BMP count twice. + let text: String = (0..30_000).map(|i| ['a', '"', 'é', '\u{1D11E}', '&', '\n'][i % 6]).collect(); + set(&mut wb, "big", &text).unwrap(); + let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect(); + assert!(names.len() > 5, "{names:?}"); + assert_eq!(names[0], "_gc_big_0"); + assert!(wb.names.iter().all(|n| n.hidden && n.scope.is_none())); + excel_limits_hold(&wb); + assert_eq!(get(&wb, "big").as_deref(), Some(text.as_str())); + assert_eq!(get(&wb, "BIG").as_deref(), Some(text.as_str()), "names are case-insensitive"); + // Shorter again: the extra chunks go. + set(&mut wb, "big", "short").unwrap(); + assert_eq!(wb.names.len(), 1); + assert_eq!(wb.names[0].formula, "\"short\""); + set(&mut wb, "big", "").unwrap(); + assert!(wb.names.is_empty()); + assert_eq!(get(&wb, "big"), None); + } + + #[test] + fn chunks_and_literals_respect_the_boundaries() { + for len in [0, 1, 254, 255, 256, 8_000, 8_192, 8_193, 20_000] { + let text = "q".repeat(len); + let fs = chunk_formulas(&text); + assert_eq!(fs.iter().map(|f| string_literals(f)).collect::(), text, "{len}"); + assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS), "{len}"); + } + // All quotes: each literal doubles in the formula. + let text = "\"".repeat(9_000); + let fs = chunk_formulas(&text); + assert_eq!(fs.iter().map(|f| string_literals(f)).collect::(), text); + assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS)); + } + + #[test] + fn the_old_single_name_still_reads_and_is_replaced_on_write() { + let mut wb = Workbook::new(); + wb.names.push(name("_sc_scripts", "\"{\"\"A\"\":[1]}\"")); + wb.names.push(name("_sc_scenarios", "\"[1,\"&\"2]\"")); + assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"A": [1]}))); + assert_eq!(get_json(&wb, "scenarios"), Some(serde_json::json!([1, 2]))); + set_json(&mut wb, "scripts", &serde_json::json!({"B": []})).unwrap(); + let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["_sc_scenarios", "_gc_scripts_0"]); + assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"B": []}))); + } + + #[test] + fn hostile_names_read_safely() { + let mut wb = Workbook::new(); + // A gap: chunk 2 without chunk 1. + wb.names.push(name("_gc_gap_0", "\"a\"")); + wb.names.push(name("_gc_gap_2", "\"c\"")); + assert_eq!(get(&wb, "gap").as_deref(), Some("a")); + // Chunks out of order, a duplicate, junk around the literals, an unterminated literal. + wb.names.push(name("_gc_mix_1", "\"b\"&A1&")); + wb.names.push(name("_gc_mix_0", "SUM(\"a\")")); + wb.names.push(name("_gc_mix_0", "\"ignored\"")); + wb.names.push(name("_gc_mix_2", "\"c")); + assert_eq!(get(&wb, "mix").as_deref(), Some("abc")); + // Names that only look like chunks. + for odd in ["_gc_", "_gc__0", "_gc_x_", "_gc_x_-1", "_gc_x_99999999999999999999", "_sc_", "_gcx_0"] { + wb.names.push(name(odd, "\"zzz\"")); + } + assert_eq!(get(&wb, "x"), None); + assert_eq!(get(&wb, ""), None); + // Not JSON, or JSON nested deeper than the parser allows. + wb.names.push(name("_gc_notjson_0", "\"{\"")); + assert_eq!(get_json(&wb, "notjson"), None); + let deep = format!("\"{}{}\"", "[".repeat(10_000), "]".repeat(10_000)); + wb.names.push(name("_gc_deep_0", &deep)); + assert_eq!(get_json(&wb, "deep"), None); + // Sheet-scoped names are someone else's. + wb.names.push(DefinedName { scope: Some(0), ..name("_gc_scoped_0", "\"s\"") }); + assert_eq!(get(&wb, "scoped"), None); + // Bad keys are refused. + for bad in ["", "has space", "quote\"", "a/b", &"k".repeat(201)] { + assert!(set(&mut wb, bad, "v").is_err(), "{bad}"); + } + assert!(set(&mut wb, "big", &"x".repeat(MAX_ITEM_CHARS + 1)).is_err()); + } + + #[test] + fn oversized_items_are_not_read() { + let mut wb = Workbook::new(); + let chunk = format!("\"{}\"", "x".repeat(250)); + let big = vec![chunk.as_str(); 32].join("&"); + for i in 0..(MAX_ITEM_CHARS / 8_000 + 2) { + wb.names.push(name(&format!("_gc_huge_{i}"), &big)); + } + assert_eq!(get(&wb, "huge"), None); + } + + #[test] + fn keys_list_items_by_prefix() { + let mut wb = Workbook::new(); + set(&mut wb, "macro_2", "b").unwrap(); + set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap(); + set(&mut wb, "scripts", "{}").unwrap(); + assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]); + assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1")); + assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts")); + assert_eq!(item_key("Total"), None); + } + + #[test] + fn items_round_trip_through_xlsx() { + let mut wb = Workbook::new(); + let text: String = (0..20_000).map(|i| ['x', '"', 'ü', '\u{1D11E}', ' '][i % 5]).collect(); + set(&mut wb, "blob", &text).unwrap(); + wb.names.push(name("_sc_watch", "\"[\"\"A1\"\"]\"")); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str())); + assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"]))); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 5695036b2..446cbe432 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code on main; supersedes the local-only plan/architecture.md for committed docs) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -43,6 +43,27 @@ including tests, 2026-10-10. 5. **Files**: `engine/io.rs` sniffs content (XLSX, XLSB, ODS) before trusting the extension and dispatches to `crates/xlsx`; imports that lose data return warnings shown to the user. +## Engine building blocks for multi-command actions + +Actions made of many commands (macros, a debugger that runs one step per call) use these parts of +`Session` and the engine: + +- **Undo groups.** `Session::grouped(label, f)` runs `f` as one undo step and rolls every change + back if `f` fails; `group_begin`, `group_end` and `group_rollback` do the same across calls. + While a group is open on a document, its commands add no undo steps of their own and Undo and + Redo are disabled there. Groups nest; the outermost one makes the step. +- **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful + top-level command with the document, sheet and selection it ran in, without secret parameters. + Commands run by other commands are not recorded, and the buffer is independent of the journal + (Automate › Record Actions still records through the journal). +- **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and + `FromFile` for workbooks opened from a file, and for workbooks that received formulas, formats + or validation pasted from one. Active content in a `FromFile` workbook asks before it runs. +- **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden + workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and + 8,192-character limits, so it survives an xlsx round trip. The older single-name `_sc_` + items are read as well. + ## Agent control - Desktop control channel: `gridcraft --control 7979`, JSON lines on localhost @@ -68,4 +89,5 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | From e165eaf3a65adcd67b890182a80face59827e865 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:18:15 -0400 Subject: [PATCH 005/166] Engine: a linked paste of values from a file carries the file's trust A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes. --- crates/engine/src/cmd/edit.rs | 6 ++++-- crates/engine/src/tests/seams.rs | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 496ca811a..018dd2991 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1184,8 +1184,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { Ok(()) })?; // Formulas, formats (conditional formats hold formulas) and validation from an untrusted file - // leave the destination untrusted; plain values, column widths and notes carry nothing active. - if !same_doc && !matches!(what, What::Values | What::ValuesAndNumberFormats | What::ColumnWidths | What::Comments) { + // leave the destination untrusted; plain values, column widths and notes carry nothing active + // (a linked paste of values still writes formulas). + let passive = matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Values | What::ValuesAndNumberFormats)); + if !same_doc && !passive { let d = s.doc_mut()?; d.trust = d.trust.receive(clip.trust); } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 55c35d76c..cc082258e 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -336,6 +336,10 @@ fn pasting_from_a_file_carries_its_trust() { // Values carry nothing that runs. s.execute("edit.pasteSpecial", json!({"what": "values", "at": "B1"})).unwrap(); assert_eq!(trust(&s), Trust::Local); + // A linked paste of values still writes formulas. + s.execute("edit.pasteSpecial", json!({"what": "values", "link": true, "at": "B2"})).unwrap(); + assert_eq!(trust(&s), Trust::FromFile); + s.new_workbook(); s.execute("edit.paste", json!({"at": "C1"})).unwrap(); assert_eq!(trust(&s), Trust::FromFile); assert_eq!(v(&s, "C1").display(), "2"); From 83ae63f79352bd0e473af0b4049b0dffdb2b0f2a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:18:15 -0400 Subject: [PATCH 006/166] Engine: a stash item with only later chunks reads as missing and falls back to the legacy name With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists. --- crates/engine/src/cmd/stash.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index a31d58752..fd8612ad0 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -40,7 +40,7 @@ pub fn get(wb: &Workbook, key: &str) -> Option { } } let mut out = String::new(); - if chunks.is_empty() { + if !chunks.contains_key(&0) { out = string_literals(legacy?); } else { // Chunks run 0, 1, 2, …; anything after a gap isn't part of the item. @@ -269,6 +269,11 @@ mod tests { wb.names.push(name("_gc_gap_0", "\"a\"")); wb.names.push(name("_gc_gap_2", "\"c\"")); assert_eq!(get(&wb, "gap").as_deref(), Some("a")); + // Only a later chunk: no item, and the legacy name is still read. + wb.names.push(name("_gc_orphan_2", "\"c\"")); + assert_eq!(get(&wb, "orphan"), None); + wb.names.push(name("_sc_orphan", "\"old\"")); + assert_eq!(get(&wb, "orphan").as_deref(), Some("old")); // Chunks out of order, a duplicate, junk around the literals, an unterminated literal. wb.names.push(name("_gc_mix_1", "\"b\"&A1&")); wb.names.push(name("_gc_mix_0", "SUM(\"a\")")); From f4e05534a3aa46d28a686db12d5649c2e6d97b40 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:20:29 -0400 Subject: [PATCH 007/166] Engine: a command that runs its own undo groups makes one undo step for itself execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command. --- crates/engine/src/cmd/mod.rs | 17 ++++++++++++ crates/engine/src/lib.rs | 27 +++++++++++++++++--- crates/engine/src/tests/seams.rs | 44 ++++++++++++++++++++++++++++++++ docs/architecture.md | 4 ++- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1db65c20c..fadb72b6c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -187,6 +187,23 @@ pub fn register_test_command(id: &'static str, run: Run) { test_commands().push(spec); } +/// Test-only: an undoable command, to exercise how `execute` records its step. +#[cfg(test)] +pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) { + let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec { + id, + label: id, + menu: &[], + shortcut: None, + params: "", + enabled: |_| Ok(()), + run, + journal: false, + undoable: true, + })); + test_commands().push(spec); +} + fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> { #[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery match TEST_COMMANDS.lock() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b57c474a1..e12335e88 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -327,6 +327,8 @@ pub struct Session { next_group: u64, /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. group_epoch: u64, + /// The step the last group made: document, its undo length after the step, and the epoch. + group_step: Option<(u64, usize, u64)>, } impl Session { @@ -396,6 +398,8 @@ impl Session { let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); let params = if params.is_null() { Json::Object(Default::default()) } else { params }; let (first_group, group_epoch) = (self.next_group, self.group_epoch); + // Whether a group was open on the document before the command, and how long its history was. + let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); // The recorder keeps top-level calls with the place they ran in. let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() { Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), @@ -434,15 +438,31 @@ impl Session { self.journal.drain(..5_000); } } - // Inside an undo group, or when the command opened or closed one, the group owns the step. - let grouped = before.as_ref().is_some_and(|(uid, ..)| self.group_epoch != group_epoch || self.in_group(*uid)); + // Inside an undo group, or when the command ended one that was open before it, the group + // owns the step. A command that opened and closed its own groups makes one step of its + // own, which replaces the steps those groups made. + let epoch_changed = self.group_epoch != group_epoch; + let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid)); + let own_groups = epoch_changed && !in_group_now && start.is_some_and(|(was_open, _)| !was_open); + let grouped = !own_groups && (epoch_changed || in_group_now); if spec.undoable && let Some((uid, wb, sel)) = before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&d.wb, &wb) { if !grouped { - d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel }); + let mut label = spec.label.trim_end_matches('…').to_string(); + if own_groups && let Some((_, len)) = start { + // A command whose only step is its group's keeps that name ("Run Macro: Totals"). + if d.undo.len() == len + 1 + && self.group_step.is_some_and(|(doc, n, epoch)| doc == uid && n == len + 1 && epoch != group_epoch) + && let Some(last) = d.undo.last() + { + label = last.label.clone(); + } + d.undo.truncate(len); + } + d.undo.push(HistoryEntry { label, wb, selection: sel }); if d.undo.len() > UNDO_LIMIT { d.undo.remove(0); } @@ -509,6 +529,7 @@ impl Session { while d.undo.len() > UNDO_LIMIT { d.undo.remove(0); } + self.group_step = Some((g.doc, d.undo.len(), self.group_epoch)); d.redo.clear(); d.revision += 1; } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index cc082258e..60f84dcf2 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -202,6 +202,50 @@ fn a_group_owns_the_steps_of_commands_that_run_commands() { assert_eq!(v(&s, "A1").display(), "kept"); } +fn put(s: &mut Session, cell: &str, text: &str) -> crate::Result<()> { + s.execute("cell.set", json!({"cell": cell, "input": text})).map(|_| ()) +} + +#[test] +fn a_command_that_runs_its_own_group_makes_one_step() { + use crate::cmd::register_undoable_test_command as register; + // Edits before its group are part of the step. + register("test.editThenGroup", |s, _| { + put(s, "A1", "outside")?; + s.grouped("Inner", |s| put(s, "A2", "inside"))?; + Ok(json!({})) + }); + // A group that fails and rolls back, then an edit outside it. + register("test.failThenEdit", |s, _| { + let failed: crate::Result<()> = s.grouped("Doomed", |s| { + put(s, "B1", "gone")?; + Err(EngineError::Other("no".into())) + }); + assert!(failed.is_err()); + put(s, "B2", "kept")?; + Ok(json!({})) + }); + // A command that is one group keeps the group's name. + register("test.onlyGroup", |s, _| s.grouped("Named Step", |s| put(s, "D1", "x")).map(|_| json!({}))); + let mut s = s(); + s.execute("test.onlyGroup", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["Named Step"]); + s.execute("edit.undo", json!({})).unwrap(); + set(&mut s, "C1", "base"); + s.execute("test.editThenGroup", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["Enter Cell", "test.editThenGroup"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A1").is_empty() && v(&s, "A2").is_empty()); + assert_eq!(v(&s, "C1").display(), "base"); + + s.execute("test.failThenEdit", json!({})).unwrap(); + assert!(v(&s, "B1").is_empty()); + assert_eq!(v(&s, "B2").display(), "kept"); + assert_eq!(undo_labels(&s), ["Enter Cell", "test.failThenEdit"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "B2").is_empty()); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { diff --git a/docs/architecture.md b/docs/architecture.md index 446cbe432..0f97f857b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,9 @@ Actions made of many commands (macros, a debugger that runs one step per call) u - **Undo groups.** `Session::grouped(label, f)` runs `f` as one undo step and rolls every change back if `f` fails; `group_begin`, `group_end` and `group_rollback` do the same across calls. While a group is open on a document, its commands add no undo steps of their own and Undo and - Redo are disabled there. Groups nest; the outermost one makes the step. + Redo are disabled there. Groups nest; the outermost one makes the step. A command that opens and + closes its own groups still makes one undo step for itself, covering its edits inside and outside + the groups. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, and the buffer is independent of the journal From ab27d353865fe29c5187f99e44c5c36b065ae5f3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:25:45 -0400 Subject: [PATCH 008/166] Engine: a full undo history keeps one step per command that runs its own undo groups When the history held 100 steps, group_end pushed its step and dropped the oldest one at once, so execute's collapse compared against a length that no longer matched. A command that was one group kept the group's step and added its own, losing two old steps. After test.editThenGroup, the second Undo brought back the intermediate state after the outside edit. The history limit now applies after the outermost command has collapsed its steps, and when a group ends outside any command. A document with an open group is trimmed when the group ends. --- crates/engine/src/lib.rs | 30 ++++++++-- crates/engine/src/tests/seams.rs | 96 +++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e12335e88..fc859e16a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -422,6 +422,9 @@ impl Session { d.selection = sel.clone(); d.calc.rebuild(&d.wb); } + if self.depth == 0 { + self.trim_history(); + } return Err(EngineError::Internal(id.to_string(), msg)); } }; @@ -463,13 +466,15 @@ impl Session { d.undo.truncate(len); } d.undo.push(HistoryEntry { label, wb, selection: sel }); - if d.undo.len() > UNDO_LIMIT { - d.undo.remove(0); - } } d.redo.clear(); d.revision += 1; } + // The history limit applies once the outermost command has collapsed its steps, so the + // lengths it compared stay true. + if self.depth == 0 { + self.trim_history(); + } result } @@ -526,16 +531,29 @@ impl Session { let changed = !Arc::ptr_eq(&d.wb, &g.wb); if changed && !outer { d.undo.push(HistoryEntry { label: g.label, wb: g.wb, selection: g.selection }); - while d.undo.len() > UNDO_LIMIT { - d.undo.remove(0); - } self.group_step = Some((g.doc, d.undo.len(), self.group_epoch)); d.redo.clear(); d.revision += 1; } + // Inside a command, the outermost command applies the history limit when it finishes. + if self.depth == 0 { + self.trim_history(); + } Ok(changed) } + /// Drops the oldest undo steps past [`UNDO_LIMIT`]. A document with an open group waits for + /// the group to end, because the group remembers where its steps start. + fn trim_history(&mut self) { + let groups = &self.groups; + for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) { + let excess = d.undo.len().saturating_sub(UNDO_LIMIT); + if excess > 0 { + d.undo.drain(..excess); + } + } + } + /// Ends an undo group by putting back the workbook, selection, undo and redo history as they /// were when it began. pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 60f84dcf2..e641fb0cd 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -206,8 +206,7 @@ fn put(s: &mut Session, cell: &str, text: &str) -> crate::Result<()> { s.execute("cell.set", json!({"cell": cell, "input": text})).map(|_| ()) } -#[test] -fn a_command_that_runs_its_own_group_makes_one_step() { +fn register_group_commands() { use crate::cmd::register_undoable_test_command as register; // Edits before its group are part of the step. register("test.editThenGroup", |s, _| { @@ -227,6 +226,18 @@ fn a_command_that_runs_its_own_group_makes_one_step() { }); // A command that is one group keeps the group's name. register("test.onlyGroup", |s, _| s.grouped("Named Step", |s| put(s, "D1", "x")).map(|_| json!({}))); + // An edit, then the end of the innermost group that was open before the command. + register("test.endOpenGroup", |s, _| { + put(s, "E1", "end")?; + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }); + s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?; + Ok(json!({})) + }); +} + +#[test] +fn a_command_that_runs_its_own_group_makes_one_step() { + register_group_commands(); let mut s = s(); s.execute("test.onlyGroup", json!({})).unwrap(); assert_eq!(undo_labels(&s), ["Named Step"]); @@ -246,6 +257,87 @@ fn a_command_that_runs_its_own_group_makes_one_step() { assert!(v(&s, "B2").is_empty()); } +/// Fills the undo history to the limit with edits of Z1 ("v0" to "v99"), and returns its snapshots. +fn fill_history(s: &mut Session) -> Vec> { + for i in 0..crate::UNDO_LIMIT { + set(s, "Z1", &format!("v{i}")); + } + let d = s.doc().unwrap(); + assert_eq!(d.undo.len(), crate::UNDO_LIMIT); + d.undo.iter().map(|e| e.wb.clone()).collect() +} + +/// The history is still full, ends with one step labelled `label`, and kept every older step but +/// the oldest. +fn assert_one_step_replaced_the_oldest(s: &Session, before: &[std::sync::Arc], label: &str) { + let d = s.doc().unwrap(); + assert_eq!(d.undo.len(), crate::UNDO_LIMIT); + assert_eq!(d.undo.last().map(|e| e.label.as_str()), Some(label)); + assert_eq!(d.undo.iter().filter(|e| e.label == label).count(), 1); + let kept = d.undo.iter().take(crate::UNDO_LIMIT - 1); + assert!(kept.zip(before.iter().skip(1)).all(|(e, wb)| std::sync::Arc::ptr_eq(&e.wb, wb))); +} + +/// Undo goes back to the state before the step, then to the edit before it; Redo comes back. +fn assert_undo_redo(s: &mut Session, cells: &[(&str, &str)]) { + let after = |s: &Session| cells.iter().all(|(c, t)| v(s, c).display() == *t); + let gone = |s: &Session| cells.iter().all(|(c, _)| v(s, c).is_empty()); + assert!(after(s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(gone(s) && v(s, "Z1").display() == "v99", "the first Undo restores the state before the command"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(gone(s) && v(s, "Z1").display() == "v98", "the second Undo restores the edit before it"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(gone(s) && v(s, "Z1").display() == "v99"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(after(s) && v(s, "Z1").display() == "v99"); +} + +#[test] +fn a_group_only_command_makes_one_step_when_the_history_is_full() { + register_group_commands(); + let mut s = s(); + let before = fill_history(&mut s); + s.execute("test.onlyGroup", json!({})).unwrap(); + assert_one_step_replaced_the_oldest(&s, &before, "Named Step"); + assert_undo_redo(&mut s, &[("D1", "x")]); +} + +#[test] +fn an_edit_then_a_group_makes_one_step_when_the_history_is_full() { + register_group_commands(); + let mut s = s(); + let before = fill_history(&mut s); + s.execute("test.editThenGroup", json!({})).unwrap(); + assert_one_step_replaced_the_oldest(&s, &before, "test.editThenGroup"); + assert_undo_redo(&mut s, &[("A1", "outside"), ("A2", "inside")]); +} + +#[test] +fn a_group_ended_at_the_history_limit_replaces_only_the_oldest_step() { + register_group_commands(); + // A command ends a group that was open before it. + let mut s = s(); + let before = fill_history(&mut s); + let _open = s.group_begin("Debug Macro").unwrap(); + set(&mut s, "E2", "step"); + assert_eq!(s.doc().unwrap().undo.len(), crate::UNDO_LIMIT); + s.execute("test.endOpenGroup", json!({})).unwrap(); + assert!(!s.in_group(s.doc().unwrap().uid)); + assert_one_step_replaced_the_oldest(&s, &before, "Debug Macro"); + assert_undo_redo(&mut s, &[("E1", "end"), ("E2", "step")]); + + // A group ended directly, outside any command, across calls. + let mut s = super::s(); + let before = fill_history(&mut s); + let token = s.group_begin("Spanning").unwrap(); + set(&mut s, "F1", "one"); + set(&mut s, "F2", "two"); + assert!(s.group_end(token).unwrap()); + assert_one_step_replaced_the_oldest(&s, &before, "Spanning"); + assert_undo_redo(&mut s, &[("F1", "one"), ("F2", "two")]); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { From 0457f102a04b53a3ca163918474e67ee129c0309 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:27:20 -0400 Subject: [PATCH 009/166] Engine: pasting only formats from a file leaves the destination's trust alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats, formulas or validation, so it carries nothing that runs. It still marked the destination FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook made that workbook ask before running its own active content. Formats pastes now count as passive, like values, column widths and notes. The comment and docs/architecture.md no longer say that pasted formats carry trust. --- crates/engine/src/cmd/edit.rs | 9 +++++---- crates/engine/src/tests/seams.rs | 3 +++ docs/architecture.md | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 018dd2991..179f35719 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1183,10 +1183,11 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; Ok(()) })?; - // Formulas, formats (conditional formats hold formulas) and validation from an untrusted file - // leave the destination untrusted; plain values, column widths and notes carry nothing active - // (a linked paste of values still writes formulas). - let passive = matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Values | What::ValuesAndNumberFormats)); + // Formulas and validation from an untrusted file leave the destination untrusted; plain values, + // formats (cell styles and merged areas, no conditional formats), column widths and notes + // carry nothing active (a linked paste of values still writes formulas). + let passive = + matches!(what, What::Formats | What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Values | What::ValuesAndNumberFormats)); if !same_doc && !passive { let d = s.doc_mut()?; d.trust = d.trust.receive(clip.trust); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index e641fb0cd..16b2617d7 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -472,6 +472,9 @@ fn pasting_from_a_file_carries_its_trust() { // Values carry nothing that runs. s.execute("edit.pasteSpecial", json!({"what": "values", "at": "B1"})).unwrap(); assert_eq!(trust(&s), Trust::Local); + // Formats carry cell styles and merged areas, nothing that runs. + s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "B3"})).unwrap(); + assert_eq!(trust(&s), Trust::Local); // A linked paste of values still writes formulas. s.execute("edit.pasteSpecial", json!({"what": "values", "link": true, "at": "B2"})).unwrap(); assert_eq!(trust(&s), Trust::FromFile); diff --git a/docs/architecture.md b/docs/architecture.md index 0f97f857b..d815bc81c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,8 +59,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u Commands run by other commands are not recorded, and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and - `FromFile` for workbooks opened from a file, and for workbooks that received formulas, formats - or validation pasted from one. Active content in a `FromFile` workbook asks before it runs. + `FromFile` for workbooks opened from a file, and for workbooks that received formulas or + validation pasted from one. Active content in a `FromFile` workbook asks before it runs. - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and 8,192-character limits, so it survives an xlsx round trip. The older single-name `_sc_` From eb3eaa052db54ae3723569b7dcef2a0b89107846 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:36:01 -0400 Subject: [PATCH 010/166] Engine: a command keeps its group's name only for a group it ended itself The record of the last group step (document, history length, epoch) outlived the Undo of that step. A later command that opened and closed its own groups at the same history length, after rolling one back, passed the epoch check (the record's epoch merely differed from the one the command started with) and took the name of the nested edit it had made instead of its own. The step must now come from a group ended during the command (an epoch past the command's starting one), and a rollback drops a record of a step it truncated away. --- crates/engine/src/lib.rs | 9 ++++++++- crates/engine/src/tests/seams.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc859e16a..1dc9196d4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -457,8 +457,11 @@ impl Session { let mut label = spec.label.trim_end_matches('…').to_string(); if own_groups && let Some((_, len)) = start { // A command whose only step is its group's keeps that name ("Run Macro: Totals"). + // The step must have been made by a group this command ended (its epoch is + // past the one the command started with): an older record at the same length + // belongs to an earlier command, undone since. if d.undo.len() == len + 1 - && self.group_step.is_some_and(|(doc, n, epoch)| doc == uid && n == len + 1 && epoch != group_epoch) + && self.group_step.is_some_and(|(doc, n, epoch)| doc == uid && n == len + 1 && epoch > group_epoch) && let Some(last) = d.undo.last() { label = last.label.clone(); @@ -567,6 +570,10 @@ impl Session { d.undo.truncate(g.start); d.redo = g.redo; d.revision += 1; + // A step the rollback dropped is no longer anyone's to name. + if self.group_step.is_some_and(|(doc, n, _)| doc == g.doc && n > g.start) { + self.group_step = None; + } Ok(()) } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 16b2617d7..e1aaaba67 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -257,6 +257,27 @@ fn a_command_that_runs_its_own_group_makes_one_step() { assert!(v(&s, "B2").is_empty()); } +#[test] +fn a_stale_group_step_does_not_name_a_later_command() { + // The record of the last group step (document, length, epoch) survives the Undo of that step. + // A later command that ends a group of its own at the same history length, after rolling + // another back, must not take the name of the nested edit it made. + register_group_commands(); + let mut s = s(); + s.execute("test.onlyGroup", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["Named Step"]); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("test.failThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["test.failThenEdit"]); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("test.failThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["test.failThenEdit"]); + assert!(v(&s, "B1").is_empty()); + assert_eq!(v(&s, "B2").display(), "kept"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "B2").is_empty() && v(&s, "D1").is_empty()); +} + /// Fills the undo history to the limit with edits of Z1 ("v0" to "v99"), and returns its snapshots. fn fill_history(s: &mut Session) -> Vec> { for i in 0..crate::UNDO_LIMIT { From 2287f63a95139db4cb4d0d42e8c238e6f34319a5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:00:54 -0400 Subject: [PATCH 011/166] Engine: a group rolled back inside a command keeps the redo history A command that changed the workbook cleared the redo history even when an open group owned its step. When the group was then rolled back inside the same command (the debugger's Stop with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared it again. Now only a command that makes a step of its own clears the redo history; a group clears it when it ends as a step, and a rollback restores it. --- crates/engine/src/lib.rs | 4 +++- crates/engine/src/tests/seams.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1dc9196d4..80bcfd5ae 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -469,8 +469,10 @@ impl Session { d.undo.truncate(len); } d.undo.push(HistoryEntry { label, wb, selection: sel }); + d.redo.clear(); } - d.redo.clear(); + // Inside a group the redo history waits for the group: `group_end` clears it when the + // group becomes a step, and `group_rollback` puts it back as it was. d.revision += 1; } // The history limit applies once the outermost command has collapsed its steps, so the diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index e1aaaba67..b3397c724 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -226,6 +226,13 @@ fn register_group_commands() { }); // A command that is one group keeps the group's name. register("test.onlyGroup", |s, _| s.grouped("Named Step", |s| put(s, "D1", "x")).map(|_| json!({}))); + // An edit, then the rollback of the innermost group that was open before the command. + register("test.rollbackOpenGroup", |s, _| { + put(s, "E1", "gone")?; + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }); + s.group_rollback(token.ok_or_else(|| EngineError::Other("no group".into()))?)?; + Ok(json!({})) + }); // An edit, then the end of the innermost group that was open before the command. register("test.endOpenGroup", |s, _| { put(s, "E1", "end")?; @@ -278,6 +285,25 @@ fn a_stale_group_step_does_not_name_a_later_command() { assert!(v(&s, "B2").is_empty() && v(&s, "D1").is_empty()); } +#[test] +fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() { + // The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback + // put the redo history back, and the command's own bookkeeping then cleared it again. + register_group_commands(); + let mut s = s(); + set(&mut s, "A1", "first"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().redo.len(), 1); + let token = s.group_begin("Debug").unwrap(); + set(&mut s, "B1", "inside"); + s.execute("test.rollbackOpenGroup", json!({})).unwrap(); + assert!(!s.in_group(token.doc)); + assert!(v(&s, "B1").is_empty() && v(&s, "E1").is_empty()); + assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history survives the rolled-back group"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "first"); +} + /// Fills the undo history to the limit with edits of Z1 ("v0" to "v99"), and returns its snapshots. fn fill_history(s: &mut Session) -> Vec> { for i in 0..crate::UNDO_LIMIT { From 471114e29663ac231bd0e4f0bc63c7bd07d59d5b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:14:55 -0400 Subject: [PATCH 012/166] Engine: a visible name of the item shape is not workbook data The item store read, listed and removed every workbook-scope name of the `_gc__` shape, visible or not, so saving an item removed a person's own visible name that happened to look like one. Items are the hidden names the store writes; a legacy `_sc_` name still reads. A visible `_gc_` name is left alone by every operation, and writing an item that would need that name is refused before anything changes: a hidden name beside it with the same name is the one an xlsx file drops, so the item would be lost on save. --- crates/engine/src/cmd/stash.rs | 54 ++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index fd8612ad0..9043262d2 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -30,8 +30,8 @@ pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; pub fn get(wb: &Workbook, key: &str) -> Option { let mut chunks: BTreeMap = BTreeMap::new(); let mut legacy = None; - for n in wb.names.iter().filter(|n| n.scope.is_none()) { - match parse_name(&n.name) { + for n in wb.names.iter() { + match item_of(n) { Some((k, Some(i))) if k.eq_ignore_ascii_case(key) => { chunks.entry(i).or_insert(&n.formula); } @@ -63,8 +63,19 @@ pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { if text.chars().count() > MAX_ITEM_CHARS { return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024)))); } + let formulas = chunk_formulas(text); + // A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside + // it with the same name would be the one an xlsx file drops. Refuse before changing anything. + for i in 0..formulas.len() { + let name = format!("{PREFIX}{key}_{i}"); + if wb.names.iter().any(|n| n.scope.is_none() && !n.hidden && n.name.eq_ignore_ascii_case(&name)) { + return Err(EngineError::Other(format!( + "The workbook has a defined name '{name}', which GridCraft needs for its own data. Rename it first." + ))); + } + } remove(wb, key); - for (i, formula) in chunk_formulas(text).into_iter().enumerate() { + for (i, formula) in formulas.into_iter().enumerate() { wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); } Ok(()) @@ -72,7 +83,7 @@ pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { /// Removes the item stored under `key`. pub fn remove(wb: &mut Workbook, key: &str) { - wb.names.retain(|n| n.scope.is_some() || !parse_name(&n.name).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key))); + wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key))); } /// The item under `key` as JSON (`None` when missing or not JSON). @@ -89,8 +100,7 @@ pub fn keys(wb: &Workbook, prefix: &str) -> Vec { let mut keys: Vec = wb .names .iter() - .filter(|n| n.scope.is_none()) - .filter_map(|n| parse_name(&n.name).map(|(k, _)| k.to_ascii_lowercase())) + .filter_map(|n| item_of(n).map(|(k, _)| k.to_ascii_lowercase())) .filter(|k| k.starts_with(&prefix.to_ascii_lowercase())) .collect(); keys.sort(); @@ -103,6 +113,16 @@ pub fn item_key(name: &str) -> Option { parse_name(name).map(|(k, _)| k.to_ascii_lowercase()) } +/// The item a name holds a part of: a hidden workbook-scope `_gc__` name, or a legacy +/// `_sc_` one. A visible name of the `_gc_` shape is someone's own name, not an item: it is +/// neither read, nor listed, nor removed on write. +fn item_of(n: &DefinedName) -> Option<(&str, Option)> { + if n.scope.is_some() { + return None; + } + parse_name(&n.name).filter(|(_, chunk)| chunk.is_none() || n.hidden) +} + /// `_gc__` → (`key`, `Some(k)`); `_sc_` → (`key`, `None`). fn parse_name(name: &str) -> Option<(&str, Option)> { if let Some(rest) = strip_prefix_ci(name, PREFIX) { @@ -313,6 +333,28 @@ mod tests { assert_eq!(get(&wb, "huge"), None); } + #[test] + fn a_visible_name_of_the_item_shape_is_not_an_item() { + // Someone's own visible `_gc_watch_0` is not storage: it is not read or listed, and + // writing or removing the item leaves it alone. + let mut wb = Workbook::new(); + let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") }; + wb.names.push(own.clone()); + assert_eq!(get(&wb, "watch"), None); + assert!(keys(&wb, "").is_empty()); + // Writing the item would put a hidden name of the same name beside it, and an xlsx file + // keeps only one of them: refused, with the names as they were. + let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string(); + assert!(err.contains("_gc_watch_0"), "{err}"); + assert_eq!(wb.names, vec![own.clone()]); + assert_eq!(get(&wb, "watch"), None); + set(&mut wb, "other", "theirs").unwrap(); + assert_eq!(get(&wb, "other").as_deref(), Some("theirs")); + remove(&mut wb, "watch"); + remove(&mut wb, "other"); + assert_eq!(wb.names, vec![own]); + } + #[test] fn keys_list_items_by_prefix() { let mut wb = Workbook::new(); From a4b4872602fae4a08530c1b875ad7e2984548f15 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:09:12 -0400 Subject: [PATCH 013/166] Model: macros as typed command lists, saved in the workbook A macro is plain data in the model (model/src/macros.rs): a name, a description, an optional shortcut, declared inputs (shape, header, column types, non-empty, default reference), steps, an output rule (anchor and what to do on a conflict) and the commands it may run that read or write files. A step runs a command (id and params) or is one of three bounded control steps: Repeat over the rows or columns of a range, If on one cell's value, and Stop with a message. Every step carries enabled, breakpoint and note flags. These are GridCraft macros, not VBA; a VBA project in a file stays preserved and is never run. Workbook::macros holds them. The JSON format keeps the field. In xlsx, io::save_bytes writes each macro as one chunked hidden-name item, _gc_macro_, and io::open_bytes takes the items back out of the names, so the names never linger in the open document. Limits are checked whenever a macro is read from a file or a command: at most 1,000 macros, 10,000 steps each, Repeat and If nested 4 deep, names and notes of bounded length, and at most 4,194,304 characters of JSON, so that saving a workbook never fails because of a macro; nesting is checked before the steps are deserialized. What is over or damaged is dropped with one warning, and clashing names get a suffix. Automate scripts keep their own storage (_sc_scripts) and are neither read nor changed by this. --- crates/engine/src/io.rs | 15 +- crates/engine/src/lib.rs | 1 + crates/engine/src/macros/mod.rs | 166 ++++++++++++++ crates/engine/src/macros/tests.rs | 195 +++++++++++++++++ crates/model/Cargo.toml | 4 +- crates/model/src/lib.rs | 2 + crates/model/src/macros.rs | 351 ++++++++++++++++++++++++++++++ crates/model/src/workbook.rs | 5 + 8 files changed, 734 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/macros/mod.rs create mode 100644 crates/engine/src/macros/tests.rs create mode 100644 crates/model/src/macros.rs diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 14717a3df..4d19205c0 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -118,8 +118,14 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String { } } -/// Parses bytes that aren't encrypted. +/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names. fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { + let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?; + warnings.extend(crate::macros::load(&mut wb)); + Ok((wb, warnings)) +} + +fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); if sniffed == gridcraft_xlsx::Format::Xls { @@ -207,7 +213,12 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())), + // Macros travel in hidden names. + FileKind::Xlsx => { + let named = crate::macros::with_names(wb)?; + gridcraft_xlsx::write_xlsx_as(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path)) + .map_err(|e| EngineError::Other(e.to_string())) + } FileKind::Xls => { Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into())) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 80bcfd5ae..7f63001d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod display; pub mod fill; pub mod io; pub mod links; +pub mod macros; pub mod outline; pub mod paper; pub mod pivot; diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs new file mode 100644 index 000000000..141323650 --- /dev/null +++ b/crates/engine/src/macros/mod.rs @@ -0,0 +1,166 @@ +//! GridCraft macros: typed, declarative command lists, not VBA. The model is plain data in +//! `gridcraft_model::Macro`; this module reads and writes it and keeps it within its limits. +//! +//! Storage: the JSON format keeps `Workbook::macros` as a field. In xlsx each macro is one item of +//! GridCraft's hidden workbook data ([`crate::cmd::stash`]), `_gc_macro__`, written when the +//! file is saved and taken back out of the names when it is opened, so the names never linger in +//! the open document. Scripts made with Automate › New Script or Record Actions are a separate, +//! older feature with their own storage; macros neither read nor change them. + +use gridcraft_model::{Macro, Workbook}; +use serde_json::{Value as Json, json}; + +use crate::cmd::stash; + +/// The hidden-data key prefix of a stored macro (`macro_`). +const MACRO_KEY: &str = "macro_"; + +/// Reads a macro from JSON, as `macro.save` and files give it. A command step may also use the +/// short form `{"command": "cell.set", "params": {…}}`. +pub fn parse_macro(v: &Json) -> Result { + let mut v = v.clone(); + if let Some(steps) = v.get_mut("steps") { + normalize_steps(steps, 1)?; + } + if v.get("v").is_none() + && let Some(o) = v.as_object_mut() + { + o.insert("v".into(), json!(gridcraft_model::MACRO_VERSION)); + } + let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?; + m.name = m.name.trim().to_string(); + m.check_limits()?; + if m.name.chars().any(char::is_control) { + return Err("A macro name can't hold line breaks or other control characters.".into()); + } + check_size(&m)?; + Ok(m) +} + +/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of +/// JSON), so saving a workbook never fails because of one of its macros. +fn check_size(m: &Macro) -> Result<(), String> { + if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS { + return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS)); + } + Ok(()) +} + +/// Rewrites the short step form into the tagged one, in place, refusing nesting past the limit +/// before the steps are deserialized. +fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> { + let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) }; + if level > gridcraft_model::MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH)); + } + for st in list { + let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) }; + if !o.contains_key("type") + && let Some(id) = o.remove("command") + { + o.insert("type".into(), json!("command")); + o.insert("id".into(), id); + } + for key in ["body", "then", "else"] { + if let Some(body) = o.get_mut(key) { + normalize_steps(body, level + 1)?; + } + } + } + Ok(()) +} + +/// A macro as JSON (the shape `macro.get` returns and `macro.save` takes). +pub fn macro_json(m: &Macro) -> Json { + serde_json::to_value(m).unwrap_or(Json::Null) +} + +/// The index of the macro called `name` (case-insensitive). +pub fn find(wb: &Workbook, name: &str) -> Option { + let name = name.trim(); + wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name)) +} + +/// 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. +pub fn with_names(wb: &Workbook) -> crate::Result> { + let stale = !stash::keys(wb, MACRO_KEY).is_empty(); + if wb.macros.is_empty() && !stale { + return Ok(None); + } + let mut out = wb.clone(); + for key in stash::keys(&out, MACRO_KEY) { + stash::remove(&mut out, &key); + } + for (i, m) in wb.macros.iter().enumerate() { + stash::set(&mut out, &format!("{MACRO_KEY}{i}"), ¯o_json(m).to_string())?; + } + out.macros.clear(); + Ok(Some(out)) +} + +/// After a file is opened: moves macros out of hidden names into `Workbook::macros` and drops +/// macros that are damaged or over the limits. Returns a warning for what was dropped. +pub fn load(wb: &mut Workbook) -> Vec { + let mut warnings = Vec::new(); + let mut bad = 0usize; + // Stored macros, in their saved order. + let mut keyed: Vec<(u64, String)> = + stash::keys(wb, MACRO_KEY).into_iter().filter_map(|k| k.strip_prefix(MACRO_KEY).and_then(|n| n.parse().ok()).map(|n| (n, k))).collect(); + keyed.sort(); + let mut found = Vec::new(); + for (_, key) in &keyed { + // Past the macro limit, the rest are counted without being read. + if found.len() >= gridcraft_model::MAX_MACROS { + bad += 1; + continue; + } + match stash::get_json(wb, key).ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) { + Ok(m) => found.push(m), + Err(_) => bad += 1, + } + } + for key in stash::keys(wb, MACRO_KEY) { + stash::remove(wb, &key); + } + // Macros that came as a field (the JSON format) go through the same checks. + let mut all = std::mem::take(&mut wb.macros); + all.extend(found); + for m in all { + if wb.macros.len() >= gridcraft_model::MAX_MACROS { + bad += 1; + continue; + } + match m.check_limits().and_then(|()| check_size(&m)) { + Ok(()) => { + let mut m = m; + m.name = unique_name(wb, m.name.trim()); + wb.macros.push(m); + } + Err(_) => bad += 1, + } + } + if bad > 0 { + warnings.push(format!( + "{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).", + if bad == 1 { "" } else { "s" }, + if bad == 1 { "it is" } else { "they are" }, + gridcraft_model::MAX_MACROS, + gridcraft_model::MAX_MACRO_STEPS, + gridcraft_model::MAX_MACRO_DEPTH + )); + } + warnings +} + +/// `name`, or `name (2)`, `name (3)`… if a macro already has it. +pub fn unique_name(wb: &Workbook, name: &str) -> String { + let name = if name.is_empty() { "Macro" } else { name }; + if find(wb, name).is_none() { + return name.to_string(); + } + (2..).map(|n| format!("{name} ({n})")).find(|n| find(wb, n).is_none()).unwrap_or_else(|| name.to_string()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs new file mode 100644 index 000000000..1b417c8a0 --- /dev/null +++ b/crates/engine/src/macros/tests.rs @@ -0,0 +1,195 @@ +use gridcraft_model::*; +use serde_json::json; + +use super::*; + +fn sample_macro(name: &str) -> Macro { + parse_macro(&json!({ + "name": name, + "description": "Totals the amounts", + "shortcut": "Ctrl+Shift+T", + "inputs": [{"name": "Input", "rows": [1, 500], "cols": [2, 2], "header": true, "colTypes": ["text", "number"], "nonEmpty": true}], + "steps": [ + {"type": "command", "id": "cell.set", "params": {"cell": "Output", "input": "=SUM({{Input.col(2)}})"}, "breakpoint": true, "note": "the total"}, + {"type": "repeat", "over": "Input.rows(2..)", "of": "rows", "max": 100, "body": [ + {"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, "then": [ + {"command": "home.bold", "params": {"range": "Row"}} + ], "else": [{"type": "stop", "message": "done"}]} + ]}, + {"type": "command", "id": "home.italic", "enabled": false} + ], + "output": {"anchor": "Input.offset(0,3)", "onConflict": "shiftDown"}, + "permissions": ["data.getData"] + })) + .unwrap() +} + +#[test] +fn macros_read_from_json_in_both_step_forms() { + let m = sample_macro("Totals"); + assert_eq!(m.v, MACRO_VERSION); + assert_eq!(m.inputs[0].col_types, [ColType::Text, ColType::Number]); + assert_eq!(m.output.on_conflict, OnConflict::ShiftDown); + assert_eq!(m.step_count(), 6); + assert!(m.steps[0].breakpoint && m.steps[0].enabled && !m.steps[2].enabled); + let StepKind::Repeat { body, max, .. } = &m.steps[1].kind else { panic!("{:?}", m.steps[1]) }; + assert_eq!(*max, 100); + let StepKind::If { then, otherwise, op, .. } = &body[0].kind else { panic!() }; + assert_eq!(*op, CompareOp::Gt); + // The short form of earlier scripts reads as a command step. + assert_eq!(then[0].kind, StepKind::Command { id: "home.bold".into(), params: json!({"range": "Row"}) }); + assert!(matches!(&otherwise[0].kind, StepKind::Stop { message, rollback: false } if message == "done")); + // Writing and reading again gives the same macro; defaults stay out of the JSON. + let v = macro_json(&m); + assert_eq!(v["steps"][0]["type"], "command"); + assert!(v["steps"][1].get("enabled").is_none() && v["steps"][1].get("breakpoint").is_none()); + assert_eq!(v["steps"][2]["enabled"], false); + assert_eq!(parse_macro(&v).unwrap(), m); + // A minimal macro gets the defaults. + let m = parse_macro(&json!({"name": " Bare "})).unwrap(); + assert_eq!((m.name.as_str(), m.output.on_conflict, m.inputs.len()), ("Bare", OnConflict::Ask, 0)); +} + +#[test] +fn macros_over_the_limits_are_refused() { + let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]}); + assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok()); + assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps")); + // Nested Repeat steps: 4 levels are fine, 5 are not. + let nest = |levels: usize| { + let mut step = json!({"command": "cell.set"}); + for _ in 1..levels { + step = json!({"type": "repeat", "over": "Input", "body": [step]}); + } + json!({"name": "Deep", "steps": [step]}) + }; + assert!(parse_macro(&nest(4)).is_ok()); + assert!(parse_macro(&nest(5)).unwrap_err().contains("4 deep")); + // Far deeper than serde would allow is refused before it is read. + assert!(parse_macro(&nest(200)).is_err()); + for bad in [ + json!({"name": ""}), + json!({"name": "x".repeat(256)}), + json!({"name": "a\nb"}), + json!({"name": "A", "steps": {}}), + json!({"name": "A", "steps": [1]}), + json!({"name": "A", "steps": [{"type": "nope"}]}), + json!({"name": "A", "steps": [{"type": "if", "cell": "A1", "op": "between"}]}), + json!({"name": "A", "inputs": [{"name": ""}]}), + json!({"name": "A", "inputs": [{"name": "Has space"}]}), + json!({"name": "A", "inputs": [{"name": "row"}]}), + json!({"name": "A", "inputs": [{"name": "In"}, {"name": "IN"}]}), + json!({"name": "A", "output": {"onConflict": "explode"}}), + json!([]), + ] { + assert!(parse_macro(&bad).is_err(), "{bad}"); + } +} + +fn names_of(wb: &Workbook) -> Vec { + wb.names.iter().map(|n| n.name.clone()).collect() +} + +#[test] +fn macros_round_trip_through_xlsx_and_json() { + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("Totals")); + // Large enough to need several hidden names. + let long: Vec = (0..300).map(|r| Step::command("cell.set", json!({"cell": format!("A{r}"), "input": "x\"y".repeat(10)}))).collect(); + wb.macros.push(Macro { name: "Long".into(), steps: long, ..Macro::default() }); + for file in ["book.xlsx", "book.json"] { + let bytes = crate::io::save_bytes(&wb, file).unwrap(); + let (back, warnings) = crate::io::open_bytes(file, &bytes).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(back.macros, wb.macros, "{file}"); + assert!(back.names.is_empty(), "{file}: macros stay out of the names once opened: {:?}", names_of(&back)); + } + // In the xlsx file they are hidden names; the long one spans several. + let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap(); + let (raw, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap(); + let names = names_of(&raw); + assert!(names.contains(&"_gc_macro_0_0".to_string()), "{names:?}"); + assert!(names.iter().filter(|n| n.starts_with("_gc_macro_1_")).count() >= 2, "{names:?}"); + assert!(raw.names.iter().all(|n| n.hidden)); + // Saving without macros writes no macro names, even if stale ones linger. + let mut empty = raw.clone(); + empty.macros.clear(); + let (again, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&empty, "x.xlsx").unwrap()).unwrap(); + assert!(again.names.iter().all(|n| !n.name.starts_with("_gc_macro_"))); + // The JSON format keeps the field (and leaves it out when there are no macros). + let json = String::from_utf8(crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap(); + assert!(json.contains("\"macros\"")); + let plain = String::from_utf8(crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + assert!(!plain.contains("\"macros\"")); +} + +fn hidden(name: &str, text: &str) -> DefinedName { + DefinedName { name: name.into(), scope: None, formula: format!("\"{}\"", text.replace('"', "\"\"")), comment: String::new(), hidden: true } +} + +#[test] +fn scripts_and_vba_stay_as_they_are() { + // Automate scripts keep their own storage, and a VBA project is carried through unchanged. + let mut wb = Workbook::new(); + let scripts = json!({"Fill": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]}); + wb.names.push(hidden("_sc_scripts", &scripts.to_string())); + wb.vba_project = Some(b"\xd0\xcf\x11\xe0 synthetic vbaProject".to_vec()); + wb.macros.push(sample_macro("Totals")); + let bytes = crate::io::save_bytes(&wb, "book.xlsm").unwrap(); + let (back, warnings) = crate::io::open_bytes("book.xlsm", &bytes).unwrap(); + // The only warning is the reader's note that the VBA project is kept and not run. + assert!(warnings.iter().all(|w| w.contains("VBA project")), "{warnings:?}"); + assert_eq!(back.macros, wb.macros); + assert_eq!(names_of(&back), ["_sc_scripts"], "the scripts item is untouched"); + assert_eq!(back.names[0].formula, wb.names[0].formula); + assert_eq!(back.vba_project, wb.vba_project); +} + +#[test] +fn damaged_or_oversized_macros_are_dropped_with_a_warning() { + let mut wb = Workbook::new(); + wb.names.push(hidden("_gc_macro_0_0", &json!({"name": "Good"}).to_string())); + wb.names.push(hidden("_gc_macro_1_0", "{not json")); + wb.names.push(hidden("_gc_macro_2_0", &json!({"name": "Huge", "steps": vec![json!({"command": "x"}); MAX_MACRO_STEPS + 1]}).to_string())); + wb.names.push(hidden("_gc_macro_3_0", &json!({"name": "good"}).to_string())); + wb.names.push(hidden("_gc_macro_10_0", &json!({"name": "Tenth"}).to_string())); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("hostile.xlsx", &bytes).unwrap(); + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + // Saved order is by number (10 after 3); a clashing name gets a suffix. + assert_eq!(names, ["Good", "good (2)", "Tenth"]); + assert!(back.names.is_empty(), "{:?}", names_of(&back)); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].starts_with("2 macros"), "{warnings:?}"); + + // The JSON format: more than the limit, and one over the step limit. + let mut wb = Workbook::new(); + wb.macros = (0..MAX_MACROS + 5).map(|i| Macro { name: format!("M{i}"), ..Macro::default() }).collect(); + wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1]; + let bytes = crate::io::save_bytes(&wb, "many.json").unwrap(); + let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap(); + assert_eq!(back.macros.len(), MAX_MACROS); + assert_eq!(back.macros[0].name, "M1"); + assert!(warnings[0].starts_with("5 macros"), "{warnings:?}"); + + // A macro too large for the hidden names is refused, so a save never fails on it. + let huge = "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS); + let err = parse_macro(&json!({"name": "Huge", "steps": [{"command": "cell.set", "params": {"input": huge}}]})).unwrap_err(); + assert!(err.contains("too large"), "{err}"); + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: "Huge".into(), steps: vec![Step::command("cell.set", json!({"input": huge}))], ..Macro::default() }); + let bytes = crate::io::save_bytes(&wb, "huge.json").unwrap(); + let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap(); + assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}"); +} + +#[test] +fn names() { + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: "Macro1".into(), ..Macro::default() }); + assert_eq!(unique_name(&wb, "macro1"), "macro1 (2)"); + assert_eq!(unique_name(&wb, "Other"), "Other"); + assert_eq!(unique_name(&wb, ""), "Macro"); + assert_eq!(find(&wb, " MACRO1 "), Some(0)); + assert_eq!(find(&wb, "Macro2"), None); +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 761cf0093..d7d7fff4d 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -13,7 +13,5 @@ gridcraft-core = { workspace = true } sha2 = { workspace = true } gridcraft-formula = { workspace = true } serde = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index a76f30fc3..f304d5470 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ mod b64; pub mod cell; mod cellmap; pub mod features; +pub mod macros; pub mod password; pub mod preserved; pub mod sheet; @@ -17,6 +18,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; +pub use macros::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml}; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs new file mode 100644 index 000000000..fa898984d --- /dev/null +++ b/crates/model/src/macros.rs @@ -0,0 +1,351 @@ +//! GridCraft macros: typed, declarative command lists that run as one undo step. They are not VBA: +//! a VBA project in a file is carried through unchanged (`Workbook::vba_project`) and never run. + +use serde::{Deserialize, Serialize}; + +/// The macro format version written by this build. +pub const MACRO_VERSION: u32 = 1; +/// The most macros a workbook keeps. +pub const MAX_MACROS: usize = 1_000; +/// The most steps in one macro, counting the steps inside Repeat and If. +pub const MAX_MACRO_STEPS: usize = 10_000; +/// How deeply Repeat and If may nest (a top-level step is at depth 1). +pub const MAX_MACRO_DEPTH: usize = 4; +/// The longest macro or input name, in characters. +pub const MAX_MACRO_NAME: usize = 255; +/// The longest macro description or step note, in characters. +pub const MAX_MACRO_TEXT: usize = 4_096; + +/// A recorded or hand-made macro: a list of typed steps that runs as one undo step. Most steps run +/// a GridCraft command; Repeat, If and Stop add bounded control flow. References in step +/// parameters can be tokens (`"Input.col(2)"`) that are resolved against the declared inputs when +/// the macro runs, so a macro recorded on one block of data works on another. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Macro { + /// Format version ([`MACRO_VERSION`]). + pub v: u32, + pub name: String, + pub description: String, + /// Keyboard shortcut (`Ctrl+Shift+M`) for a user interface to offer; the engine only stores it. + #[serde(skip_serializing_if = "Option::is_none")] + pub shortcut: Option, + /// The ranges the macro works on, checked before it runs. + pub inputs: Vec, + pub steps: Vec, + /// Where results go and what to do when they would land on existing data. + pub output: OutputRule, + /// Commands that need permission (reading or writing files) which the macro may run. + pub permissions: Vec, +} + +impl Default for Macro { + fn default() -> Self { + Macro { + v: MACRO_VERSION, + name: String::new(), + description: String::new(), + shortcut: None, + inputs: vec![], + steps: vec![], + output: OutputRule::default(), + permissions: vec![], + } + } +} + +impl Macro { + /// Steps in the macro, counting those inside Repeat and If. + pub fn step_count(&self) -> usize { + fn count(steps: &[Step]) -> usize { + steps.iter().map(|s| 1usize.saturating_add(s.kind.children().map(|b| count(b)).sum::())).sum() + } + count(&self.steps) + } + + /// Checks the size limits ([`MAX_MACRO_STEPS`], [`MAX_MACRO_DEPTH`], name and text lengths). + pub fn check_limits(&self) -> Result<(), String> { + let name = self.name.trim(); + if name.is_empty() { + return Err("A macro needs a name.".into()); + } + if name.chars().count() > MAX_MACRO_NAME { + return Err(format!("A macro name can be at most {MAX_MACRO_NAME} characters long.")); + } + if self.description.chars().count() > MAX_MACRO_TEXT { + return Err(format!("A macro description can be at most {MAX_MACRO_TEXT} characters long.")); + } + // Count without recursing past the depth limit (a hostile file can nest deeper). + fn walk(steps: &[Step], level: usize, count: &mut usize) -> Result<(), String> { + if level > MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); + } + for s in steps { + *count = count.saturating_add(1); + if *count > MAX_MACRO_STEPS { + return Err(format!("A macro can have at most {MAX_MACRO_STEPS} steps.")); + } + if s.note.chars().count() > MAX_MACRO_TEXT { + return Err(format!("A step note can be at most {MAX_MACRO_TEXT} characters long.")); + } + for body in s.kind.children() { + walk(body, level + 1, count)?; + } + } + Ok(()) + } + walk(&self.steps, 1, &mut 0)?; + if self.inputs.len() > 64 { + return Err("A macro can declare at most 64 inputs.".into()); + } + for (k, i) in self.inputs.iter().enumerate() { + let n = i.name.as_str(); + let ident = n.chars().next().is_some_and(char::is_alphabetic) && n.chars().all(|c| c.is_alphanumeric() || c == '_'); + if !ident || n.chars().count() > MAX_MACRO_NAME { + return Err(format!("'{n}' can't name an input: use letters, digits and _, starting with a letter.")); + } + if ["Output", "Row", "Column", "Name"].iter().any(|r| r.eq_ignore_ascii_case(n)) { + return Err(format!("'{n}' is a token of its own and can't name an input.")); + } + if self.inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) { + return Err(format!("Two inputs are called '{n}'.")); + } + } + Ok(()) + } +} + +/// A range the macro works on, with the shape and content it expects. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct NamedInput { + /// The token that stands for it in step parameters (`Input`, `Rates`). + pub name: String, + pub kind: InputKind, + /// Allowed data rows (header excluded), `[min, max]`; a max of 0 means no limit. + pub rows: [u32; 2], + /// Allowed columns, `[min, max]`; a max of 0 means no limit. + pub cols: [u32; 2], + /// The first row holds column names. + pub header: bool, + /// What each column must hold, first column first; columns past the list may hold anything. + pub col_types: Vec, + /// Every data cell must have something in it. + pub non_empty: bool, + /// The range used when a run doesn't name one (else the current selection, for the first input). + #[serde(skip_serializing_if = "Option::is_none")] + pub default_ref: Option, +} + +impl Default for NamedInput { + fn default() -> Self { + NamedInput { + name: "Input".into(), + kind: InputKind::Range, + rows: [1, 0], + cols: [1, 0], + header: false, + col_types: vec![], + non_empty: false, + default_ref: None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InputKind { + /// A single cell. + Cell, + #[default] + Range, +} + +/// What a column of an input must hold. Empty cells pass unless the input is `non_empty`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColType { + Number, + Text, + /// A number shown with a date or time format. + Date, + Bool, + #[default] + Any, +} + +impl ColType { + /// The plural noun used in messages ("must hold numbers"). + pub fn noun(self) -> &'static str { + match self { + ColType::Number => "numbers", + ColType::Text => "text", + ColType::Date => "dates", + ColType::Bool => "TRUE or FALSE", + ColType::Any => "anything", + } + } +} + +/// One macro step and its editor flags. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Step { + #[serde(flatten)] + pub kind: StepKind, + /// A disabled step is skipped. + #[serde(default = "yes", skip_serializing_if = "is_true")] + pub enabled: bool, + /// The debugger stops before this step. + #[serde(default, skip_serializing_if = "is_false")] + pub breakpoint: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub note: String, +} + +fn yes() -> bool { + true +} +fn is_true(b: &bool) -> bool { + *b +} +fn is_false(b: &bool) -> bool { + !*b +} + +impl Step { + pub fn new(kind: StepKind) -> Step { + Step { kind, enabled: true, breakpoint: false, note: String::new() } + } + + /// A step that runs command `id` with `params`. + pub fn command(id: impl Into, params: serde_json::Value) -> Step { + Step::new(StepKind::Command { id: id.into(), params }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum StepKind { + /// Runs a GridCraft command. String parameters may hold tokens, resolved when the step runs. + Command { + id: String, + #[serde(default)] + params: serde_json::Value, + }, + /// Runs `body` once for each row (or column) of the range `over`, a token or reference; inside + /// it, `Row` (or `Column`) stands for the current one. At most `max` times. + Repeat { + over: String, + #[serde(default)] + of: RepeatOf, + #[serde(default = "repeat_max")] + max: u32, + #[serde(default)] + body: Vec, + }, + /// Compares the value of `cell` (a token or reference to one cell) with `value` and runs + /// `then` or `else`. + If { + cell: String, + op: CompareOp, + #[serde(default)] + value: serde_json::Value, + #[serde(default)] + then: Vec, + #[serde(default, rename = "else")] + otherwise: Vec, + }, + /// Ends the run with a message. The changes made so far are kept, or undone with `rollback`. + Stop { + #[serde(default)] + message: String, + #[serde(default, skip_serializing_if = "is_false")] + rollback: bool, + }, +} + +fn repeat_max() -> u32 { + 10_000 +} + +impl StepKind { + /// The step lists inside this step (Repeat's body, If's branches). + pub fn children(&self) -> impl Iterator> { + let (a, b) = match self { + StepKind::Repeat { body, .. } => (Some(body), None), + StepKind::If { then, otherwise, .. } => (Some(then), Some(otherwise)), + _ => (None, None), + }; + a.into_iter().chain(b) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RepeatOf { + #[default] + Rows, + Columns, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CompareOp { + Eq, + Ne, + Gt, + Ge, + Lt, + Le, + /// The cell is empty (`value` is ignored). + Empty, + NotEmpty, + /// The cell's text contains `value`'s text, ignoring case. + Contains, +} + +/// Where a macro's results go. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OutputRule { + /// What the `Output` token stands for: a token (`Input.offset(0,4)`), a reference or `Name:X`. + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor: Option, + /// What to do when the cells the macro writes, outside its inputs, already hold data. + pub on_conflict: OnConflict, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OnConflict { + /// Ask first: overwrite, shift the data down or right, or cancel. Programmatic runs fail. + #[default] + Ask, + Fail, + Overwrite, + ShiftDown, + ShiftRight, +} + +impl OnConflict { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().replace(['_', '-', ' '], "").as_str() { + "ask" => OnConflict::Ask, + "fail" | "cancel" => OnConflict::Fail, + "overwrite" => OnConflict::Overwrite, + "shiftdown" => OnConflict::ShiftDown, + "shiftright" => OnConflict::ShiftRight, + _ => return None, + }) + } + + pub fn name(self) -> &'static str { + match self { + OnConflict::Ask => "ask", + OnConflict::Fail => "fail", + OnConflict::Overwrite => "overwrite", + OnConflict::ShiftDown => "shiftDown", + OnConflict::ShiftRight => "shiftRight", + } + } +} diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 828ca692e..b90186c40 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -113,6 +113,10 @@ pub struct Workbook { /// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`. #[serde(default, skip_serializing_if = "Option::is_none")] pub csv_delimiter: Option, + /// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them. + /// The JSON format keeps them as this field; xlsx keeps them in hidden defined names. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub macros: Vec, /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. #[serde(skip)] pub preserved: Option>, @@ -146,6 +150,7 @@ impl Workbook { cell_styles: vec![], vba_project: None, csv_delimiter: None, + macros: vec![], preserved: None, source_dir: None, } From 54c08645b9335d3cc8d3cb9afbae455987b1beaf Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:15:12 -0400 Subject: [PATCH 014/166] Engine: macro.run and macro.check: input checks, footprint dry run, policy macro.run runs a macro as one undo step ("Run Macro: "); an error at any step rolls everything back and names the step and its path (3, 2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts: - Policy (macro_policy). File, app, window, automate and macro commands and Undo and Redo never run in a macro. Reading or writing files (data.getData, data.fromTextCsv, insert.picture, Page Layout Background, or an export with a path) needs the command in the macro's permissions and the person's approval. A macro in a workbook opened from a file asks before it runs. Approval holds for the session until the macro's content changes; it is asked for with the new EngineError::Approve and given with approve: true. A step that would open a dialog or a web page fails instead. - Inputs. Each declared input is bound (given, its default, or the selection for the first one), whole columns shrink to the used range, and the shape and column types are checked with messages that name the cells: "Input column 2 (Amount) must hold numbers; C4 holds text". - Footprint. Unless the macro may overwrite, it first runs in dry-run mode on the real workbook inside an undo group that is rolled back, with the session's clipboard, journal, windows and the document's trust put back. CellStore::diff on every sheet, plus the cells commands wrote (DocState::touched), sheets and objects, gives what it would write outside its inputs; cells that only recalculated do not count. Writing over data, deleting a sheet or deleting an object is a conflict: ask (the new EngineError::Confirm, so programmatic runs fail unless they pass onConflict or overwrite), fail, overwrite, or shift the data down or right with Insert Cells and check again. A step that would fail, for example past the edge of the sheet, fails the run before anything changes. File steps and Solver stop the dry run early and are reported. - Steps run on a machine with an explicit frame stack: Repeat over the rows or columns of a range with Row or Column bound, If on one cell with Excel's comparisons, and Stop, which keeps the changes or rolls them back. A run executes at most 100,000 steps within a time limit (60 s by default, timeLimit to change; the web build has no clock, so only the step limit applies there). macro.check reports the bound inputs, the output anchor, the approval needed, the footprint and the conflicts without changing anything; nothing from an unapproved macro runs, not even as a dry run. --- crates/engine/src/cmd/macros.rs | 43 ++ crates/engine/src/cmd/mod.rs | 12 + crates/engine/src/lib.rs | 12 + crates/engine/src/macros/mod.rs | 10 + crates/engine/src/macros/policy.rs | 122 +++ crates/engine/src/macros/run.rs | 1134 ++++++++++++++++++++++++++++ crates/engine/src/macros/tests.rs | 5 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/macros.rs | 436 +++++++++++ 9 files changed, 1775 insertions(+) create mode 100644 crates/engine/src/cmd/macros.rs create mode 100644 crates/engine/src/macros/policy.rs create mode 100644 crates/engine/src/macros/run.rs create mode 100644 crates/engine/src/tests/macros.rs diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs new file mode 100644 index 000000000..2aba053b0 --- /dev/null +++ b/crates/engine/src/cmd/macros.rs @@ -0,0 +1,43 @@ +//! 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, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", + has_doc, + run + ), + cmd!( + query "macro.check", + "Check Macro", + [], + None, + "{name, inputs?} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}", + has_doc, + check + ), + ] +} + +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) +} + +fn check(s: &mut Session, p: &Json) -> Result { + let name = name_param("macro.check", p)?; + crate::macros::run::check(s, name, p) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index fadb72b6c..6aa9cac58 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()); @@ -365,6 +367,13 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) if !structural { check_arrays(&d.wb, &wb)?; } + if let Some(log) = d.touched.as_mut() { + for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) { + if let Some(sh) = wb.sheet(*si) { + log.push((sh.name.clone(), *c)); + } + } + } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } @@ -392,6 +401,9 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) Ok(r) } +/// The most written cells a macro's footprint check records. +const TOUCH_LOG_LIMIT: usize = 1_000_000; + /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7f63001d2..d188161ee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -52,6 +52,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), } @@ -173,6 +179,9 @@ pub struct DocState { pub content_kind: Option, /// Whether the workbook's active content may run without asking. pub trust: Trust, + /// While a macro's footprint is measured: every cell a command wrote, by sheet name, even + /// where it wrote what was already there. + pub touched: Option>, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -199,6 +208,7 @@ impl DocState { unlocked_ranges: vec![], content_kind: None, trust: Trust::Local, + touched: None, } } pub fn is_dirty(&self) -> bool { @@ -312,6 +322,8 @@ 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, macro content hash). + pub macro_approvals: std::collections::HashSet<(u64, u64)>, /// 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 fee539201..55933c1ac 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_"; @@ -86,6 +88,14 @@ pub fn find(wb: &Workbook, name: &str) -> Option { wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name)) } +/// A stable fingerprint of a macro's content: approval to run a macro from a file holds until +/// the macro changes. +pub fn hash(m: &Macro) -> u64 { + // FNV-1a over the macro's JSON. + let text = macro_json(m).to_string(); + text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +} + /// 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. pub fn with_names(wb: &Workbook) -> crate::Result> { diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs new file mode 100644 index 000000000..7329f567f --- /dev/null +++ b/crates/engine/src/macros/policy.rs @@ -0,0 +1,122 @@ +//! What a macro step may run. Macros run commands, so this is the one place that keeps a macro +//! from saving, opening or closing files, driving the app, running other macros, or reading and +//! writing files without the person knowing. + +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")]; + +/// 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"]; + +/// The policy for running command `id` with `params` as a macro step in a document with `trust`. +pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy { + let has_path = params.get("path").and_then(Json::as_str).is_some_and(|p| !p.trim().is_empty()); + 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, + } +} + +/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops +/// before it and reports the footprint as partial. +pub fn previewable(id: &str) -> bool { + id != "data.solver" +} + +#[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", + ] { + 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": " "}), Trust::Local), Policy::Allow, "{id}"); + } + 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}"); + } + assert!(!previewable("data.solver") && previewable("cell.set")); + } + + #[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 000000000..3651deb0c --- /dev/null +++ b/crates/engine/src/macros/run.rs @@ -0,0 +1,1134 @@ +//! 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. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run +//! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it +//! would write outside its inputs that already hold data are conflicts, handled by the output +//! rule: ask, fail, overwrite, or shift the data down or right (and check again). +//! 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; +use std::sync::Arc; + +use gridcraft_core::{CellRef, RangeRef, Value}; +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, previewable}; +use super::resolve::{self, Env, Place, ResolveError}; +use crate::{EngineError, Result, Selection, 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 ranges a footprint lists 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, +} + +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 } + } + + 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 { + EngineError::Other(format!("Macro '{name}' stopped. {} Nothing was changed.", self.describe())) + } +} + +// ---------------------------------------------------------------- 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::hash(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)] +struct Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + start: std::time::Instant, +} + +impl Stopwatch { + 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, + /// Dry run (the footprint check): steps that would touch files or can't be previewed stop + /// the run early instead of running. + pub dry_run: bool, + /// A Stop step ended the run: its message, and whether it asked for a rollback. + pub stopped: Option<(String, bool)>, + /// A dry run stopped before this step (path, reason): what follows isn't in the footprint. + pub partial: Option<(String, String)>, + /// The last step's resolved parameters (for the debugger). + pub last_params: Option, +} + +impl Machine { + pub fn new(p: &Prepared, dry_run: bool) -> 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, + dry_run, + stopped: None, + partial: None, + last_params: 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> { + let Some((path, st)) = self.current().map(|(p, st)| (p, st.clone())) else { return Ok(()) }; + if let Some(f) = self.frames.last_mut() { + f.next += 1; + } + let r = self.exec(s, &path, &st); + if r.is_err() { + 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 }); + self.frames.push(Frame { steps: Arc::new(body.clone()), 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) { + self.frames.push(Frame { steps: Arc::new(branch.clone()), next: 0, prefix: format!("{path}.{name}"), repeat: None }); + } + Ok(()) + } + StepKind::Stop { message, rollback } => { + self.stopped = Some((message.clone(), *rollback)); + self.frames.clear(); + Ok(()) + } + } + } + + 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); + match macro_policy(id, params, self.trust) { + Policy::Deny(why) => return Err(fail(format!("{id} can't run in a macro: {why}."))), + 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."))); + } + if self.dry_run { + self.partial = Some((path.to_string(), format!("{id} {what}"))); + self.frames.clear(); + return Ok(()); + } + } + Policy::Allow | Policy::Ask(_) => {} + } + if self.dry_run && !previewable(id) { + self.partial = Some((path.to_string(), format!("{id} can't be previewed"))); + self.frames.clear(); + return Ok(()); + } + 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))? + }; + self.last_params = Some(resolved.clone()); + let queued = s.ui_requests.len(); + let r = s.execute(id, resolved); + // Macros never open dialogs or web pages; 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(_) => {} + m @ UiRequest::Message(_) => s.ui_requests.push(m), + } + } + r.map_err(|e| fail(e.to_string()))?; + if let Some(b) = blocked { + return Err(fail(b)); + } + 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)), + } +} + +// ---------------------------------------------------------------- footprint + +/// What a macro would change, from a dry run on the real workbook. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Footprint { + pub sheets: Vec, + pub sheets_added: Vec, + pub sheets_removed: Vec, + pub objects_added: usize, + pub objects_changed: usize, + pub objects_removed: usize, + /// The dry run stopped early before this step; later steps aren't in the footprint. + #[serde(skip_serializing_if = "Option::is_none")] + pub partial: Option, + /// The step the run would fail at. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// A Stop step's message. + #[serde(skip_serializing_if = "Option::is_none")] + pub stopped: Option, +} + +/// The cells a macro would write on one sheet, outside its inputs. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetFootprint { + pub sheet: String, + #[serde(skip)] + pub index: usize, + /// The written cells as ranges (at most 500 listed), and how many cells. + pub cells: Vec, + pub changed: usize, + /// Written cells that hold data now. + pub conflicts: Vec, + pub conflict_count: usize, + /// The block that holds every written cell. + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option, + #[serde(skip)] + pub bounds_range: Option, +} + +impl Footprint { + /// Cells that hold data and would be written, plus sheets and objects that would go. + pub fn conflict_count(&self) -> usize { + self.sheets.iter().map(|s| s.conflict_count).sum::() + self.sheets_removed.len() + self.objects_removed + } + + /// "C2:C9 on Sheet1, the sheet Old, 2 objects". + pub fn describe_conflicts(&self) -> String { + let mut parts: Vec = self + .sheets + .iter() + .filter(|s| s.conflict_count > 0) + .map(|s| { + let mut listed = s.conflicts.iter().take(3).cloned().collect::>().join(", "); + if s.conflicts.len() > 3 { + listed.push_str(", …"); + } + format!("{listed} on {}", s.sheet) + }) + .collect(); + parts.extend(self.sheets_removed.iter().map(|n| format!("the sheet {n}"))); + if self.objects_removed > 0 { + parts.push(format!("{} object{}", self.objects_removed, if self.objects_removed == 1 { "" } else { "s" })); + } + parts.join("; ") + } +} + +/// Session state a dry run may touch, put back afterwards. The workbook, its selection and its +/// history are put back by the dry run's undo group. +struct Saved { + active: usize, + docs: Vec, + sheet_selections: Vec, + unlocked_ranges: Vec<(String, String)>, + trust: Trust, + clipboard: Option, + prefs: crate::Prefs, + mode: crate::Mode, + journal: usize, + recording: Option, + ui_requests: usize, + format_painter: Option<(Arc, usize, RangeRef, bool)>, + last_find: Option, + hide_ruler: bool, + hidden_windows: Vec, + draw: (String, String, f32), + comments_pane: bool, + show_all_notes: bool, +} + +impl Saved { + fn take(s: &mut Session, doc: u64) -> Saved { + let d = s.docs.iter().find(|d| d.uid == doc); + Saved { + active: s.active, + docs: s.docs.iter().map(|d| d.uid).collect(), + sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), + unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), + trust: d.map_or(Trust::Local, |d| d.trust), + clipboard: s.clipboard.clone(), + prefs: s.prefs.clone(), + mode: s.mode, + journal: s.journal.len(), + recording: s.recording, + ui_requests: s.ui_requests.len(), + format_painter: s.format_painter.clone(), + last_find: s.last_find.clone(), + hide_ruler: s.hide_ruler, + hidden_windows: s.hidden_windows.clone(), + draw: (s.draw_tool.clone(), s.draw_color.clone(), s.draw_width), + comments_pane: s.comments_pane, + show_all_notes: s.show_all_notes, + } + } + + fn restore(self, s: &mut Session, doc: u64) { + s.docs.retain(|d| self.docs.contains(&d.uid)); + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { + d.sheet_selections = self.sheet_selections; + d.unlocked_ranges = self.unlocked_ranges; + d.trust = self.trust; + } + s.active = self.active.min(s.docs.len().saturating_sub(1)); + s.clipboard = self.clipboard; + s.prefs = self.prefs; + s.mode = self.mode; + s.journal.truncate(self.journal); + s.recording = self.recording; + s.ui_requests.truncate(self.ui_requests); + s.format_painter = self.format_painter; + s.last_find = self.last_find; + s.hide_ruler = self.hide_ruler; + s.hidden_windows = self.hidden_windows; + (s.draw_tool, s.draw_color, s.draw_width) = self.draw; + s.comments_pane = self.comments_pane; + s.show_all_notes = self.show_all_notes; + } +} + +/// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would +/// change. +pub fn footprint(s: &mut Session, p: &Prepared) -> Result { + let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; + s.set_active(i); + let saved = Saved::take(s, p.doc); + let before = s.doc()?.wb.clone(); + let token = s.group_begin("Check Macro")?; + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == p.doc) { + d.touched = Some(Vec::new()); + } + let mut m = Machine::new(p, true); + let outcome = m.run_to_end(s); + let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { + Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), + None => (before.clone(), Vec::new()), + }; + let rolled = s.group_rollback(token); + saved.restore(s, p.doc); + rolled?; + let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; + let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.error = outcome.err(); + fp.stopped = m.stopped.map(|(msg, _)| msg); + fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); + Ok(fp) +} + +/// Compares the workbook before and after a dry run. `touched` adds the cells commands wrote +/// without changing them (a formula written again), so a shift moves everything the macro writes. +fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { + let mut fp = Footprint::default(); + // Sheets are matched by name; what's left is matched in order (a renamed sheet). + 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(); + } + fp.sheets_added = spare.filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); + for (bi, ai) in pairs { + let Some(bs) = before.sheets.get(bi) else { continue }; + let Some(ai) = ai else { + fp.sheets_removed.push(bs.name.clone()); + continue; + }; + let Some(after_sheet) = after.sheets.get(ai) else { continue }; + let rewritten: BTreeSet = touched.iter().filter(|(n, _)| *n == after_sheet.name).map(|(_, c)| *c).collect(); + if Arc::ptr_eq(bs, after_sheet) && rewritten.is_empty() { + continue; + } + let (added, changed, removed) = objects(bs, after_sheet); + fp.objects_added += added; + fp.objects_changed += changed; + fp.objects_removed += removed; + let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == bi && p.range.contains(c)); + let mut written = Vec::new(); + let mut conflicts = Vec::new(); + let mut cells: BTreeSet = bs.cells.diff(&after_sheet.cells).into_iter().collect(); + cells.extend(rewritten.iter().copied()); + for c in cells { + if in_input(c) { + continue; + } + let (b, a) = (bs.cell(c), after_sheet.cell(c)); + if recalculated_only(b, a) && !rewritten.contains(&c) { + continue; + } + written.push(c); + if b.is_some_and(has_content) && content_differs(b, a) { + conflicts.push(c); + } + } + let Some(first) = written.first() else { continue }; + let bounds = written.iter().fold(RangeRef::cell(*first), |r, c| r.union(&RangeRef::cell(*c))); + fp.sheets.push(SheetFootprint { + sheet: bs.name.clone(), + index: bi, + changed: written.len(), + conflict_count: conflicts.len(), + cells: ranges(&mut written), + conflicts: ranges(&mut conflicts), + bounds: Some(bounds.a1()), + bounds_range: Some(bounds), + }); + } + fp +} + +/// 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, + } +} + +/// Objects added, changed and removed between two versions of a sheet. +fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { + fn count(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) { + let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count(); + let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count(); + let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); + (added, changed, removed) + } + let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)]; + parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2)) +} + +/// 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() +} + +/// Inserts blank cells where the footprint lands (moving what is there down or right), so the +/// macro writes into empty cells. +fn make_room(s: &mut Session, fp: &Footprint, down: bool) -> Result<()> { + if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { + return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); + } + let wb = s.doc()?.wb.clone(); + for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { + let Some(bounds) = sh.bounds_range else { continue }; + let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + let range = resolve::a1(Place { sheet: sh.index, range: bounds }, &env); + s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; + } + Ok(()) +} + +// ---------------------------------------------------------------- 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}'.")))?; + 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 = 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); + let inputs = prepared.inputs_json(&s.doc()?.wb); + Ok(Begun { mac: m, prepared, rule, time_limit_ms, inputs }) +} + +/// The footprint check and the output rule, inside the run's undo group: an error when the macro +/// would fail or write over data it may not, after shifting the data if the rule says so. Returns +/// whether it shifted. +pub fn make_way(s: &mut Session, b: &Begun) -> Result { + let (m, rule) = (&b.mac, b.rule); + if rule == OnConflict::Overwrite { + return Ok(false); + } + let fp = footprint(s, &b.prepared)?; + if let Some(e) = fp.error { + return Err(e.into_engine(&m.name)); + } + if fp.conflict_count() == 0 { + return Ok(false); + } + let what = fp.describe_conflicts(); + match rule { + OnConflict::Ask => Err(EngineError::Confirm(format!( + "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", + m.name + ))), + OnConflict::Fail | OnConflict::Overwrite => { + Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))) + } + OnConflict::ShiftDown | OnConflict::ShiftRight => { + make_room(s, &fp, rule == OnConflict::ShiftDown)?; + let again = footprint(s, &b.prepared)?; + if let Some(e) = again.error { + return Err(e.into_engine(&m.name)); + } + if again.conflict_count() > 0 { + return Err(EngineError::Other(format!( + "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", + m.name, + again.describe_conflicts() + ))); + } + Ok(true) + } + } +} + +/// 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 shifted = make_way(s, &b)?; + let mut machine = Machine::new(&b.prepared, false); + machine.time_limit_ms = b.time_limit_ms; + 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} Nothing was changed."))), + stopped => { + let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs}); + if let Some((msg, _)) = stopped { + out["stopped"] = json!(msg); + } + if shifted { + out["shifted"] = json!(b.rule.name()); + } + Ok(out) + } + } + }) +} + +/// The conflict rule for this run: `onConflict`, or `overwrite: true` (the answer to the +/// question), else the macro's own. +fn conflict_rule(p: &Json, m: &Macro) -> Result { + if let Some(t) = p.get("onConflict").and_then(Json::as_str) { + return OnConflict::parse(t) + .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'."))); + } + if p.get("overwrite").and_then(Json::as_bool) == Some(true) { + return Ok(OnConflict::Overwrite); + } + Ok(m.output.on_conflict) +} + +/// `macro.check`: what running the macro would do, without changing anything. +pub fn check(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}'.")))?; + let mut out = json!({"macro": m.name, "ok": false, "onConflict": m.output.on_conflict.name()}); + let asks = match gate(&m, trust) { + Ok(a) => a, + Err(e) => { + out["errors"] = json!([e.to_string()]); + return Ok(out); + } + }; + let approved = s.macro_approvals.contains(&(doc, super::hash(&m))); + if !asks.is_empty() && !approved { + // Nothing from an unapproved macro runs, not even in a dry run. + out["needsApproval"] = json!(asks); + } + let prepared = match prepare(s, &m, p.get("inputs")) { + Ok(pr) => pr, + Err(e) => { + out["errors"] = json!([e.to_string()]); + return Ok(out); + } + }; + out["inputs"] = prepared.inputs_json(&s.doc()?.wb); + if let Some(o) = prepared.output { + let wb = &s.doc()?.wb; + out["output"] = json!(resolve::a1(o, &Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None })); + } + if !asks.is_empty() && !approved { + return Ok(out); + } + let fp = footprint(s, &prepared)?; + out["ok"] = json!(fp.error.is_none()); + out["conflicts"] = json!(fp.conflict_count()); + if let Some(e) = &fp.error { + out["errors"] = json!([e.describe()]); + } + out["footprint"] = serde_json::to_value(&fp).unwrap_or(Json::Null); + Ok(out) +} diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 9a28f14f9..f0845a54b 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -213,6 +213,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!(hash(&a), hash(&b)); + b.steps[0].note = "changed".into(); + assert_ne!(hash(&a), hash(&b)); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b5ac30d43..74b7621eb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3344,4 +3344,5 @@ fn csv_as_text_keeps_identifier_columns_as_written() { let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); } +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 000000000..fcc418b56 --- /dev/null +++ b/crates/engine/src/tests/macros.rs @@ -0,0 +1,436 @@ +//! Macros: running, input checks, the footprint dry run, the policy and the limits. + +use std::sync::Arc; + +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("would write over cells that hold 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!(s.execute("macro.run", json!({"name": "Totals", "onConflict": "sideways"})).is_err()); + + // macro.check shows the footprint and the conflict, and changes nothing. + let journal = s.journal.len(); + let sel = s.doc().unwrap().selection.clone(); + let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap(); + assert_eq!(c["ok"], true); + assert_eq!(c["conflicts"], 1); + assert_eq!(c["output"], "E2"); + assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["E2:E3"])); + assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["E3"])); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!((s.journal.len(), &s.doc().unwrap().selection), (journal, &sel)); + + // 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"); +} + +#[test] +fn shifting_makes_room_and_is_checked_again() { + for (rule, moved) in [("shiftDown", ["E4", "E5"]), ("shiftRight", ["F2", "F3"])] { + let mut s = s(); + data(&mut s); + set(&mut s, "E2", "old 1"); + set(&mut s, "E3", "old 2"); + put(&mut s, totals()); + let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": rule})).unwrap(); + assert_eq!(r["shifted"], rule); + assert_eq!((v(&s, "E2").display(), v(&s, "E3").display()), ("Total".into(), "400".into()), "{rule}"); + assert_eq!((v(&s, moved[0]).display(), v(&s, moved[1]).display()), ("old 1".into(), "old 2".into()), "{rule}"); + // The shift and the run are one undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "E2").display(), "old 1", "{rule}"); + assert!(v(&s, moved[1]).is_empty(), "{rule}"); + } + // A cell written with what it already holds isn't a conflict, but it moves with the others, + // so rows stay together. + let mut s = super::s(); + for (cell, input) in [("F2", "x"), ("G2", "b"), ("F3", "f3"), ("G3", "g3")] { + set(&mut s, cell, input); + } + put( + &mut s, + json!({"name": "Pair", "output": {"onConflict": "shiftDown"}, "steps": [ + {"command": "cell.set", "params": {"cell": "F2", "input": "a"}}, + {"command": "cell.set", "params": {"cell": "G2", "input": "b"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Pair"})).unwrap(); + assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["sheets"][0]["cells"]), (Some(1), &json!(["F2:G2"]))); + s.execute("macro.run", json!({"name": "Pair"})).unwrap(); + let row = |s: &Session, r: u32| (v(s, &format!("F{r}")).display(), v(s, &format!("G{r}")).display()); + assert_eq!(row(&s, 2), ("a".into(), "b".into())); + assert_eq!(row(&s, 3), ("x".into(), "b".into())); + assert_eq!(row(&s, 4), ("f3".into(), "g3".into())); + // An anchor that moves with the shifted data still lands on it: the second check refuses. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old"); + s.execute("formulas.defineName", json!({"name": "Target", "refersTo": "=Sheet1!$E$2"})).unwrap(); + put( + &mut s, + json!({"name": "Named", "steps": [{"command": "cell.set", "params": {"cell": "Name:Target", "input": "new"}}], "output": {"onConflict": "shiftDown"}}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string(); + assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); +} + +#[test] +fn the_footprint_covers_other_sheets_objects_and_edges() { + let mut s = s(); + data(&mut s); + s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); + set(&mut s, "A1", "note"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("insert.chart", json!({"range": "B2:C5", "type": "column", "at": "H2"})).unwrap(); + let chart = s.doc().unwrap().wb.sheet(0).unwrap().charts[0].id; + put( + &mut s, + json!({"name": "Wide", "output": {"onConflict": "fail"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Notes!A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "Notes!B2", "input": "y"}}, + {"command": "home.insertSheet", "params": {"name": "Extra"}}, + {"command": "sheet.activate", "params": {"sheet": "Sheet1"}}, + {"command": "insert.shape", "params": {"kind": "ellipse", "at": "J2"}}, + {"command": "object.delete", "params": {"kind": "chart", "id": chart}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Wide", "inputs": {}})).unwrap(); + let fp = &c["footprint"]; + assert_eq!( + fp["sheets"], + json!([{"sheet": "Notes", "cells": ["A1", "B2"], "changed": 2, "conflicts": ["A1"], "conflictCount": 1, "bounds": "A1:B2"}]) + ); + assert_eq!(fp["sheetsAdded"], json!(["Extra"])); + assert_eq!((fp["objectsAdded"].as_u64(), fp["objectsRemoved"].as_u64()), (Some(1), Some(1))); + assert_eq!(c["conflicts"], 2, "the deleted chart and the note"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2, "the dry run's new sheet went away"); + let err = s.execute("macro.run", json!({"name": "Wide"})).unwrap_err().to_string(); + assert!(err.contains("(A1 on Notes; 1 object)"), "{err}"); + + // Running past the edge of the sheet fails before anything changes. + put( + &mut s, + json!({"name": "Edge", "inputs": [{"name": "Input"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.offset(-3,0)", "input": "x"}}]}), + ); + let err = s.execute("macro.run", json!({"name": "Edge", "inputs": "B2"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (cell.set): 'Input.offset(-3,0)' runs past the top of the sheet."), "{err}"); + let c = s.execute("macro.check", json!({"name": "Edge", "inputs": "B2"})).unwrap(); + assert_eq!((c["ok"].as_bool(), c["footprint"]["error"]["edge"].as_bool()), (Some(false), Some(true))); +} + +#[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}"); +} + +#[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"})), + ] { + 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); + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["needsApproval"], json!(["runs data.fromTextCsv, which reads a file"])); + assert!(c.get("footprint").is_none(), "nothing unapproved runs, even dry"); + 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(); + // The dry run skips the file step and says so. + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["footprint"]["partial"]["step"], "1"); + // 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()); +} + +#[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(); +} From 20d2a3b99861cc4a0e89be6c07fa950f9dedf0ee Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:20:38 -0400 Subject: [PATCH 015/166] Model: a macro name with control characters is refused on every path Only parse_macro refused a name holding a line break or other control character. Macros that came from the macros field of a JSON workbook were checked with check_limits alone, so a file could bring in a macro named "a\nb" that macro.save and macro.rename would refuse. The check now lives in Macro::check_limits, so every path applies it, and such a macro is dropped with the usual warning when the file is opened. --- crates/engine/src/macros/mod.rs | 3 --- crates/engine/src/macros/tests.rs | 13 +++++++++++++ crates/model/src/macros.rs | 6 +++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 141323650..6b3ccdbd8 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -30,9 +30,6 @@ pub fn parse_macro(v: &Json) -> Result { let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?; m.name = m.name.trim().to_string(); m.check_limits()?; - if m.name.chars().any(char::is_control) { - return Err("A macro name can't hold line breaks or other control characters.".into()); - } check_size(&m)?; Ok(m) } diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 1b417c8a0..fea61f6c3 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -193,3 +193,16 @@ fn names() { assert_eq!(find(&wb, " MACRO1 "), Some(0)); assert_eq!(find(&wb, "Macro2"), None); } + +#[test] +fn json_field_macros_get_the_name_check_too() { + // A JSON workbook can't bring in a name that macro.save and macro.rename refuse. + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: "a\nb".into(), ..Macro::default() }); + wb.macros.push(Macro { name: "Fine".into(), ..Macro::default() }); + let bytes = crate::io::save_bytes(&wb, "ctl.json").unwrap(); + let (back, warnings) = crate::io::open_bytes("ctl.json", &bytes).unwrap(); + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, ["Fine"]); + assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro "), "{warnings:?}"); +} diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index fa898984d..caee00805 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -63,7 +63,8 @@ impl Macro { count(&self.steps) } - /// Checks the size limits ([`MAX_MACRO_STEPS`], [`MAX_MACRO_DEPTH`], name and text lengths). + /// Checks the size limits ([`MAX_MACRO_STEPS`], [`MAX_MACRO_DEPTH`], name and text lengths) and + /// that the name has no control characters. pub fn check_limits(&self) -> Result<(), String> { let name = self.name.trim(); if name.is_empty() { @@ -72,6 +73,9 @@ impl Macro { if name.chars().count() > MAX_MACRO_NAME { return Err(format!("A macro name can be at most {MAX_MACRO_NAME} characters long.")); } + if name.chars().any(char::is_control) { + return Err("A macro name can't hold line breaks or other control characters.".into()); + } if self.description.chars().count() > MAX_MACRO_TEXT { return Err(format!("A macro description can be at most {MAX_MACRO_TEXT} characters long.")); } From 23c0107511d516dcd5978594384ba850e7d95fba Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:01:51 -0400 Subject: [PATCH 016/166] Engine: macro policy checks a step's parameters after tokens resolve; any path counts as file access A path given as a token (`{"$ref": "Input"}` or `{{Input}}`) became a file name only when the step ran, after the policy had passed it, so a macro without permissions or approval could write a file, even from macro.check. Exports also wrote to blank paths the policy ignored. Any `path` other than null now needs the permission and approval, and the runner checks the policy again on the resolved parameters before the command runs. --- crates/engine/src/macros/policy.rs | 15 +++++++++-- crates/engine/src/macros/run.rs | 21 ++++++++++------ crates/engine/src/tests/macros.rs | 40 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs index 7329f567f..84bbc497b 100644 --- a/crates/engine/src/macros/policy.rs +++ b/crates/engine/src/macros/policy.rs @@ -43,8 +43,12 @@ const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.pictu const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"]; /// 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").and_then(Json::as_str).is_some_and(|p| !p.trim().is_empty()); + 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) { @@ -104,7 +108,14 @@ mod tests { // 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": " "}), 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}"); diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 3651deb0c..aebaa8c90 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -576,29 +576,34 @@ impl Machine { 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); - match macro_policy(id, params, self.trust) { - Policy::Deny(why) => return Err(fail(format!("{id} can't run in a macro: {why}."))), - Policy::NeedsPermission(what) => { + // 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."))); + } if self.dry_run { self.partial = Some((path.to_string(), format!("{id} {what}"))); self.frames.clear(); return Ok(()); } } - Policy::Allow | Policy::Ask(_) => {} + (_, Policy::Allow | Policy::Ask(_)) => {} } if self.dry_run && !previewable(id) { self.partial = Some((path.to_string(), format!("{id} can't be previewed"))); self.frames.clear(); return Ok(()); } - 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))? - }; self.last_params = Some(resolved.clone()); let queued = s.ui_requests.len(); let r = s.execute(id, resolved); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index fcc418b56..c3a2c4442 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -358,6 +358,46 @@ fn macros_are_bounded() { 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, not even the check's dry run. + 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}"); + let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("writes a file"), "{path}: {c}"); + assert!(c.get("footprint").is_none()); + // With it, the person approves first; the check doesn't run it before then. + 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}"); + let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); + assert_eq!(c["needsApproval"], json!(["runs file.exportCsv, which writes a file"]), "{path}"); + assert!(c.get("footprint").is_none()); + assert_eq!(files(&dir), 0, "{path}"); + assert!(!std::path::Path::new("A1.csv").exists() && !std::path::Path::new(" .csv").exists()); + } + // Once approved, the check's dry run stops before the step, and only 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); + std::fs::remove_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let c = s.execute("macro.check", json!({"name": "Export", "inputs": "B2"})).unwrap(); + assert_eq!(c["footprint"]["partial"]["step"], "1", "{c}"); + assert_eq!(files(&dir), 0, "the check writes nothing"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn the_policy_keeps_macros_away_from_files_and_the_app() { let mut s = s(); From b2740bdf339c1a77dfcabe7e0c285a79ef911a23 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:19:29 -0400 Subject: [PATCH 017/166] Engine: macro recorder and macro.list, get, save, delete, rename, record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands: macro.list, macro.get, macro.save (a macro object or its fields; replace defaults to true), macro.delete, macro.rename and macro.record, beside macro.run, macro.check and macro.debug. Saving, renaming and deleting are undoable. The catalog gains View › Macros › Record Macro (parity 275 / 291). The recorder builds on the recording buffer of top-level commands, so nested commands are never recorded twice and trimming the journal loses nothing. When it stops, commands a macro may not run are left out and listed; a macro run while recording is replaced by its steps when it has no inputs, and an Automate script run by the script's steps; sheet.activate and selection.set steps are added where the sheet or selection changed between commands without one, and runs of them collapse to the last. With relative references (the default) the selection when recording started is the example input: references in and near it become tokens (Input.col(2), Input.rows(2..), Input.col(2).below, Input.row(4).right, Input.cell(r,c)), exact references in formulas become {{...}}, and the input's width, header and column types are inferred from the example. A recording replayed on a longer block of data writes in the matching places. A toggle such as Bold is recorded with the state it set ("on": true), as Excel records Bold = True, so a replay sets it instead of flipping it again. Automate scripts are unchanged: automate.* keep their commands, parameters, storage and results, and View › Macros still lists the scripts and a preserved VBA project. --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/extra.rs | 17 ++ crates/engine/src/cmd/macros.rs | 165 ++++++++++- crates/engine/src/lib.rs | 13 +- crates/engine/src/macros/mod.rs | 29 +- crates/engine/src/macros/recorder.rs | 391 +++++++++++++++++++++++++++ crates/engine/src/macros/tests.rs | 3 + crates/engine/src/tests/macros.rs | 186 +++++++++++++ docs/parity-checklist.md | 7 +- 9 files changed, 805 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/macros/recorder.rs diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6b67e1b4..aefbae29c 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -269,6 +269,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("view.unhideWindow", "View", "Window", "Unhide"), ("view.switchWindows", "View", "Window", "Switch Windows"), ("view.macros", "View", "Macros", "Macros"), + ("macro.record", "View", "Macros", "Record Macro"), // Automate ("automate.newScript", "Automate", "Scripting Tools", "New Script"), ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 6d6516f50..08dfc0a64 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -999,6 +999,23 @@ fn run_script(s: &mut Session, p: &Json) -> Result { Ok(json!({"ran": n})) } +/// The command steps of the Automate script called `name`, as `automate.runScript` runs them +/// (steps that run other scripts are left out), or `None` when there is no such script. +pub(crate) fn script_steps(wb: &Workbook, name: &str) -> Option> { + let all = stash_get(wb, SCRIPTS_NAME); + let steps = all.get(name)?.as_array()?; + Some( + steps + .iter() + .take(100_000) + .filter_map(|st| { + let id = st["command"].as_str()?; + (!id.starts_with("automate.")).then(|| (id.to_string(), st["params"].clone())) + }) + .collect(), + ) +} + fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index a14fc2842..e68dd92e6 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -1,12 +1,27 @@ //! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model, -//! tokens, runner, debugger and policy. Automate scripts (`automate.*`) are a separate, older feature. +//! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate, +//! older feature with their own storage; these commands neither read nor change them. -use serde_json::Value as Json; +use serde_json::{Value as Json, json}; use super::*; +use crate::macros::recorder::RecordingStart; pub fn specs() -> Vec { vec![ + cmd!(query "macro.list", "List Macros", [], None, "{} → [{name, description, shortcut?, steps, inputs: [names], onConflict, permissions}]", has_doc, list), + cmd!(query "macro.get", "Get Macro", [], None, "{name} → the macro: {v, name, description, shortcut?, inputs, steps, output, permissions}", has_doc, get), + cmd!( + "macro.save", + "Save Macro", + [], + None, + "{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default true)} → {name, steps}", + has_doc, + save + ), + cmd!("macro.delete", "Delete Macro", [], None, "{name}", has_doc, delete), + cmd!("macro.rename", "Rename Macro", [], None, "{name, to}", has_doc, rename), cmd!( "macro.run", "Run Macro", @@ -25,6 +40,15 @@ pub fn specs() -> Vec { has_doc, check ), + cmd!( + noundo "macro.record", + "Record Macro", + ["View", "Macros"], + None, + "{stop?: bool, relative?: bool (default true; when starting), name?, description? (when stopping)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", + has_doc, + record + ), cmd!( "macro.debug", "Step Into", @@ -41,6 +65,93 @@ 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`")) } +/// The index of macro `name` in the active workbook, or an error that says there is none. +fn index_of(s: &Session, name: &str) -> Result { + crate::macros::find(&s.doc()?.wb, name).ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'."))) +} + +fn list(s: &mut Session, _: &Json) -> Result { + let wb = &s.doc()?.wb; + Ok(Json::Array( + wb.macros + .iter() + .map(|m| { + let mut o = json!({ + "name": m.name, + "description": m.description, + "steps": m.step_count(), + "inputs": m.inputs.iter().map(|i| i.name.clone()).collect::>(), + "onConflict": m.output.on_conflict.name(), + "permissions": m.permissions, + }); + if let Some(k) = &m.shortcut { + o["shortcut"] = json!(k); + } + o + }) + .collect(), + )) +} + +fn get(s: &mut Session, p: &Json) -> Result { + let i = index_of(s, name_param("macro.get", p)?)?; + let wb = &s.doc()?.wb; + Ok(wb.macros.get(i).map(crate::macros::macro_json).unwrap_or(Json::Null)) +} + +fn save(s: &mut Session, p: &Json) -> Result { + let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p); + let m = crate::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?; + let replace = bool_param(p, "replace").unwrap_or(true); + let (name, steps) = (m.name.clone(), m.step_count()); + edit(s, |cx| { + match crate::macros::find(&cx.wb, &m.name) { + Some(_) if !replace => return Err(EngineError::Other(format!("A macro called '{}' already exists.", m.name))), + Some(i) => { + if let Some(old) = cx.wb.macros.get_mut(i) { + *old = m; + } + } + None if cx.wb.macros.len() >= gridcraft_model::MAX_MACROS => { + return Err(EngineError::Other(format!("A workbook can hold at most {} macros.", gridcraft_model::MAX_MACROS))); + } + None => cx.wb.macros.push(m), + } + Ok(()) + })?; + Ok(json!({"name": name, "steps": steps})) +} + +fn delete(s: &mut Session, p: &Json) -> Result { + let i = index_of(s, name_param("macro.delete", p)?)?; + edit(s, |cx| { + if i < cx.wb.macros.len() { + cx.wb.macros.remove(i); + } + Ok(Json::Null) + }) +} + +fn rename(s: &mut Session, p: &Json) -> Result { + let i = index_of(s, name_param("macro.rename", p)?)?; + let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("macro.rename", "missing `to`"))?.to_string(); + if to.chars().any(char::is_control) { + return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters")); + } + if crate::macros::find(&s.doc()?.wb, &to).is_some_and(|j| j != i) { + return Err(EngineError::Other(format!("A macro called '{to}' already exists."))); + } + edit(s, |cx| { + let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) }; + let mut renamed = m.clone(); + renamed.name = to.clone(); + renamed.check_limits().map_err(EngineError::Other)?; + *m = renamed; + Ok(()) + })?; + Ok(json!({"name": to})) +} + fn run(s: &mut Session, p: &Json) -> Result { let name = name_param("macro.run", p)?; crate::macros::run::run(s, name, p) @@ -50,3 +161,53 @@ fn check(s: &mut Session, p: &Json) -> Result { let name = name_param("macro.check", p)?; crate::macros::run::check(s, name, p) } + +fn record(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "stop").unwrap_or(false) || s.is_recording() { + return stop_recording(s, p); + } + start_recording(s, bool_param(p, "relative").unwrap_or(true)) +} + +fn start_recording(s: &mut Session, relative: bool) -> Result { + let d = s.doc()?; + let start = RecordingStart { + doc: d.uid, + sheet: d.wb.active_sheet, + sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), + selection: d.selection.clone(), + relative, + }; + let input = start.selection.current().a1(); + s.start_recording(); + s.macro_recording = Some(start); + Ok(if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) }) +} + +/// Stops recording and saves what was recorded as a macro. +fn stop_recording(s: &mut Session, p: &Json) -> Result { + let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) }; + let start = s.macro_recording.take(); + let d = s.doc()?; + let start = start.unwrap_or_else(|| RecordingStart { + doc: d.uid, + sheet: d.wb.active_sheet, + sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), + selection: d.selection.clone(), + relative: true, + }); + let wb = d.wb.clone(); + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => n.to_string(), + None => crate::macros::next_name(&wb), + }; + let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &wb); + converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS); + if let Some(desc) = str_param(p, "description") { + converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect(); + } + let steps = converted.mac.steps.len(); + let inputs: Vec = converted.mac.inputs.iter().map(|i| i.name.clone()).collect(); + s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac)}))?; + Ok(json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped, "skipped": converted.skipped, "inputs": inputs})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 242c86b97..2f6c252fc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -327,6 +327,8 @@ pub struct Session { pub macro_approvals: std::collections::HashSet<(u64, String)>, /// The macro being debugged, between debugger actions. pub macro_debug: Option, + /// Where the macro recorder started (beside `recorder`, its buffer). + pub macro_recording: Option, /// Windows hidden with View › Hide. pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. @@ -462,7 +464,16 @@ impl Session { if let Some((doc, sheet, sheet_name, selection)) = place && let Some(rec) = self.recorder.as_mut() { - rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); + // A toggle (Bold) is recorded with the state it set, as Excel records + // `Bold = True`: playing it back sets that state rather than flipping it again. + let mut params = cmd::journal_params(¶ms); + if let (Ok(Json::Object(r)), Json::Object(p)) = (&result, &mut params) + && !p.contains_key("on") + && let Some(on @ Json::Bool(_)) = r.get("on") + { + p.insert("on".into(), on.clone()); + } + rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params }); } self.journal.push((id.to_string(), cmd::journal_params(¶ms))); if self.journal.len() > 10_000 { diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index b25f06cb1..872f80513 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -9,13 +9,14 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use gridcraft_model::{DefinedName, Macro, Workbook}; +use gridcraft_model::{DefinedName, Macro, Step, StepKind, Workbook}; use serde_json::{Value as Json, json}; use crate::cmd::stash; pub mod debug; pub mod policy; +pub mod recorder; pub mod resolve; pub mod run; @@ -261,5 +262,31 @@ impl Taken { } } +/// The first free `Macro1`, `Macro2`… name. +pub fn next_name(wb: &Workbook) -> String { + (1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none()).unwrap_or_else(|| "Macro".into()) +} + +/// Every command step of `steps` (inside Repeat and If too), with its path. +pub fn command_steps(steps: &[Step]) -> Vec<(String, &str, &Json)> { + fn walk<'a>(steps: &'a [Step], prefix: &str, out: &mut Vec<(String, &'a str, &'a Json)>) { + for (i, st) in steps.iter().enumerate() { + let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) }; + match &st.kind { + StepKind::Command { id, params } => out.push((path, id.as_str(), params)), + StepKind::Repeat { body, .. } => walk(body, &path, out), + StepKind::If { then, otherwise, .. } => { + walk(then, &format!("{path}.then"), out); + walk(otherwise, &format!("{path}.else"), out); + } + StepKind::Stop { .. } => {} + } + } + } + let mut out = Vec::new(); + walk(steps, "", &mut out); + out +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs new file mode 100644 index 000000000..56a64a72f --- /dev/null +++ b/crates/engine/src/macros/recorder.rs @@ -0,0 +1,391 @@ +//! Turning a recording into a macro. +//! +//! The recording buffer (`crate::record`) holds the top-level commands run while recording, each +//! with the sheet and selection it ran in. When recording stops: +//! +//! - commands a macro may not run (saving, opening files…) and other macros' commands are left +//! out; a macro run while recording is replaced by its steps when it has no inputs, and an +//! Automate script run while recording by the script's steps; +//! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed +//! between commands without one, and runs of `selection.set` collapse to the last; +//! - with relative references (the default), the selection when recording started is the example +//! input: references in, next to or near it become tokens (`Input.col(2)`, `Input.below`, +//! `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's shape, +//! header and column types are inferred from it. + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_model::{ColType, InputKind, Macro, NamedInput, Step, Workbook}; +use serde_json::{Map, Value as Json, json}; + +use super::policy::{Policy, macro_policy}; +use crate::Selection; +use crate::record::RecordedStep; + +/// How far outside the example input a reference may be and still be made relative to it. +const NEAR_ROWS: u32 = 50; +const NEAR_COLS: u32 = 26; + +/// Where recording started, kept beside the buffer. +#[derive(Clone, Debug)] +pub struct RecordingStart { + pub doc: u64, + pub sheet: usize, + pub sheet_name: String, + pub selection: Selection, + pub relative: bool, +} + +/// A recording turned into a macro, with what was left out. +pub struct Converted { + pub mac: Macro, + /// Commands left out (ids), in order. + pub skipped: Vec, +} + +/// Builds a macro called `name` from the recorded steps. +pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &Workbook) -> Converted { + let mut skipped = Vec::new(); + // Commands as they'll be played back: (id, params, sheet before, selection before, whether + // they came from another macro and stay as written). + let mut cmds: Vec<(String, Json, usize, String, Selection, bool)> = Vec::new(); + for st in steps.iter().filter(|st| st.doc == start.doc) { + let id = st.id.as_str(); + if id == "macro.run" { + // Another macro's steps, when they stand on their own. + let inner = st.params.get("name").and_then(Json::as_str).and_then(|n| super::find(wb, n)).and_then(|i| wb.macros.get(i)); + match inner { + Some(m) if m.inputs.is_empty() => { + for (_, cid, params) in super::command_steps(&m.steps) { + cmds.push((cid.to_string(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + } + } + _ => skipped.push(id.to_string()), + } + continue; + } + if id == "automate.runScript" { + // An Automate script's steps, as the script runs them. + match st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)) { + Some(steps) => { + for (cid, params) in steps { + cmds.push((cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + } + } + None => skipped.push(id.to_string()), + } + continue; + } + if matches!(macro_policy(id, &st.params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)) { + skipped.push(id.to_string()); + continue; + } + cmds.push((id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), false)); + } + + // Sheet and selection changes made without a command: where a command ran on another sheet, + // or with another selection, than the one before it, say so (a command that moved the + // selection itself does it again when played back, so the extra step is harmless). + let mut out: Vec<(String, Json, bool)> = Vec::new(); + let (mut sheet, mut sel) = (start.sheet, start.selection.clone()); + for (id, params, at_sheet, sheet_name, at_sel, kept) in cmds { + if at_sheet != sheet { + out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), false)); + } + // A recorded selection.set just before already put the selection there. + let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set"); + if (at_sel != sel || at_sheet != sheet) && !selected { + out.push(("selection.set".into(), selection_params(&at_sel), false)); + } + (sheet, sel) = (at_sheet, at_sel); + out.push((id, params, kept)); + } + // Runs of selection.set (or sheet.activate): the last one wins. + let mut collapsed: Vec<(String, Json, bool)> = Vec::new(); + for (id, params, kept) in out { + if matches!(id.as_str(), "selection.set" | "sheet.activate") + && let Some((last, ..)) = collapsed.last() + && *last == id + { + collapsed.pop(); + } + collapsed.push((id, params, kept)); + } + + // Relative references. + let example = start.selection.current(); + let mut used_input = false; + let steps: Vec = collapsed + .into_iter() + .map(|(id, params, kept)| { + let params = if start.relative && !kept { relative(¶ms, example, &start.sheet_name, &mut used_input, None) } else { params }; + Step::command(id, params) + }) + .collect(); + let mut mac = Macro { name: name.to_string(), steps, ..Macro::default() }; + if start.relative && used_input { + mac.inputs = vec![infer_input(wb, start.sheet, example)]; + } + Converted { mac, skipped } +} + +fn selection_params(sel: &Selection) -> Json { + let ranges: Vec = sel.ranges.iter().map(RangeRef::a1).collect(); + json!({"range": ranges.join(","), "active": sel.active.a1()}) +} + +/// `params` with references near `example` made into tokens. +fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Option<&str>) -> Json { + match v { + Json::Object(o) => { + let mut out = Map::new(); + for (k, x) in o { + out.insert(k.clone(), relative(x, example, sheet, used, Some(k))); + } + Json::Object(out) + } + Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, used, key)).collect()), + Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => { + // A list of areas ("A1:B2,D4") stays as it is unless every part has a token. + let parts: Option> = t.split(',').map(|part| ref_token(part, example, sheet)).collect(); + match parts { + Some(p) if p.len() == 1 => { + *used = true; + json!(p.join("")) + } + _ => v.clone(), + } + } + Json::String(t) if t.starts_with('=') && example.count() > 1 => { + let (f, changed) = formula_tokens(t, example); + *used |= changed; + json!(f) + } + other => other.clone(), + } +} + +/// The token for an A1 reference (on the example's sheet) near the example input. +fn ref_token(text: &str, example: RangeRef, sheet: &str) -> Option { + let (on, body) = crate::cmd::split_sheet(text.trim()); + if on.is_some_and(|s| !s.eq_ignore_ascii_case(sheet)) { + return None; + } + let t = RangeRef::parse(body)?; + if t.is_full_cols() || t.is_full_rows() { + return None; + } + token(t, example) +} + +/// `t` as a token relative to `r`. +fn token(t: RangeRef, r: RangeRef) -> Option { + if t == r { + return Some("Input".into()); + } + let rel_row = |row: u32| i64::from(row) - i64::from(r.start.row) + 1; + let rel_col = |col: u32| i64::from(col) - i64::from(r.start.col) + 1; + let cols = |a: u32, b: u32| if a == b { format!("col({})", rel_col(a)) } else { format!("cols({}..{})", rel_col(a), rel_col(b)) }; + if r.contains_range(&t) { + let full_rows = t.start.row == r.start.row && t.end.row == r.end.row; + let full_cols = t.start.col == r.start.col && t.end.col == r.end.col; + return Some(if full_rows { + format!("Input.{}", cols(t.start.col, t.end.col)) + } else if full_cols && t.start.row == t.end.row { + format!("Input.row({})", rel_row(t.start.row)) + } else if full_cols && t.end.row == r.end.row { + format!("Input.rows({}..)", rel_row(t.start.row)) + } else if full_cols { + format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row)) + } else if t.is_single() { + format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col)) + } else { + format!("Input.cell({},{}).resize({},{})", rel_row(t.start.row), rel_col(t.start.col), t.height(), t.width()) + }); + } + let near = t.start.row.saturating_add(NEAR_ROWS) >= r.start.row + && t.end.row <= r.end.row.saturating_add(NEAR_ROWS) + && t.start.col.saturating_add(NEAR_COLS) >= r.start.col + && t.end.col <= r.end.col.saturating_add(NEAR_COLS); + if !near { + return None; + } + // Just below the input, in its columns: follows the input's length. + if t.start.row == r.end.row + 1 && t.start.col >= r.start.col && t.end.col <= r.end.col { + let base = if t.start.col == r.start.col && t.end.col == r.end.col { + "Input".to_string() + } else { + format!("Input.{}", cols(t.start.col, t.end.col)) + }; + return Some(if t.height() == 1 { format!("{base}.below") } else { format!("{base}.below({})", t.height()) }); + } + // Just right of the input, in its rows: follows its width. + if t.start.col == r.end.col + 1 && t.start.row >= r.start.row && t.end.row <= r.end.row { + let base = if t.start.row == r.start.row && t.end.row == r.end.row { + "Input".to_string() + } else if t.start.row == t.end.row { + format!("Input.row({})", rel_row(t.start.row)) + } else { + format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row)) + }; + return Some(if t.width() == 1 { format!("{base}.right") } else { format!("{base}.right({})", t.width()) }); + } + let cell = format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col)); + Some(if t.is_single() { cell } else { format!("{cell}.resize({},{})", t.height(), t.width()) }) +} + +/// Replaces references in a formula that are exactly the example input, one of its columns or +/// rows, or a column without its first row, with `{{token}}`. +fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) { + let mut candidates: Vec<(String, String)> = vec![(r.a1(), "Input".into())]; + for (i, col) in (r.start.col..=r.end.col).enumerate() { + let n = i + 1; + candidates.push((RangeRef::new(CellRef::new(r.start.row, col), CellRef::new(r.end.row, col)).a1(), format!("Input.col({n})"))); + if r.height() > 2 { + candidates + .push((RangeRef::new(CellRef::new(r.start.row + 1, col), CellRef::new(r.end.row, col)).a1(), format!("Input.rows(2..).col({n})"))); + } + } + if r.width() > 1 { + for (i, row) in (r.start.row..=r.end.row).enumerate() { + candidates.push((RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), format!("Input.row({})", i + 1))); + } + } + // Longest first, so `B2:B10` isn't taken for a `B2` cell reference. + candidates.sort_by_key(|(a1, _)| std::cmp::Reverse(a1.len())); + let mut out = String::with_capacity(f.len()); + let mut changed = false; + let (mut i, mut in_text) = (0, false); + let bytes = f.as_bytes(); + while i < f.len() { + let Some(rest) = f.get(i..) else { break }; + let ch = rest.chars().next().unwrap_or(' '); + if ch == '"' { + in_text = !in_text; + } + let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':')); + if !in_text && prev_ok { + let hit = candidates.iter().find(|(a1, _)| { + rest.starts_with(a1.as_str()) + && !rest + .get(a1.len()..) + .and_then(|x| x.chars().next()) + .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '!' | '(' | '_')) + }); + if let Some((a1, tok)) = hit { + out.push_str("{{"); + out.push_str(tok); + out.push_str("}}"); + i += a1.len(); + changed = true; + continue; + } + } + out.push(ch); + i += ch.len_utf8(); + } + (out, changed) +} + +/// The example input's shape: a header when the first row is all text over other kinds of +/// values, and each column's type when all its values agree. +fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput { + let mut input = NamedInput::default(); + if r.is_single() { + input.kind = InputKind::Cell; + input.rows = [1, 1]; + input.cols = [1, 1]; + return input; + } + input.cols = [r.width(), r.width()]; + let Some(sh) = wb.sheet(sheet) else { return input }; + if r.count() > 100_000 { + return input; + } + let kind = |c: CellRef| -> Option { + let v = sh.value(c); + Some(match v { + Value::Empty => return None, + Value::Text(t) if t.is_empty() => return None, + Value::Number(_) if crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).is_date() => ColType::Date, + Value::Number(_) => ColType::Number, + Value::Text(_) => ColType::Text, + Value::Bool(_) => ColType::Bool, + _ => ColType::Any, + }) + }; + let column = |col: u32, from: u32| -> ColType { + let mut seen: Option = None; + for row in from..=r.end.row { + match (seen, kind(CellRef::new(row, col))) { + (_, None) => {} + (None, k) => seen = k, + (Some(a), Some(b)) if a != b => return ColType::Any, + _ => {} + } + } + seen.unwrap_or(ColType::Any) + }; + let first_text = (r.start.col..=r.end.col).all(|c| kind(CellRef::new(r.start.row, c)) == Some(ColType::Text)); + let below = (r.start.col..=r.end.col).map(|c| column(c, r.start.row + 1)).collect::>(); + input.header = r.height() > 1 && first_text && below.iter().any(|k| !matches!(k, ColType::Text | ColType::Any)); + input.col_types = if input.header { below } else { (r.start.col..=r.end.col).map(|c| column(c, r.start.row)).collect() }; + if input.col_types.iter().all(|k| *k == ColType::Any) { + input.col_types.clear(); + } + input.rows = [1, 0]; + input +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(a1: &str) -> RangeRef { + RangeRef::parse(a1).unwrap() + } + + #[test] + fn references_near_the_example_become_tokens() { + let ex = r("B2:D11"); + for (t, want) in [ + ("B2:D11", Some("Input")), + ("C2:C11", Some("Input.col(2)")), + ("C2:D11", Some("Input.cols(2..3)")), + ("B2:D2", Some("Input.row(1)")), + ("B3:D11", Some("Input.rows(2..)")), + ("B3:D5", Some("Input.rows(2..4)")), + ("C4", Some("Input.cell(3,2)")), + ("C4:D5", Some("Input.cell(3,2).resize(2,2)")), + ("B12:D12", Some("Input.below")), + ("C12", Some("Input.col(2).below")), + ("C12:C13", Some("Input.col(2).below(2)")), + ("E2:E11", Some("Input.right")), + ("E5", Some("Input.row(4).right")), + ("E3:F4", Some("Input.rows(2..3).right(2)")), + ("G2", Some("Input.cell(1,6)")), + ("A1", Some("Input.cell(0,0)")), + ("Z500", None), + ("A:A", None), + ] { + assert_eq!(ref_token(t, ex, "Sheet1").as_deref(), want, "{t}"); + } + assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1").as_deref(), Some("Input.cell(3,2)")); + assert_eq!(ref_token("Other!C4", ex, "Sheet1"), None); + } + + #[test] + fn formulas_get_tokens_for_exact_references() { + let ex = r("B2:C5"); + for (f, want) in [ + ("=SUM(C3:C5)", "=SUM({{Input.rows(2..).col(2)}})"), + ("=SUM(C2:C5)+COUNT(B2:C5)", "=SUM({{Input.col(2)}})+COUNT({{Input}})"), + ("=SUM($C$3:$C$5)", "=SUM($C$3:$C$5)"), + ("=SUM(Other!C3:C5)", "=SUM(Other!C3:C5)"), + ("=\"C3:C5\"&C3:C5", "=\"C3:C5\"&{{Input.rows(2..).col(2)}}"), + ("=SUM(AC3:C5)", "=SUM(AC3:C5)"), + ("=B2", "=B2"), + ] { + assert_eq!(formula_tokens(f, ex).0, want, "{f}"); + } + } +} diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 84792dc40..2602dc0f0 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -218,6 +218,9 @@ fn names() { assert_eq!(fingerprint(&a), fingerprint(&b)); b.steps[0].note = "changed".into(); assert_ne!(fingerprint(&a), fingerprint(&b)); + assert_eq!(next_name(&wb), "Macro2"); + let paths: Vec<(String, &str)> = command_steps(&a.steps).into_iter().map(|(p, id, _)| (p, id)).collect(); + assert_eq!(paths, [("1".into(), "cell.set"), ("2.1.then.1".into(), "home.bold"), ("3".into(), "home.italic")]); } #[test] diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 207e4c944..941abe906 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -752,6 +752,192 @@ fn a_failing_step_ends_the_debugged_run_and_rolls_it_back() { assert_eq!(debug(&mut s, json!({"name": "Empty"}))["state"], "finished"); } +// ---------------------------------------------------------------- commands and the recorder + +#[test] +fn macros_are_saved_listed_renamed_and_deleted() { + let mut s = s(); + s.execute("macro.save", json!({"macro": totals()})).unwrap(); + // The fields can also come at the top level. + let r = s + .execute("macro.save", json!({"name": "Second", "description": "two", "shortcut": "Ctrl+Shift+Q", "steps": [{"command": "home.bold"}]})) + .unwrap(); + assert_eq!(r, json!({"name": "Second", "steps": 1})); + let list = s.execute("macro.list", json!({})).unwrap(); + assert_eq!(list[0], json!({"name": "Totals", "description": "", "steps": 3, "inputs": ["Input"], "onConflict": "ask", "permissions": []})); + assert_eq!(list[1]["shortcut"], "Ctrl+Shift+Q"); + let got = s.execute("macro.get", json!({"name": "totals"})).unwrap(); + assert_eq!(crate::macros::parse_macro(&got).unwrap(), crate::macros::parse_macro(&totals()).unwrap()); + assert!(s.execute("macro.save", json!({"macro": totals(), "replace": false})).unwrap_err().to_string().contains("already exists")); + assert!(s.execute("macro.save", json!({"macro": {"name": "Bad", "steps": [{"type": "warp"}]}})).is_err()); + assert!(s.execute("macro.rename", json!({"name": "Second", "to": "TOTALS"})).unwrap_err().to_string().contains("already exists")); + s.execute("macro.rename", json!({"name": "Second", "to": "Third"})).unwrap(); + s.execute("macro.rename", json!({"name": "third", "to": "THIRD"})).unwrap(); + s.execute("macro.delete", json!({"name": "Totals"})).unwrap(); + let names: Vec = + s.execute("macro.list", json!({})).unwrap().as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(names, ["THIRD"]); + assert!(s.execute("macro.delete", json!({"name": "Totals"})).unwrap_err().to_string().contains("no macro called")); + // Saving, renaming and deleting are undoable. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("macro.list", json!({})).unwrap().as_array().unwrap().len(), 2); + // Macros are kept apart from Automate scripts: View › Macros still lists the scripts. + assert_eq!(s.execute("view.macros", json!({})).unwrap(), json!([])); + assert!(s.execute("macro.rename", json!({"name": "THIRD", "to": "a\nb"})).is_err()); +} + +#[test] +fn macros_and_automate_scripts_live_side_by_side() { + let mut s = s(); + let steps = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "from script"}}]); + s.execute("automate.newScript", json!({"name": "Inner", "commands": steps})).unwrap(); + s.execute("macro.save", json!({"name": "Inner", "steps": [{"command": "cell.set", "params": {"cell": "C1", "input": "from macro"}}], "output": {"onConflict": "overwrite"}})).unwrap(); + // Recording a script run keeps the script's steps, so the macro stands on its own. + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + s.execute("automate.runScript", json!({"name": "Inner"})).unwrap(); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "Empty"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Recorded"})).unwrap(); + assert_eq!(r["skipped"], json!(["automate.recordActions", "automate.recordActions"])); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Recorded"})).unwrap()).unwrap(); + let ids: Vec = crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect(); + assert_eq!(ids, ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}", "cell.set {\"cell\":\"B1\",\"input\":\"from script\"}"]); + // Both lists keep their own entries, under the same name if need be. + let scripts = s.execute("automate.allScripts", json!({})).unwrap(); + assert_eq!(scripts, json!([{"name": "Empty", "steps": 0}, {"name": "Inner", "steps": 1}])); + let macros: Vec = s.execute("macro.list", json!({})).unwrap().as_array().unwrap().iter().map(|m| m["name"].clone()).collect(); + assert_eq!(macros, [json!("Inner"), json!("Recorded")]); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("automate.runScript", json!({"name": "Inner"})).unwrap(); + s.execute("macro.run", json!({"name": "Inner"})).unwrap(); + assert_eq!((v(&s, "B1").display(), v(&s, "C1").display()), ("from script".into(), "from macro".into())); + // Both survive an xlsx round trip unchanged. + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "both.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("automate.allScripts", json!({})).unwrap(), scripts); + assert_eq!(s.execute("macro.list", json!({})).unwrap().as_array().unwrap().len(), 2); +} + +#[test] +fn a_relative_recording_plays_back_on_another_block() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Plain", "steps": [{"command": "cell.set", "params": {"cell": "A20", "input": "plain"}}], "output": {"onConflict": "overwrite"}}), + ); + put(&mut s, json!({"name": "Shaped", "inputs": [{"name": "Input"}], "steps": []})); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + let r = s.execute("macro.record", json!({})).unwrap(); + assert_eq!(r, json!({"recording": true, "relative": true, "input": "B2:C5"})); + // Clicking a cell and typing, a formula over the amounts, bold header, italic on a selection. + s.execute("selection.set", json!({"cell": "E2"})).unwrap(); + set(&mut s, "E2", "Total"); + set(&mut s, "E3", "=SUM(C3:C5)"); + s.execute("home.bold", json!({"range": "B2:C2"})).unwrap(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + s.execute("selection.set", json!({"cell": "C4"})).unwrap(); + s.execute("home.italic", json!({})).unwrap(); + set(&mut s, "C6", "end"); + // Not kept: commands macros don't run, and a macro with inputs; a plain macro's steps are + // inlined. + s.execute("window.activate", json!({"index": 0})).unwrap(); + s.execute("macro.run", json!({"name": "Plain"})).unwrap(); + s.execute("macro.run", json!({"name": "Shaped", "inputs": "A1"})).unwrap(); + // The journal can be trimmed meanwhile. + s.journal.clear(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Sum it", "description": "made by recording"})).unwrap(); + assert!(!s.is_recording()); + assert_eq!(r["saved"], "Sum it"); + assert_eq!(r["skipped"], json!(["window.activate", "macro.run"])); + assert_eq!(r["inputs"], json!(["Input"])); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Sum it"})).unwrap()).unwrap(); + assert_eq!(m.description, "made by recording"); + let input = &m.inputs[0]; + assert!(input.header); + assert_eq!((input.cols, input.col_types.clone()), ([2, 2], vec![gridcraft_model::ColType::Text, gridcraft_model::ColType::Number])); + let steps: Vec<(String, Json)> = m + .steps + .iter() + .map(|st| match &st.kind { + gridcraft_model::StepKind::Command { id, params } => (id.clone(), params.clone()), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + steps, + vec![ + ("selection.set".to_string(), json!({"cell": "Input.cell(1,4)"})), + ("cell.set".into(), json!({"cell": "Input.cell(1,4)", "input": "Total"})), + ("cell.set".into(), json!({"cell": "Input.cell(2,4)", "input": "=SUM({{Input.rows(2..).col(2)}})"})), + ("home.bold".into(), json!({"range": "Input.row(1)", "on": true})), + ("selection.set".into(), json!({"cell": "Input.cell(3,2)"})), + ("home.italic".into(), json!({"on": true})), + ("cell.set".into(), json!({"cell": "Input.col(2).below", "input": "end"})), + ("cell.set".into(), json!({"cell": "A20", "input": "plain"})), + ] + ); + // Play it back on a longer block somewhere else. + for (cell, input) in [ + ("G10", "Who"), + ("H10", "Paid"), + ("G11", "A"), + ("H11", "1"), + ("G12", "B"), + ("H12", "2"), + ("G13", "C"), + ("H13", "3"), + ("G14", "D"), + ("H14", "4"), + ] { + set(&mut s, cell, input); + } + s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14", "onConflict": "overwrite"})).unwrap(); + assert_eq!((v(&s, "J10").display(), v(&s, "J11").display()), ("Total".into(), "10".into())); + assert!(bold(&s, "G10") && bold(&s, "H10")); + assert_eq!(v(&s, "H15").display(), "end"); + // Toggles were recorded with the state they set: a second run keeps the header bold. + s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14", "onConflict": "overwrite"})).unwrap(); + assert!(bold(&s, "G10") && bold(&s, "H10")); + let d = s.doc().unwrap(); + assert!(d.wb.styles.get(d.wb.active().unwrap().style_id(gridcraft_core::CellRef::parse("H12").unwrap())).font.italic); + // The input is checked like any other: text in the amounts is refused. + set(&mut s, "H12", "two"); + let err = s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14"})).unwrap_err().to_string(); + assert!(err.contains("Input column 2 (Paid) must hold numbers; H12 holds text."), "{err}"); +} + +#[test] +fn an_absolute_recording_keeps_its_references() { + let mut s = s(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + set(&mut s, "B2", "x"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + set(&mut s, "C3", "y"); + let r = s.execute("macro.record", json!({})).unwrap(); + assert_eq!(r["saved"], "Macro1"); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Macro1"})).unwrap()).unwrap(); + assert!(m.inputs.is_empty()); + let ids: Vec = crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect(); + assert_eq!( + ids, + [ + "home.insertSheet {\"name\":\"Two\"}", + "sheet.activate {\"sheet\":\"Two\"}", + "selection.set {\"active\":\"A1\",\"range\":\"A1\"}", + "cell.set {\"cell\":\"B2\",\"input\":\"x\"}", + "sheet.activate {\"sheet\":\"Sheet1\"}", + "selection.set {\"active\":\"A1\",\"range\":\"A1\"}", + "cell.set {\"cell\":\"C3\",\"input\":\"y\"}", + ] + ); + // Stopping when nothing records says so. + assert_eq!(s.execute("macro.record", json!({"stop": true})).unwrap(), json!({"recording": false})); +} + #[test] fn only_the_workbooks_a_debugged_run_touched_wait() { let mut s = s(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d218c8169..dec0cf557 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 274 / 290 commands implemented (94.5%).** +**Overall: 275 / 291 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -16,7 +16,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Formulas | 23 | 23 | 100% | | Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | -| View | 20 | 20 | 100% | +| View | 21 | 21 | 100% | | Automate | 3 | 3 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | @@ -299,7 +299,7 @@ Missing: - `review.translate` Translate (Language) - `review.showChanges` Show Changes (Changes) -## View (20 / 20, 100%) +## View (21 / 21, 100%) Implemented: @@ -323,6 +323,7 @@ Implemented: - `view.unhideWindow` Unhide (Window) - `view.switchWindows` Switch Windows (Window) - `view.macros` Macros (Macros) +- `macro.record` Record Macro (Macros) ## Automate (3 / 3, 100%) From 7011395fcf06734110dee5d86a06325fa0665e08 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:21:33 -0400 Subject: [PATCH 018/166] Engine: a macro renamed on a name clash keeps its name within the length limit When a file held two macros with the same name, the second was renamed "name (2)" without room for the suffix. Two macros with the same 255-character name both opened, but the second name grew to 259 characters, was saved that way, and the next open dropped that macro as over the limits. unique_name now shortens the name to make room for the suffix, and load checks the macro again after renaming it. --- crates/engine/src/macros/mod.rs | 27 +++++++++++++++++++-------- crates/engine/src/macros/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 6b3ccdbd8..68db8e249 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -128,12 +128,14 @@ pub fn load(wb: &mut Workbook) -> Vec { bad += 1; continue; } - match m.check_limits().and_then(|()| check_size(&m)) { - Ok(()) => { - let mut m = m; - m.name = unique_name(wb, m.name.trim()); - wb.macros.push(m); - } + let mut m = m; + let checked = m.check_limits().and_then(|()| { + m.name = unique_name(wb, m.name.trim()); + // The macro as it is kept, renamed, must still pass, or the next save would lose it. + m.check_limits().and_then(|()| check_size(&m)) + }); + match checked { + Ok(()) => wb.macros.push(m), Err(_) => bad += 1, } } @@ -150,13 +152,22 @@ pub fn load(wb: &mut Workbook) -> Vec { warnings } -/// `name`, or `name (2)`, `name (3)`… if a macro already has it. +/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A long name is shortened to make +/// room for the suffix, so the result stays within [`gridcraft_model::MAX_MACRO_NAME`] characters. pub fn unique_name(wb: &Workbook, name: &str) -> String { let name = if name.is_empty() { "Macro" } else { name }; if find(wb, name).is_none() { return name.to_string(); } - (2..).map(|n| format!("{name} ({n})")).find(|n| find(wb, n).is_none()).unwrap_or_else(|| name.to_string()) + (2..) + .map(|n| { + let suffix = format!(" ({n})"); + let keep = gridcraft_model::MAX_MACRO_NAME.saturating_sub(suffix.chars().count()); + let base: String = name.chars().take(keep).collect(); + format!("{}{suffix}", base.trim_end()) + }) + .find(|n| find(wb, n).is_none()) + .unwrap_or_else(|| name.to_string()) } #[cfg(test)] diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index fea61f6c3..e67479eab 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -190,6 +190,13 @@ fn names() { assert_eq!(unique_name(&wb, "macro1"), "macro1 (2)"); assert_eq!(unique_name(&wb, "Other"), "Other"); assert_eq!(unique_name(&wb, ""), "Macro"); + // A long name is shortened to make room for the suffix. + let long = "y".repeat(MAX_MACRO_NAME); + wb.macros.push(Macro { name: long.clone(), ..Macro::default() }); + let renamed = unique_name(&wb, &long); + assert_eq!(renamed, format!("{} (2)", "y".repeat(MAX_MACRO_NAME - 4))); + wb.macros.push(Macro { name: renamed, ..Macro::default() }); + assert_eq!(unique_name(&wb, &long).chars().count(), MAX_MACRO_NAME); assert_eq!(find(&wb, " MACRO1 "), Some(0)); assert_eq!(find(&wb, "Macro2"), None); } @@ -206,3 +213,24 @@ fn json_field_macros_get_the_name_check_too() { assert_eq!(names, ["Fine"]); assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro "), "{warnings:?}"); } + +#[test] +fn duplicate_names_at_the_length_limit_survive_save_and_reopen() { + // Two macros with the same 255-character name: the second is renamed within the limit, so a + // save and a second open keep both. + let long = "é".repeat(MAX_MACRO_NAME); + for file in ["dup.json", "dup.xlsx"] { + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: long.clone(), ..Macro::default() }); + wb.macros.push(Macro { name: long.clone(), description: "second".into(), ..Macro::default() }); + let (first, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&wb, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(first.macros.len(), 2, "{file}"); + let renamed = &first.macros[1].name; + assert_eq!(renamed.chars().count(), MAX_MACRO_NAME, "{file}"); + assert!(renamed.ends_with(" (2)") && renamed.starts_with("éé"), "{file}: {renamed}"); + let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&first, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(again.macros, first.macros, "{file}"); + } +} From d305566e57165f77abb8f925190fcaa0b797bcb2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:09:24 -0400 Subject: [PATCH 019/166] Docs: running and checking macros --- docs/macros.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index dd14951bc..424dd4f39 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,13 +1,15 @@ # Macros -> **Last updated:** 2026-10-21 · **Change:** minor (macro tokens) +> **Last updated:** 2026-10-21 · **Change:** minor (running and checking macros: `macro.run`, `macro.check`) 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 everything back on an error, checks +where its results will land before it writes them, 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 and checking macros. The engine has +no macro debugger, recorder or management commands in this version, and no macro interface. ## A macro @@ -67,6 +69,30 @@ any string, such as a formula. Plain A1 references stay absolute; a `Name:` anch inserted rows. A reference with a sheet name is never a token, so `Output!A1` is a cell on a sheet called Output, even when a token or input has that name. +## Running + +`macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`: + +1. **Policy.** File, app, window, automate and macro commands, and Undo and Redo, never run in a + macro. 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. +2. **Footprint.** The macro first runs in dry-run mode on the real workbook, and everything is put + back. Cells it would write outside its inputs that hold data, and sheets and objects it would + delete, are conflicts, handled by `onConflict` (or the macro's `output.onConflict`): `ask` (the + call fails with `confirm: …`; `overwrite: true` answers it), `fail`, `overwrite`, `shiftDown` or + `shiftRight` (Insert Cells makes room, then the footprint is checked again). Steps that touch + files, and Solver, end the dry run early; the footprint then says it is partial. +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). + +`macro.check {name, inputs?}` reports the bound inputs, the output anchor, any approval needed and +the footprint, without changing anything. + ## Storage Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden From 749558f978e00b6ca49ac8dc5f69de30b76b2f04 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:34:26 -0400 Subject: [PATCH 020/166] Docs: recording macros and the management commands; roadmap, architecture and parity --- ROADMAP.md | 6 ++++-- docs/architecture.md | 8 +++++++- docs/macros.md | 22 ++++++++++++++++++---- docs/target-app-parity.md | 5 +++-- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2c0ee33cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (macro engine and `macro.*` commands) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -85,7 +85,7 @@ collaboration, mobile, AI). | Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| Automation (recorded scripts; typed macros in the engine, no interface yet; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | ## Languages @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Macro engine and `macro.*` commands; catalog 274 / 290 → 275 / 291 (View › Macros › Record Macro); progress log row | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/docs/architecture.md b/docs/architecture.md index d815bc81c..fa38b52ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (macro engine on the engine building blocks) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -66,6 +66,11 @@ Actions made of many commands (macros, a debugger that runs one step per call) u 8,192-character limits, so it survives an xlsx round trip. The older single-name `_sc_` items are read as well. +The macro engine (`engine/src/macros/`, commands in `cmd/macros.rs`, described in +[`macros.md`](macros.md)) is built on these: a run is an undo group, the recorder reads the +recording buffer, the policy asks before a macro in a `FromFile` workbook runs, and xlsx keeps +macros as chunked hidden names (`_gc_macro_`). + ## Agent control - Desktop control channel: `gridcraft --control 7979`, JSON lines on localhost @@ -91,5 +96,6 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | | 2026-10-11 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | diff --git a/docs/macros.md b/docs/macros.md index 4301989ae..79e6c02c5 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-21 · **Change:** minor (debugging macros: `macro.debug`) +> **Last updated:** 2026-10-21 · **Change:** minor (recording macros and the management commands) 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 @@ -9,8 +9,9 @@ error (a file it already wrote stays), checks where its results will land before and does not touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the macro format, its tokens, and running, checking and debugging macros. -The engine has no macro recorder or management commands in this version, and no macro interface. +This page describes the engine and its commands. The macro interface (a Macros list, a recorder +button and a step editor) is not part of it yet. + ## A macro @@ -115,7 +116,20 @@ command makes them (stopping Record Actions saves a script), and other workbooks editable. Each answer gives the next step with its parameters as they would run, the parameters of the step that just ran, and the cells changed so far. -## Storage +## Recording + +`macro.record` toggles recording (`relative: false` for absolute references; `name` and +`description` when stopping). With relative references the selection when recording starts is the +example input: references in and near it become tokens, and the input's shape, header and column +types are inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so +playing them back sets them rather than flipping them. Commands a macro may not run are left out +and listed in the result; a macro run while recording is replaced by its steps when it has no +inputs, and an Automate script run by the script's steps. + +## Other commands and storage + +`macro.list`, `macro.get`, `macro.save {macro, replace?}`, `macro.delete` and `macro.rename +{name, to}`. Saving, renaming and deleting are undoable. Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b623124d9..de57d10d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and row formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (automation: typed macros in the engine) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -91,7 +91,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), with no interface yet. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Automation: typed macros in the engine and `macro.*` commands, no interface yet (no score change) | | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | From 4966b9c86db8b9e8adbae2158fdb6e8a5474bc6e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:22:38 -0400 Subject: [PATCH 021/166] Engine: opening a file reads its hidden macro names in one bounded pass load listed every _gc_macro_ item, then read each one with a scan of all the workbook's names, and removed each with another full scan. Damaged items didn't count toward the macro limit, so a file with 20,000 damaged items took 20,000 scans of 20,000 names (seconds, and far more for a larger file). The names are now taken out of the workbook and grouped by item in one pass; only the first 1,000 items are read, and the rest are counted in the warning without being read. --- crates/engine/src/macros/mod.rs | 43 ++++++++++++++++++++----------- crates/engine/src/macros/tests.rs | 20 ++++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 68db8e249..155b9182f 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -7,7 +7,9 @@ //! the open document. Scripts made with Automate › New Script or Record Actions are a separate, //! older feature with their own storage; macros neither read nor change them. -use gridcraft_model::{Macro, Workbook}; +use std::collections::BTreeMap; + +use gridcraft_model::{DefinedName, Macro, Workbook}; use serde_json::{Value as Json, json}; use crate::cmd::stash; @@ -101,25 +103,29 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { pub fn load(wb: &mut Workbook) -> Vec { let mut warnings = Vec::new(); let mut bad = 0usize; - // Stored macros, in their saved order. - let mut keyed: Vec<(u64, String)> = - stash::keys(wb, MACRO_KEY).into_iter().filter_map(|k| k.strip_prefix(MACRO_KEY).and_then(|n| n.parse().ok()).map(|n| (n, k))).collect(); - keyed.sort(); - let mut found = Vec::new(); - for (_, key) in &keyed { - // Past the macro limit, the rest are counted without being read. - if found.len() >= gridcraft_model::MAX_MACROS { - bad += 1; - continue; + // One pass takes the hidden macro names out of the workbook, grouped by macro in saved order. + let mut stored: BTreeMap<(u64, String), Vec> = BTreeMap::new(); + let mut kept = Vec::new(); + for n in std::mem::take(&mut wb.names) { + match macro_item(&n) { + None => kept.push(n), + Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n), + // A `macro_` item that isn't numbered isn't one this build wrote; it goes unread. + Some((_, None)) => {} } - match stash::get_json(wb, key).ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) { + } + wb.names = kept; + // Past the macro limit, the rest are counted without being read. + bad = bad.saturating_add(stored.len().saturating_sub(gridcraft_model::MAX_MACROS)); + let mut found = Vec::new(); + let mut item = Workbook::new(); + for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) { + item.names = names; + match stash::get_json(&item, &key).ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) { Ok(m) => found.push(m), Err(_) => bad += 1, } } - for key in stash::keys(wb, MACRO_KEY) { - stash::remove(wb, &key); - } // Macros that came as a field (the JSON format) go through the same checks. let mut all = std::mem::take(&mut wb.macros); all.extend(found); @@ -152,6 +158,13 @@ pub fn load(wb: &mut Workbook) -> Vec { warnings } +/// The item key (`macro_`, lower-cased) of a hidden macro name, and `n` when it is a number. +fn macro_item(n: &DefinedName) -> Option<(String, Option)> { + let key = stash::item_key(&n.name).filter(|k| n.scope.is_none() && k.starts_with(MACRO_KEY))?; + let i = key.strip_prefix(MACRO_KEY).and_then(|i| i.parse().ok()); + Some((key, i)) +} + /// `name`, or `name (2)`, `name (3)`… if a macro already has it. A long name is shortened to make /// room for the suffix, so the result stays within [`gridcraft_model::MAX_MACRO_NAME`] characters. pub fn unique_name(wb: &Workbook, name: &str) -> String { diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index e67479eab..189bf752e 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -234,3 +234,23 @@ fn duplicate_names_at_the_length_limit_survive_save_and_reopen() { assert_eq!(again.macros, first.macros, "{file}"); } } + +#[test] +#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm +fn many_hidden_macro_names_are_read_in_one_pass() { + // A hostile file with many damaged macro items: only the first MAX_MACROS are read, and the + // names are gathered and removed in one pass instead of one scan of every name per item. + let mut wb = Workbook::new(); + let items = 20_000; + for i in 0..items { + wb.names.push(hidden(&format!("_gc_macro_{i}_0"), "{not json")); + } + wb.names.push(hidden("_gc_other_0", "kept")); + let start = std::time::Instant::now(); + let warnings = load(&mut wb); + let took = start.elapsed(); + assert!(wb.macros.is_empty()); + assert_eq!(names_of(&wb), ["_gc_other_0"]); + assert!(warnings.len() == 1 && warnings[0].starts_with(&format!("{items} macros")), "{warnings:?}"); + assert!(took < std::time::Duration::from_secs(3), "{took:?}"); +} From 3bc1d91636c15e5dce4897c0ea663360e672007d Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:09:52 -0400 Subject: [PATCH 022/166] Engine: macro tokens, resolved to A1 references when a step runs Step parameters can name where they act through tokens that are worked out when the step runs, so a macro made on one block of data works on another: a declared input (Input, or any other input's name), Output (the output rule's anchor), Row or Column (the current item of the Repeat step around the step) and Name:Total (a defined name or a table's data). Methods narrow or move the range: col(n) or col("Header"), row(n), cols(a..b), rows(2..), cell(r,c) relative to the top-left cell, offset(r,c), resize(h,w), data (without the header row), below(n) and right(n). Methods chain. A token is written as {"$ref": "..."} anywhere, as the whole string of a reference parameter (cell, range, target, keys ending in Cell or Range and similar), or as {{...}} inside any string, which is how a formula refers to its input. Other text parameters are never read as tokens, so a cell can still hold the word Row and a chart can still be of type column. Every token becomes A1 text, sheet-qualified when it is on another sheet, before the command runs, so no existing command changes. Plain A1 references in a step stay absolute. A reference that runs past the edge of the sheet is reported apart from other mistakes, so the footprint check can tell the two apart. --- crates/engine/src/macros/mod.rs | 2 + crates/engine/src/macros/resolve.rs | 650 ++++++++++++++++++++++++++++ 2 files changed, 652 insertions(+) create mode 100644 crates/engine/src/macros/resolve.rs diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 38016049d..fee539201 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -14,6 +14,8 @@ use serde_json::{Value as Json, json}; use crate::cmd::stash; +pub mod resolve; + /// The hidden-data key prefix of a stored macro (`macro_`). const MACRO_KEY: &str = "macro_"; diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs new file mode 100644 index 000000000..8abf68ad4 --- /dev/null +++ b/crates/engine/src/macros/resolve.rs @@ -0,0 +1,650 @@ +//! Macro tokens: references in step parameters that are worked out when the step runs, so a +//! macro made on one block of data works on another. +//! +//! A token is a base followed by methods: +//! +//! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or +//! `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or +//! a table's data); +//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by header), `.row(n)`, +//! `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the top-left cell; any whole numbers, +//! so `cell(0,1)` is the cell above), `.offset(r,c)`, `.resize(h,w)`, `.data` (without the +//! header row), `.below(n)` and `.right(n)` (the n rows below or columns right of the range). +//! +//! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole +//! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending +//! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`). +//! Each becomes A1 text before the command runs, so commands never see a token. Plain A1 +//! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor +//! does. + +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use gridcraft_model::{RepeatOf, Workbook}; +use serde_json::{Map, Value as Json}; + +/// A resolved reference: a block of cells on a sheet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Place { + pub sheet: usize, + pub range: RangeRef, +} + +/// Why a reference couldn't be worked out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResolveError { + /// It runs past the edge of the sheet. + Edge(String), + /// Anything else (an unknown input, a malformed token). + Bad(String), +} + +impl ResolveError { + pub fn message(&self) -> &str { + match self { + ResolveError::Edge(m) | ResolveError::Bad(m) => m, + } + } +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +type R = Result; + +fn bad(msg: impl Into) -> R { + Err(ResolveError::Bad(msg.into())) +} + +/// What tokens refer to while a step runs. +#[derive(Clone, Copy, Debug)] +pub struct Env<'a> { + pub wb: &'a Workbook, + /// The sheet a reference without a sheet name is on. + pub active_sheet: usize, + /// Declared inputs, with whether each has a header row. + pub inputs: &'a [(String, Place, bool)], + pub output: Option, + /// The innermost Repeat step's current row or column. + pub item: Option<(RepeatOf, Place)>, +} + +/// Parameter keys that hold references, where a whole-string token is resolved. Elsewhere a +/// string is text (a cell may hold "Row", a chart's type is "column"), and a token needs +/// `{"$ref": …}` or `{{…}}`. +const REF_KEYS: &[&str] = &[ + "active", + "anchor", + "area", + "areas", + "at", + "cell", + "cells", + "changing", + "copyTo", + "destination", + "from", + "fromCell", + "location", + "objective", + "over", + "range", + "ranges", + "reference", + "resultCells", + "source", + "sources", + "target", + "to", + "variables", +]; + +/// Whether a parameter called `key` holds a reference. +pub fn is_ref_key(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end)) +} + +/// The most nesting [`resolve_params`] walks into. +const MAX_JSON_DEPTH: usize = 64; + +/// Whether `text` starts like a token (its base is one this environment knows). +pub fn is_token(text: &str, env: &Env) -> bool { + let t = text.trim(); + if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { + return true; + } + let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum(); + let base = t.get(..n).unwrap_or(""); + is_builtin(base) || env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)) +} + +fn is_builtin(base: &str) -> bool { + ["Output", "Row", "Column"].iter().any(|b| b.eq_ignore_ascii_case(base)) +} + +/// Resolves a token, or a plain reference (`B2`, `Sheet2!A1:C3`, a defined name). +pub fn resolve_ref(text: &str, env: &Env) -> R { + let t = text.trim(); + if is_token(t, env) { + return resolve_token(t, env); + } + plain_ref(t, env) +} + +/// `A1`, `'My Sheet'!A1:B2` or a defined name or table. +fn plain_ref(t: &str, env: &Env) -> R { + let (sheet, body) = crate::cmd::split_sheet(t); + let si = match &sheet { + Some(name) => env.wb.sheet_index(name).ok_or_else(|| ResolveError::Bad(format!("There is no sheet named '{name}'.")))?, + None => env.active_sheet, + }; + if let Some(range) = RangeRef::parse(body) { + return Ok(Place { sheet: si, range }); + } + if sheet.is_none() { + return named(t, env); + } + bad(format!("'{t}' isn't a reference.")) +} + +/// A defined name (by its formula) or a table (its data rows). +fn named(name: &str, env: &Env) -> R { + if let Some(n) = env.wb.name(name, env.active_sheet) { + let f = n.formula.trim().trim_start_matches('=').replace('$', ""); + let (sheet, body) = crate::cmd::split_sheet(&f); + let si = match sheet { + Some(s) => env.wb.sheet_index(&s), + None => n.scope.or(Some(env.active_sheet)), + }; + if let (Some(sheet), Some(range)) = (si, RangeRef::parse(body)) { + return Ok(Place { sheet, range }); + } + return bad(format!("The name '{name}' doesn't refer to a range.")); + } + if let Some((si, ti)) = env.wb.table(name) + && let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti)) + { + return Ok(Place { sheet: si, range: t.data_range().unwrap_or(t.range) }); + } + bad(format!("There is no name or table called '{name}'.")) +} + +/// Resolves a token (see the module docs). +pub fn resolve_token(text: &str, env: &Env) -> R { + let t = text.trim(); + let mut p = Parser { s: t, at: 0 }; + let (mut place, mut header) = p.base(env)?; + while p.eat('.') { + let name = p.ident(); + if name.is_empty() { + return bad(format!("'{t}': expected a method name after '.'.")); + } + let args = if p.eat('(') { p.args(t)? } else { vec![] }; + let r = place.range; + let range = match (name.to_ascii_lowercase().as_str(), args.as_slice()) { + ("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?, + ("col", [Arg::Text(h)]) => { + let c = header_col(env, place, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?; + sub(r, None, Some((c, c)), t)? + } + ("row", [Arg::Int(n)]) => sub(r, Some((*n, *n)), None, t)?, + ("cols", [Arg::Span(a, b)]) => sub(r, None, Some(span(*a, *b, i64::from(r.width()))), t)?, + ("rows", [Arg::Span(a, b)]) => sub(r, Some(span(*a, *b, i64::from(r.height()))), None, t)?, + ("cell", [Arg::Int(row), Arg::Int(col)]) => { + let c = shift(r.start, row - 1, col - 1, t)?; + RangeRef::cell(c) + } + ("offset", [Arg::Int(dr), Arg::Int(dc)]) => RangeRef::new(shift(r.start, *dr, *dc, t)?, shift(r.end, *dr, *dc, t)?), + ("resize", [Arg::Int(h), Arg::Int(w)]) => { + if *h < 1 || *w < 1 { + return bad(format!("'{t}': resize needs at least 1 row and 1 column.")); + } + RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?) + } + ("data", []) => { + if header && r.height() > 1 { + RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) + } else { + r + } + } + ("below", []) | ("below", [Arg::Int(_)]) => { + let n = arg_or_one(&args); + let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?; + RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?) + } + ("right", []) | ("right", [Arg::Int(_)]) => { + let n = arg_or_one(&args); + let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?; + RangeRef::new(left, shift(r.end, 0, n, t)?) + } + _ => { + return bad(format!( + "'{t}': '{name}' with these arguments isn't a token method (col, row, cols, rows, cell, offset, resize, data, below, right)." + )); + } + }; + // After a method the result is plain cells: `data` and `col("…")` no longer apply. + header = false; + place = Place { sheet: place.sheet, range }; + } + p.skip_ws(); + if p.at < p.s.len() { + return bad(format!("'{t}' isn't a token: unexpected '{}'.", p.s.get(p.at..).unwrap_or(""))); + } + Ok(place) +} + +fn arg_or_one(args: &[Arg]) -> i64 { + match args.first() { + Some(Arg::Int(n)) => (*n).max(1), + _ => 1, + } +} + +/// `a..b` with open ends filled from 1 and `len`. +fn span(a: Option, b: Option, len: i64) -> (i64, i64) { + (a.unwrap_or(1), b.unwrap_or(len)) +} + +/// Rows `rows` and columns `cols` (1-based, inclusive) of `r`; `None` keeps them all. +fn sub(r: RangeRef, rows: Option<(i64, i64)>, cols: Option<(i64, i64)>, t: &str) -> R { + let (h, w) = (i64::from(r.height()), i64::from(r.width())); + let (r0, r1) = rows.unwrap_or((1, h)); + let (c0, c1) = cols.unwrap_or((1, w)); + if r0 < 1 || c0 < 1 || r1 < r0 || c1 < c0 || r1 > h || c1 > w { + return bad(format!("'{t}' is outside the range it starts from ({} rows by {} columns).", h, w)); + } + Ok(RangeRef::new(shift(r.start, r0 - 1, c0 - 1, t)?, shift(r.start, r1 - 1, c1 - 1, t)?)) +} + +fn shift(c: CellRef, dr: i64, dc: i64, t: &str) -> R { + let row = i64::from(c.row).checked_add(dr); + let col = i64::from(c.col).checked_add(dc); + match (row, col) { + (Some(row), Some(col)) if (0..i64::from(MAX_ROWS)).contains(&row) && (0..i64::from(MAX_COLS)).contains(&col) => { + Ok(CellRef::new(row as u32, col as u32)) + } + (Some(row), Some(_)) if row < 0 => Err(ResolveError::Edge(format!("'{t}' runs past the top of the sheet."))), + (Some(row), Some(_)) if row >= i64::from(MAX_ROWS) => Err(ResolveError::Edge(format!("'{t}' runs past the last row of the sheet."))), + _ => Err(ResolveError::Edge(format!("'{t}' runs past the edge of the sheet."))), + } +} + +/// The 1-based column of `place` whose header (first row) reads `h`. +fn header_col(env: &Env, place: Place, h: &str) -> Option { + let sh = env.wb.sheet(place.sheet)?; + let r = place.range; + (r.start.col..=r.end.col) + .position(|c| crate::display::cell_text(env.wb, sh, CellRef::new(r.start.row, c)).trim().eq_ignore_ascii_case(h.trim())) + .map(|i| i as i64 + 1) +} + +#[derive(Debug)] +enum Arg { + Int(i64), + Span(Option, Option), + Text(String), +} + +struct Parser<'a> { + s: &'a str, + at: usize, +} + +impl Parser<'_> { + fn rest(&self) -> &str { + self.s.get(self.at..).unwrap_or("") + } + fn skip_ws(&mut self) { + let n = self.rest().len() - self.rest().trim_start().len(); + self.at += n; + } + fn eat(&mut self, c: char) -> bool { + self.skip_ws(); + if self.rest().starts_with(c) { + self.at += c.len_utf8(); + true + } else { + false + } + } + fn ident(&mut self) -> String { + self.skip_ws(); + let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum(); + let out = self.rest().get(..n).unwrap_or("").to_string(); + self.at += n; + out + } + fn int(&mut self) -> Option { + self.skip_ws(); + let neg = self.rest().starts_with('-'); + let start = self.at + usize::from(neg); + let digits: usize = self.s.get(start..).unwrap_or("").chars().take_while(char::is_ascii_digit).count(); + if digits == 0 || digits > 9 { + return None; + } + let n: i64 = self.s.get(start..start + digits)?.parse().ok()?; + self.at = start + digits; + Some(if neg { -n } else { n }) + } + /// The base of a token and whether it has a header row. + fn base(&mut self, env: &Env) -> R<(Place, bool)> { + self.skip_ws(); + if self.rest().get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { + self.at += 5; + // A name runs to the first `.` followed by a method, or the end. + let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\')).map(char::len_utf8).sum(); + let name = self.rest().get(..n).unwrap_or("").to_string(); + self.at += n; + if name.is_empty() { + return bad("'Name:' needs a defined name after it."); + } + return Ok((named(&name, env)?, false)); + } + let base = self.ident(); + if base.eq_ignore_ascii_case("Output") { + return env.output.map(|p| (p, false)).ok_or_else(|| ResolveError::Bad("'Output' is used, but the macro has no output anchor.".into())); + } + if base.eq_ignore_ascii_case("Row") || base.eq_ignore_ascii_case("Column") { + return match env.item { + Some((_, p)) => Ok((p, false)), + None => bad(format!("'{base}' only means something inside a Repeat step.")), + }; + } + match env.inputs.iter().find(|(n, ..)| n.eq_ignore_ascii_case(&base)) { + Some((_, p, header)) => Ok((*p, *header)), + None => bad(format!("'{base}' isn't an input of this macro.")), + } + } + fn args(&mut self, t: &str) -> R> { + let mut out = Vec::new(); + if self.eat(')') { + return Ok(out); + } + loop { + self.skip_ws(); + if self.rest().starts_with('"') { + self.at += 1; + let n = self.rest().find('"').ok_or_else(|| ResolveError::Bad(format!("'{t}': a quote isn't closed.")))?; + out.push(Arg::Text(self.rest().get(..n).unwrap_or("").to_string())); + self.at += n + 1; + } else { + let a = self.int(); + self.skip_ws(); + if self.rest().starts_with("..") { + self.at += 2; + let b = self.int(); + out.push(Arg::Span(a, b)); + } else { + out.push(Arg::Int(a.ok_or_else(|| ResolveError::Bad(format!("'{t}': expected a whole number.")))?)); + } + } + if out.len() > 4 { + return bad(format!("'{t}': too many arguments.")); + } + if self.eat(')') { + return Ok(out); + } + if !self.eat(',') { + return bad(format!("'{t}': expected ',' or ')'.")); + } + } + } +} + +/// `place` as A1 text, with the sheet name when it isn't the active sheet. +pub fn a1(place: Place, env: &Env) -> String { + if place.sheet == env.active_sheet { + return place.range.a1(); + } + let name = env.wb.sheet(place.sheet).map(|s| s.name.as_str()).unwrap_or(""); + format!("{}!{}", quote_sheet(name), place.range.a1()) +} + +/// `Sheet1` or `'My Sheet'` (quotes doubled). +pub fn quote_sheet(name: &str) -> String { + if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') && !name.starts_with(|c: char| c.is_ascii_digit()) { + name.to_string() + } else { + format!("'{}'", name.replace('\'', "''")) + } +} + +/// Step parameters with every token replaced by A1 text. +pub fn resolve_params(params: &Json, env: &Env) -> R { + walk(params, None, env, 0) +} + +fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { + if depth > MAX_JSON_DEPTH { + return bad("The step's parameters nest too deeply."); + } + Ok(match v { + Json::Object(o) => { + if o.len() == 1 + && let Some(Json::String(t)) = o.get("$ref") + { + return Ok(Json::String(a1(resolve_ref(t, env)?, env))); + } + let mut out = Map::new(); + for (k, x) in o { + out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?); + } + Json::Object(out) + } + // Items of a list keep their key's kind (`ranges: ["Input", …]`). + Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::>>()?), + Json::String(s) => { + if key.is_some_and(is_ref_key) && is_token(s, env) { + Json::String(a1(resolve_token(s, env)?, env)) + } else { + Json::String(interpolate(s, env)?) + } + } + other => other.clone(), + }) +} + +/// Replaces each `{{token}}` in `s`. +pub fn interpolate(s: &str, env: &Env) -> R { + if !s.contains("{{") { + return Ok(s.to_string()); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find("{{") { + out.push_str(rest.get(..i).unwrap_or("")); + let after = rest.get(i + 2..).unwrap_or(""); + let Some(j) = after.find("}}") else { + out.push_str(rest.get(i..).unwrap_or("")); + return Ok(out); + }; + let token = after.get(..j).unwrap_or(""); + out.push_str(&a1(resolve_ref(token, env)?, env)); + rest = after.get(j + 2..).unwrap_or(""); + } + out.push_str(rest); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn wb() -> Workbook { + let mut wb = Workbook::new(); + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("My Data"))); + let sh = wb.sheet_mut(0).unwrap(); + for (i, h) in ["Name", "Amount", "Date"].iter().enumerate() { + sh.set_value(CellRef::new(1, 1 + i as u32), gridcraft_core::Value::text(*h)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Total".into(), + scope: None, + formula: "Sheet1!$F$20".into(), + comment: String::new(), + hidden: false, + }); + wb.names.push(gridcraft_model::DefinedName { + name: "Rates".into(), + scope: None, + formula: "'My Data'!$A$1:$A$5".into(), + comment: String::new(), + hidden: false, + }); + wb + } + + fn place(a1: &str) -> Place { + Place { sheet: 0, range: RangeRef::parse(a1).unwrap() } + } + + fn check(env: &Env, token: &str, want: &str) { + match resolve_ref(token, env) { + Ok(p) => assert_eq!(a1(p, env), want, "{token}"), + Err(e) => panic!("{token}: {e}"), + } + } + + #[test] + fn every_token_resolves() { + let wb = wb(); + // Input B2:D11 with a header row; Other on another sheet. + let inputs = vec![ + ("Input".to_string(), place("B2:D11"), true), + ("Other".to_string(), Place { sheet: 1, range: RangeRef::parse("C3:C4").unwrap() }, false), + ]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("G2")), item: Some((RepeatOf::Rows, place("B5:D5"))) }; + for (token, want) in [ + ("Input", "B2:D11"), + ("input", "B2:D11"), + ("Input.col(2)", "C2:C11"), + ("Input.col(\"Amount\")", "C2:C11"), + ("Input.col(\"date\").data", "D2:D11"), + ("Input.data", "B3:D11"), + ("Input.data.col(2)", "C3:C11"), + ("Input.row(1)", "B2:D2"), + ("Input.rows(2..)", "B3:D11"), + ("Input.rows(2..4)", "B3:D5"), + ("Input.rows(..2)", "B2:D3"), + ("Input.cols(2..3)", "C2:D11"), + ("Input.cell(1,1)", "B2"), + ("Input.cell(3,2)", "C4"), + ("Input.cell(0,1)", "B1"), + ("Input.cell(12,3)", "D13"), + ("Input.offset(0,4)", "F2:H11"), + ("Input.offset(-1,-1)", "A1:C10"), + ("Input.cell(1,1).resize(2,3)", "B2:D3"), + ("Input.below()", "B12:D12"), + ("Input.below(2)", "B12:D13"), + ("Input.col(2).below", "C12"), + ("Input.right", "E2:E11"), + (" Input . col( 2 ) ", "C2:C11"), + ("Output", "G2"), + ("Output.offset(1,0)", "G3"), + ("Row", "B5:D5"), + ("Row.cell(1,2)", "C5"), + ("Column", "B5:D5"), + ("Name:Total", "F20"), + ("Name:Rates", "'My Data'!A1:A5"), + ("Name:Rates.cell(2,1)", "'My Data'!A2"), + ("Other", "'My Data'!C3:C4"), + ("B7", "B7"), + ("'My Data'!A1:B2", "'My Data'!A1:B2"), + ("Total", "F20"), + ] { + check(&env, token, want); + } + } + + #[test] + fn bad_tokens_say_why() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for (token, needle, edge) in [ + ("Input.col(4)", "outside the range", false), + ("Input.col(0)", "outside the range", false), + ("Input.rows(5..2)", "outside the range", false), + ("Input.col(\"Nope\")", "no column is headed", false), + ("Input.colour(1)", "isn't a token method", false), + ("Input.col(1", "expected", false), + ("Input.col(x)", "whole number", false), + ("Input.col(99999999999)", "whole number", false), + ("Input.", "method name", false), + ("Input junk", "unexpected", false), + ("Input.resize(0,1)", "at least 1 row", false), + ("Output", "no output anchor", false), + ("Row", "inside a Repeat", false), + ("Name:", "needs a defined name", false), + ("Name:Nothing", "no name or table", false), + ("Nowhere!A1", "no sheet named", false), + ("Input.offset(-2,0)", "past the top", true), + ("Input.offset(0,-2)", "edge of the sheet", true), + ("Input.offset(1048570,0)", "past the last row", true), + ("Input.cell(1,16384)", "edge of the sheet", true), + ] { + let e = resolve_ref(token, &env).unwrap_err(); + assert!(e.message().contains(needle), "{token}: {e}"); + assert_eq!(matches!(e, ResolveError::Edge(_)), edge, "{token}"); + } + } + + #[test] + fn params_resolve_tokens_in_reference_keys_and_braces_anywhere() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item: None }; + let p = json!({ + "cell": "Output", + "range": "Input.col(2)", + "ranges": ["Input.row(1)", "A1"], + "input": "Row", + "value": "Input", + "formula": "=SUM({{Input.data.col(2)}})/{{Name:Total}}", + "nested": {"target": {"$ref": "Input.cell(1,1)"}, "n": 3, "flag": true}, + "text": {"$ref": "Output"}, + "type": "column", + "sheet": "Input", + "rowInputCell": "Input.cell(1,1)", + "unclosed": "a {{b" + }); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!( + r, + json!({ + "cell": "F2", + "range": "C2:C11", + "ranges": ["B2:D2", "A1"], + "input": "Row", + "value": "Input", + "formula": "=SUM(C3:C11)/F20", + "nested": {"target": "B2", "n": 3, "flag": true}, + "text": "F2", + "type": "column", + "sheet": "Input", + "rowInputCell": "B2", + "unclosed": "a {{b" + }) + ); + // Deeply nested parameters are refused, not followed. + let mut deep = json!(1); + for _ in 0..100 { + deep = json!([deep]); + } + assert!(resolve_params(&deep, &env).is_err()); + assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err()); + } + + #[test] + fn sheet_names_are_quoted_when_needed() { + for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] { + assert_eq!(quote_sheet(name), want); + } + } +} From 4c6962a97c8ad812ffbe858f6a1583b43505391b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:24:08 -0400 Subject: [PATCH 023/166] Engine: a partial macro footprint no longer clears later writes; the run checks each step The footprint dry run stops at an approved file step and at Solver, but make_way took a partial footprint with no conflicts as proof that the run was safe, also after a shift. A macro that exported a CSV and then set B1 ran under onConflict fail or ask and overwrote B1 without asking. When the footprint is partial and the rule is not overwrite, the run's machine now checks every step as it runs: a step that writes over cells, sheets or objects that held data when the run started stops the run and rolls it back, with a confirmation under ask and an error otherwise. make_way now returns the machine for the run along with whether it shifted. --- crates/engine/src/macros/run.rs | 198 +++++++++++++++++++++++------- crates/engine/src/tests/macros.rs | 60 +++++++++ docs/macros.md | 6 +- 3 files changed, 220 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index aebaa8c90..1f4f39f74 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -9,7 +9,9 @@ //! 3. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run //! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it //! would write outside its inputs that already hold data are conflicts, handled by the output -//! rule: ask, fail, overwrite, or shift the data down or right (and check again). +//! rule: ask, fail, overwrite, or shift the data down or right (and check again). A dry run +//! that stops early (a file step, Solver) proves nothing about later steps, so the run then +//! checks each step as it goes. //! 4. **Run**: one undo step (`Session::grouped`); an error at any step rolls everything back and //! names the step. //! @@ -52,11 +54,14 @@ pub struct StepError { /// 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, } 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 } + StepError { step: step.to_string(), command: command.map(str::to_string), message: message.into(), edge: false, confirm: false } } fn resolve(step: &str, command: Option<&str>, e: ResolveError) -> StepError { @@ -73,7 +78,8 @@ impl StepError { } pub fn into_engine(self, name: &str) -> EngineError { - EngineError::Other(format!("Macro '{name}' stopped. {} Nothing was changed.", self.describe())) + let text = format!("Macro '{name}' stopped. {} Nothing was changed.", self.describe()); + if self.confirm { EngineError::Confirm(text) } else { EngineError::Other(text) } } } @@ -424,6 +430,85 @@ pub struct Machine { pub partial: Option<(String, String)>, /// The last step's resolved parameters (for the debugger). pub last_params: Option, + /// Set when the footprint was partial: each step is checked for writes over data as it runs. + guard: Option, +} + +/// Checks each step of a real run for writes over data the footprint couldn't see. +#[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>, + /// Where the footprint stopped: "step 2 (file.exportCsv writes a file)". + partial: String, + /// The conflict rule is ask. + ask: bool, +} + +impl Guard { + fn new(start: Arc, partial: &Json, ask: bool) -> Guard { + let text = |k: &str| partial.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); + let partial = format!("step {} ({})", text("step"), text("reason")); + Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, partial, 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; + } + let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == si && 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 { @@ -443,6 +528,7 @@ impl Machine { stopped: None, partial: None, last_params: None, + guard: None, }; m.settle(); m @@ -605,6 +691,7 @@ impl Machine { return Ok(()); } self.last_params = Some(resolved.clone()); + let prev = self.guard.as_ref().and_then(|_| s.docs.iter().find(|d| d.uid == self.doc)).map(|d| d.wb.clone()); let queued = s.ui_requests.len(); let r = s.execute(id, resolved); // Macros never open dialogs or web pages; messages for the person stay queued. @@ -622,6 +709,14 @@ impl Machine { if let Some(b) = blocked { return Err(fail(b)); } + if let (Some(g), Some(prev)) = (self.guard.as_mut(), prev) { + 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()))?; + let what = g.check(&prev, &after, &self.inputs); + if !what.is_empty() { + let msg = format!("It would write over data ({what}); the check before the run stopped at {} and couldn't see this.", g.partial); + return Err(StepError { confirm: g.ask, ..fail(msg) }); + } + } Ok(()) } } @@ -843,14 +938,8 @@ pub fn footprint(s: &mut Session, p: &Prepared) -> Result { /// without changing them (a formula written again), so a shift moves everything the macro writes. fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { let mut fp = Footprint::default(); - // Sheets are matched by name; what's left is matched in order (a renamed sheet). - 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(); - } - fp.sheets_added = spare.filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); + let (pairs, added) = pair_sheets(before, after); + fp.sheets_added = added.into_iter().filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); for (bi, ai) in pairs { let Some(bs) = before.sheets.get(bi) else { continue }; let Some(ai) = ai else { @@ -900,6 +989,19 @@ fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(Strin fp } +/// 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) { @@ -1012,44 +1114,56 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { /// The footprint check and the output rule, inside the run's undo group: an error when the macro /// would fail or write over data it may not, after shifting the data if the rule says so. Returns -/// whether it shifted. -pub fn make_way(s: &mut Session, b: &Begun) -> Result { +/// the machine for the run and whether it shifted. +/// +/// A partial footprint proves nothing about the steps it didn't reach, so then the machine +/// checks each step as it runs and stops the run at one that writes over data. +pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { let (m, rule) = (&b.mac, b.rule); + let mut machine = Machine::new(&b.prepared, false); + machine.time_limit_ms = b.time_limit_ms; if rule == OnConflict::Overwrite { - return Ok(false); + return Ok((machine, false)); } - let fp = footprint(s, &b.prepared)?; - if let Some(e) = fp.error { + let mut fp = footprint(s, &b.prepared)?; + if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } - if fp.conflict_count() == 0 { - return Ok(false); - } - let what = fp.describe_conflicts(); - match rule { - OnConflict::Ask => Err(EngineError::Confirm(format!( - "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", - m.name - ))), - OnConflict::Fail | OnConflict::Overwrite => { - Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))) - } - OnConflict::ShiftDown | OnConflict::ShiftRight => { - make_room(s, &fp, rule == OnConflict::ShiftDown)?; - let again = footprint(s, &b.prepared)?; - if let Some(e) = again.error { - return Err(e.into_engine(&m.name)); - } - if again.conflict_count() > 0 { - return Err(EngineError::Other(format!( - "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", - m.name, - again.describe_conflicts() + let mut shifted = false; + if fp.conflict_count() > 0 { + let what = fp.describe_conflicts(); + match rule { + OnConflict::Ask => { + return Err(EngineError::Confirm(format!( + "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", + m.name ))); } - Ok(true) + OnConflict::Fail | OnConflict::Overwrite => { + return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))); + } + OnConflict::ShiftDown | OnConflict::ShiftRight => { + make_room(s, &fp, rule == OnConflict::ShiftDown)?; + fp = footprint(s, &b.prepared)?; + if let Some(e) = fp.error.take() { + return Err(e.into_engine(&m.name)); + } + if fp.conflict_count() > 0 { + return Err(EngineError::Other(format!( + "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", + m.name, + fp.describe_conflicts() + ))); + } + shifted = true; + } } } + if let Some(p) = &fp.partial { + let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone(); + machine.guard = Some(Guard::new(start, p, rule == OnConflict::Ask)); + } + Ok((machine, shifted)) } /// Runs macro `name` on the active workbook: `{inputs?, onConflict?, overwrite?, approve?, @@ -1058,9 +1172,7 @@ 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 shifted = make_way(s, &b)?; - let mut machine = Machine::new(&b.prepared, false); - machine.time_limit_ms = b.time_limit_ms; + let (mut machine, shifted) = make_way(s, &b)?; let name = &b.mac.name; machine.run_to_end(s).map_err(|e| e.into_engine(name))?; match machine.stopped { diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index c3a2c4442..5f483e31d 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -474,3 +474,63 @@ fn macros_from_a_file_ask_before_running() { // Approved once per macro content: the second run doesn't ask. s.execute("macro.run", json!({"name": "Theirs"})).unwrap(); } + +#[test] +fn a_partial_footprint_doesnt_clear_later_writes() { + let dir = std::env::temp_dir().join(format!("gc-macro-partial-{}", 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"}}); + // The dry run stops at an approved file step or at Solver; the step after it writes over B1. + 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(); + let c = s.execute("macro.check", json!({"name": "Late", "inputs": "A1"})).unwrap(); + if c.get("needsApproval").is_none() { + assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["partial"]["step"]), (Some(0), &json!("1")), "{c}"); + } + 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}"); + 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"); + // After a shift, the steps past the file step are checked too. + let mut s = super::s(); + set(&mut s, "C1", "old"); + set(&mut s, "D1", "keep"); + let step = |cell: &str| json!({"command": "cell.set", "params": {"cell": cell, "input": "new"}}); + put( + &mut s, + json!({"name": "Shift", "permissions": ["file.exportCsv"], "output": {"onConflict": "shiftDown"}, "steps": [step("C1"), export, step("D1")]}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Shift", "approve": true})).unwrap_err().to_string(); + assert!(err.contains("Step 3 (cell.set): It would write over data (D1 on Sheet1)"), "{err}"); + assert_eq!((v(&s, "C1").display(), v(&s, "D1").display()), ("old".into(), "keep".into())); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "the shift is rolled back too"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/macros.md b/docs/macros.md index 424dd4f39..2b17c6a50 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -85,7 +85,11 @@ called Output, even when a token or input has that name. delete, are conflicts, handled by `onConflict` (or the macro's `output.onConflict`): `ask` (the call fails with `confirm: …`; `overwrite: true` answers it), `fail`, `overwrite`, `shiftDown` or `shiftRight` (Insert Cells makes room, then the footprint is checked again). Steps that touch - files, and Solver, end the dry run early; the footprint then says it is partial. + files, and Solver, end the dry run early; the footprint then says it is partial, and unless + the rule is `overwrite`, the run checks every step as it goes. A step that writes over cells, + sheets or objects that held data when the run started stops the run and rolls the workbook + back (a file already written stays): it asks (`confirm: …`) under `ask` and fails otherwise, + since shifting can't make room that late. 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). From 252ffa95b6a9ee8c67b1a738770348bc081295d4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:21:46 -0400 Subject: [PATCH 024/166] Engine: macro recorder keeps a recording when saving it fails Stopping a recording took the recorded commands and where recording started before saving the macro, so a failed save threw both away. For example, stopping with a name that holds a control character, or in a workbook at its macro limit, ended the recording, and stopping again with a good name found nothing to save. A failed save now puts the recording back, so stopping again saves what was recorded. --- crates/engine/src/cmd/macros.rs | 12 ++++++++- crates/engine/src/tests/macros.rs | 43 +++++++++++++++++++++++++++++++ docs/macros.md | 14 +++++----- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index e68dd92e6..953be1982 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -184,10 +184,20 @@ fn start_recording(s: &mut Session, relative: bool) -> Result { Ok(if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) }) } -/// Stops recording and saves what was recorded as a macro. +/// Stops recording and saves what was recorded as a macro. When saving fails (a bad name, a full +/// workbook) the recording goes on, so stopping again keeps what was recorded. fn stop_recording(s: &mut Session, p: &Json) -> Result { let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) }; let start = s.macro_recording.take(); + let saved = save_recording(s, p, &rec, start.clone()); + if saved.is_err() { + s.recorder = Some(rec); + s.macro_recording = start; + } + saved +} + +fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option) -> Result { let d = s.doc()?; let start = start.unwrap_or_else(|| RecordingStart { doc: d.uid, diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 941abe906..c32fa2d43 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -967,3 +967,46 @@ fn only_the_workbooks_a_debugged_run_touched_wait() { debug(&mut s, json!({"action": "stop"})); set(&mut s, "A1", "free"); } + +/// The commands of saved macro `name`, as `id params`. +fn recorded(s: &mut Session, name: &str) -> Vec { + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": name})).unwrap()).unwrap(); + crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect() +} + +#[test] +fn a_recording_is_kept_when_saving_it_fails() { + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + // A name the macro check refuses: still recording, and a good name saves what was recorded. + assert!(s.execute("macro.record", json!({"stop": true, "name": "Bad\u{1}name"})).is_err()); + assert!(s.is_recording()); + set(&mut s, "A2", "more"); + let r = s.execute("macro.record", json!({"stop": true, "name": "Good"})).unwrap(); + assert_eq!(r["saved"], "Good"); + assert!(!s.is_recording()); + assert_eq!(recorded(&mut s, "Good"), ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}", "cell.set {\"cell\":\"A2\",\"input\":\"more\"}"]); + + // A workbook at its macro limit: freeing a slot and stopping again saves it. + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "B1", "full"); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + let filler = wb.macros[0].clone(); + while wb.macros.len() < gridcraft_model::MAX_MACROS { + let mut m = filler.clone(); + m.name = format!("Filler{}", wb.macros.len()); + wb.macros.push(m); + } + d.wb = Arc::new(wb); + let err = s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap_err().to_string(); + assert!(err.contains("at most"), "{err}"); + assert!(s.is_recording()); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.pop(); + d.wb = Arc::new(wb); + s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap(); + assert_eq!(recorded(&mut s, "Late"), ["cell.set {\"cell\":\"B1\",\"input\":\"full\"}"]); +} diff --git a/docs/macros.md b/docs/macros.md index 79e6c02c5..8587ca877 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -119,12 +119,14 @@ of the step that just ran, and the cells changed so far. ## Recording `macro.record` toggles recording (`relative: false` for absolute references; `name` and -`description` when stopping). With relative references the selection when recording starts is the -example input: references in and near it become tokens, and the input's shape, header and column -types are inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so -playing them back sets them rather than flipping them. Commands a macro may not run are left out -and listed in the result; a macro run while recording is replaced by its steps when it has no -inputs, and an Automate script run by the script's steps. +`description` when stopping). When saving the recording fails (a name the check refuses, a workbook +at its macro limit), recording goes on, so stopping again keeps what was recorded. With relative +references the selection when recording starts is the example input: references in and near it +become tokens, and the input's shape, header and column types are inferred. Toggles such as Bold +are recorded with the state they set (`"on": true`), so playing them back sets them rather than +flipping them. Commands a macro may not run are left out and listed in the result; a macro run +while recording is replaced by its steps when it has no inputs, and an Automate script run by the +script's steps. ## Other commands and storage From 709a9952909e036b573cc34096dcaa815cbb6109 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:25:10 -0400 Subject: [PATCH 025/166] Model: one damaged macro no longer stops a JSON workbook opening Workbook::macros was read straight into typed macros, so one entry this build couldn't read (a step type from a later build, an unknown onConflict value, an entry that isn't an object) failed the whole file with "not a GridCraft JSON workbook". The field is now read as raw entries into Workbook::macros_read, and macros::load reads each one with parse_macro, as for xlsx: short-form steps are accepted, the depth limit is checked before deserializing, and a bad entry is dropped with the same warning. Only the first 1,000 entries are read, as in xlsx. --- crates/engine/src/macros/mod.rs | 11 ++++++++++- crates/engine/src/macros/tests.rs | 28 ++++++++++++++++++++++++++-- crates/model/src/macros.rs | 10 ++++++++++ crates/model/src/workbook.rs | 8 +++++++- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 155b9182f..6b2150e66 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -103,6 +103,15 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { pub fn load(wb: &mut Workbook) -> Vec { let mut warnings = Vec::new(); let mut bad = 0usize; + // Entries of the JSON format's field, each read on its own so a damaged one drops alone. + let read = std::mem::take(&mut wb.macros_read); + bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS)); + for v in read.iter().take(gridcraft_model::MAX_MACROS) { + match parse_macro(v) { + Ok(m) => wb.macros.push(m), + Err(_) => bad += 1, + } + } // One pass takes the hidden macro names out of the workbook, grouped by macro in saved order. let mut stored: BTreeMap<(u64, String), Vec> = BTreeMap::new(); let mut kept = Vec::new(); @@ -126,7 +135,7 @@ pub fn load(wb: &mut Workbook) -> Vec { Err(_) => bad += 1, } } - // Macros that came as a field (the JSON format) go through the same checks. + // All of them go through the same limits, and a clashing name gets a suffix. let mut all = std::mem::take(&mut wb.macros); all.extend(found); for m in all { diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 189bf752e..9d5b90a37 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -168,9 +168,10 @@ fn damaged_or_oversized_macros_are_dropped_with_a_warning() { wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1]; let bytes = crate::io::save_bytes(&wb, "many.json").unwrap(); let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap(); - assert_eq!(back.macros.len(), MAX_MACROS); + // Only the first MAX_MACROS entries are read, as in xlsx. + assert_eq!(back.macros.len(), MAX_MACROS - 1); assert_eq!(back.macros[0].name, "M1"); - assert!(warnings[0].starts_with("5 macros"), "{warnings:?}"); + assert!(warnings[0].starts_with("6 macros"), "{warnings:?}"); // A macro too large for the hidden names is refused, so a save never fails on it. let huge = "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS); @@ -254,3 +255,26 @@ fn many_hidden_macro_names_are_read_in_one_pass() { assert!(warnings.len() == 1 && warnings[0].starts_with(&format!("{items} macros")), "{warnings:?}"); assert!(took < std::time::Duration::from_secs(3), "{took:?}"); } + +#[test] +fn a_damaged_json_macro_drops_alone() { + // One macro this build can't read (a newer step type, an unknown value, the wrong shape) is + // dropped with the usual warning; the workbook and its other macros open. + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(gridcraft_core::CellRef::new(0, 0), gridcraft_core::Value::text("kept")); + let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap(); + v["macros"] = json!([ + {"name": "Good"}, + {"name": "Newer", "steps": [{"type": "fromALaterBuild"}]}, + {"name": "Odd", "output": {"onConflict": "explode"}}, + {"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]}, + "not a macro" + ]); + let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, ["Good", "Short"]); + assert!(matches!(&back.macros[1].steps[0].kind, StepKind::Command { id, .. } if id == "cell.set")); + assert!(back.macros_read.is_empty()); + assert!(warnings.len() == 1 && warnings[0].starts_with("3 macros"), "{warnings:?}"); + assert_eq!(back.sheet(0).unwrap().value(gridcraft_core::CellRef::new(0, 0)), gridcraft_core::Value::text("kept")); +} diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index caee00805..39f799615 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -119,6 +119,16 @@ impl Macro { } } +/// Reads the `macros` field of a JSON workbook as raw entries, whatever they hold, so they can be +/// checked one at a time. +pub(crate) fn entries<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(match serde_json::Value::deserialize(d)? { + serde_json::Value::Array(list) => list, + serde_json::Value::Null => vec![], + other => vec![other], + }) +} + /// A range the macro works on, with the shape and content it expects. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index b90186c40..4ac73a1ae 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -115,8 +115,13 @@ pub struct Workbook { pub csv_delimiter: Option, /// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them. /// The JSON format keeps them as this field; xlsx keeps them in hidden defined names. - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] pub macros: Vec, + /// The `macros` entries of a JSON file as read, before the engine checks each one on its own + /// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening. + /// Never written. + #[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::macros::entries")] + pub macros_read: Vec, /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. #[serde(skip)] pub preserved: Option>, @@ -151,6 +156,7 @@ impl Workbook { vba_project: None, csv_delimiter: None, macros: vec![], + macros_read: vec![], preserved: None, source_dir: None, } From 7a0f5917127299b633373d1351ba31de4c2acc4c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:30:33 -0400 Subject: [PATCH 026/166] Docs: macro tokens --- docs/macros.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index 1c755e329..2860aa33e 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,14 +1,13 @@ # Macros -> **Last updated:** 2026-10-21 · **Change:** major (created: the macro format and storage) +> **Last updated:** 2026-10-21 · **Change:** minor (macro tokens) 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. -This page describes the macro format and how macros are saved in a workbook. Step parameters -can hold tokens such as `Input` and `Output`; the engine does not resolve or run them in this -version. +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. ## A macro @@ -43,6 +42,26 @@ version. "Input column 2 (Amount) must hold numbers; C4 holds text." - **Limits**: 1,000 macros a workbook, 10,000 steps a macro, and Repeat and If nested 4 deep. +## Tokens + +References in step parameters can be tokens, worked out when the step runs: + +| Token | Means | +|---|---| +| `Input`, or another input's name | the range the input is bound to | +| `Output` | the output rule's anchor | +| `Row`, `Column` | the current row or column of the Repeat around the step | +| `Name:Total` | a defined name, or a table's data | + +Methods narrow or move a range and chain: `col(2)` or `col("Amount")`, `row(1)`, `cols(2..3)`, +`rows(2..)`, `cell(r,c)` (relative to the top-left cell; `cell(0,1)` is the cell above), +`offset(r,c)`, `resize(h,w)`, `data` (without the header row), `below(n)` and `right(n)`. + +Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending +in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside +any string, such as a formula. Plain A1 references stay absolute; a `Name:` anchor follows +inserted rows. + ## Storage Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden From bf456c9ff8465b0c9b65ea921b19316b49d73dc2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:25:34 -0400 Subject: [PATCH 027/166] Engine: a macro's footprint dry runs keep to the run's timeLimit footprint always ran its dry run with the default 60 second limit, so macro.run with timeLimit 5 could run for over two minutes (a dry run, then another after a shift) and fail with "ran longer than 60 seconds". footprint now takes the run's clock and limit. make_way starts the run's clock before the dry runs, so the checks and the run share one limit, and macro.check takes timeLimit too. --- crates/engine/src/cmd/macros.rs | 2 +- crates/engine/src/macros/run.rs | 25 ++++++++++++++++--------- crates/engine/src/tests/macros.rs | 11 +++++++++++ docs/macros.md | 8 +++++--- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 2aba053b0..53856e720 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Check Macro", [], None, - "{name, inputs?} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}", + "{name, inputs?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}", has_doc, check ), diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 1f4f39f74..57a70562e 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -346,13 +346,13 @@ fn holds(v: &Value, wanted: gridcraft_model::ColType) -> &'static str { /// A stopwatch for the time limit. `Instant::now` traps on wasm, so there it measures nothing. #[derive(Clone, Copy, Debug)] -struct Stopwatch { +pub struct Stopwatch { #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, } impl Stopwatch { - fn start() -> 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() }; @@ -907,8 +907,8 @@ impl Saved { } /// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would -/// change. -pub fn footprint(s: &mut Session, p: &Prepared) -> Result { +/// change. The dry run stops once `time_limit_ms` has passed on `clock`. +pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64) -> Result { let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; s.set_active(i); let saved = Saved::take(s, p.doc); @@ -918,6 +918,7 @@ pub fn footprint(s: &mut Session, p: &Prepared) -> Result { d.touched = Some(Vec::new()); } let mut m = Machine::new(p, true); + (m.clock, m.time_limit_ms) = (clock, time_limit_ms); let outcome = m.run_to_end(s); let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), @@ -1107,14 +1108,15 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { 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 = 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); + 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 footprint check and the output rule, inside the run's undo group: an error when the macro /// would fail or write over data it may not, after shifting the data if the rule says so. Returns -/// the machine for the run and whether it shifted. +/// the machine for the run and whether it shifted. The dry runs count towards the run's time +/// limit. /// /// A partial footprint proves nothing about the steps it didn't reach, so then the machine /// checks each step as it runs and stops the run at one that writes over data. @@ -1125,7 +1127,7 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { if rule == OnConflict::Overwrite { return Ok((machine, false)); } - let mut fp = footprint(s, &b.prepared)?; + let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1144,7 +1146,7 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { } OnConflict::ShiftDown | OnConflict::ShiftRight => { make_room(s, &fp, rule == OnConflict::ShiftDown)?; - fp = footprint(s, &b.prepared)?; + fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1191,6 +1193,11 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { }) } +/// 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`, or `overwrite: true` (the answer to the /// question), else the macro's own. fn conflict_rule(p: &Json, m: &Macro) -> Result { @@ -1240,7 +1247,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { if !asks.is_empty() && !approved { return Ok(out); } - let fp = footprint(s, &prepared)?; + let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p))?; out["ok"] = json!(fp.error.is_none()); out["conflicts"] = json!(fp.conflict_count()); if let Some(e) = &fp.error { diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 5f483e31d..2855f11e6 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -356,6 +356,17 @@ fn macros_are_bounded() { 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}"); + // The footprint's dry run keeps to the time limit too, and so does the check's. + 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}"); + let c = s.execute("macro.check", json!({"name": "Spin", "timeLimit": 0.001})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("ran longer than"), "{c}"); } #[test] diff --git a/docs/macros.md b/docs/macros.md index 2b17c6a50..1e1b873fb 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -42,7 +42,9 @@ no macro debugger, recorder or management commands in this version, and no macro `nonEmpty` and `defaultRef`. 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, and Repeat and If nested 4 deep. +- **Limits**: 1,000 macros a workbook, 10,000 steps a macro, Repeat and If nested 4 deep, + 100,000 steps executed per run, and 60 seconds a run, footprint checks included (`timeLimit` + to change; the web build has no clock, so there only the step limit applies). ## Tokens @@ -94,8 +96,8 @@ called Output, even when a token or input has that name. step: `Step 3.1 (cell.set): …` (paths are 1-based; `2.1` is inside a Repeat, `4.then.1` inside an If). -`macro.check {name, inputs?}` reports the bound inputs, the output anchor, any approval needed and -the footprint, without changing anything. +`macro.check {name, inputs?, timeLimit?}` reports the bound inputs, the output anchor, any +approval needed and the footprint, without changing anything. ## Storage From 45e580ad915389186636aae24697517fac8e398e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:16:29 -0400 Subject: [PATCH 028/166] Engine: macro.debug steps through a macro as one undo step macro.debug runs a macro one step at a time. start checks the macro as macro.run does (policy, approval, inputs, footprint and output rule) and pauses before the first step; step runs one step; continue runs to the next breakpoint; runTo runs to a chosen step path, stopping at breakpoints on the way; stop ends the run and keeps its changes or rolls them back; state reports where it is. Breakpoints are the steps' own flags plus a list of paths given with any action (breakpointsOnly makes the list replace the flags), and they stop in every pass of a Repeat. The run lives on the session between calls, inside an undo group that spans them (group_begin and group_end), so a debugged run is one undo step "Run Macro: ", as a normal run is. A failing step ends the run and rolls it back; so does a Stop step that asks for a rollback. While a macro is being debugged, edits of the workbooks the run touched (its own, and any other a step changed) are refused at the top level with "Finish or stop the macro first"; other workbooks stay editable, and selecting and queries still work. Every answer gives the next step's path, kind, command, parameters as they would run now (tokens resolved), the Repeat pass, the parameters of the step that just ran, the steps executed and the cells changed so far. Each action gets its own time limit. If a command panics while a step runs, the session's panic guard rolls the debugged run back as well, so its undo group never stays open. --- crates/engine/src/cmd/macros.rs | 11 +- crates/engine/src/lib.rs | 9 + crates/engine/src/macros/debug.rs | 394 ++++++++++++++++++++++++++++++ crates/engine/src/macros/mod.rs | 1 + crates/engine/src/macros/run.rs | 43 ++++ crates/engine/src/tests/macros.rs | 225 +++++++++++++++++ 6 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/macros/debug.rs diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 53856e720..a14fc2842 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -1,5 +1,5 @@ //! 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. +//! tokens, runner, debugger and policy. Automate scripts (`automate.*`) are a separate, older feature. use serde_json::Value as Json; @@ -25,6 +25,15 @@ pub fn specs() -> Vec { has_doc, check ), + cmd!( + "macro.debug", + "Step Into", + [], + None, + "{action: start|step|continue|runTo|stop|state, name? and inputs?, onConflict?, approve? (start), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"], breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time as one undo step; edits of the workbooks it touched wait until it finishes or stops (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed, atBreakpoint?, error?}", + has_doc, + |s, p| crate::macros::debug::debug(s, p) + ), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index caac006bc..3d542469b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -325,6 +325,8 @@ pub struct Session { /// 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)>, + /// The macro being debugged, between debugger actions. + pub macro_debug: Option, /// Windows hidden with View › Hide. pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. @@ -409,6 +411,12 @@ impl Session { if let Err(why) = (spec.enabled)(self) { return Err(EngineError::Disabled(id.to_string(), why)); } + // While a macro is being debugged its undo group is open: other edits of the workbooks it + // touched wait. Other workbooks stay editable. + let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid))); + if self.depth == 0 && spec.undoable && id != "macro.debug" && locked { + return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into())); + } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); let params = if params.is_null() { Json::Object(Default::default()) } else { params }; let (first_group, group_epoch) = (self.next_group, self.group_epoch); @@ -436,6 +444,7 @@ impl Session { d.selection = sel.clone(); d.calc.rebuild(&d.wb); } + macros::debug::recover(self); if self.depth == 0 { self.trim_history(); } diff --git a/crates/engine/src/macros/debug.rs b/crates/engine/src/macros/debug.rs new file mode 100644 index 000000000..1e599ab01 --- /dev/null +++ b/crates/engine/src/macros/debug.rs @@ -0,0 +1,394 @@ +//! The macro debugger: a run that stops between steps. +//! +//! `macro.debug` starts a run that pauses before its first step, then steps, continues to the next +//! breakpoint, runs to a chosen step, or stops. The run lives on the session between calls (one +//! action per call) inside an undo group that spans them, so a debugged run is still +//! one undo step; Stop keeps the changes or rolls them back. While a macro is being debugged, +//! other edits of the workbooks it touched (its own, and any other its steps changed) are refused +//! ("Finish or stop the macro first"); other workbooks stay editable. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use gridcraft_model::Workbook; +use serde_json::{Value as Json, json}; + +use super::run::{Machine, StepError}; +use crate::{EngineError, GroupToken, Result, Session}; + +/// A macro being debugged. +pub struct Debugger { + pub name: String, + pub doc: u64, + /// Out of the session only while a step runs. + machine: Option, + token: Option, + /// Breakpoints set for this run (step paths), on top of the steps' own. + pub breakpoints: BTreeSet, + /// Only `breakpoints` stop the run; the steps' own flags are ignored (an editor that shows + /// its own breakpoints sends them all, so clearing one takes effect at once). + pub only_listed: bool, + /// The workbook when the run started, for "changed so far". + start: Arc, + /// Every step path in the macro, for checking `runTo` targets. + paths: BTreeSet, + /// The workbooks the run has touched (by uid): its own, and any other a step changed. Edits + /// of these wait until the run ends. + touched: BTreeSet, +} + +impl Debugger { + /// Whether edits of the workbook with this uid wait for the run to end. + pub fn locks(&self, doc: u64) -> bool { + self.touched.contains(&doc) + } +} + +/// Each open workbook's uid and its current version, to see which ones a step changes. +fn versions(s: &Session) -> Vec<(u64, Arc)> { + s.docs.iter().map(|d| (d.uid, d.wb.clone())).collect() +} + +/// Adds to the run's touched log the workbooks that changed since `before`. +fn note_touched(s: &mut Session, before: &[(u64, Arc)]) { + let changed: Vec = + s.docs.iter().filter(|d| before.iter().any(|(uid, wb)| *uid == d.uid && !Arc::ptr_eq(wb, &d.wb))).map(|d| d.uid).collect(); + if let Some(dbg) = s.macro_debug.as_mut() { + dbg.touched.extend(changed); + } +} + +/// The message for edits refused while a macro is being debugged. +pub const BUSY: &str = "Finish or stop the macro first"; + +enum Mode { + Step, + Continue, + RunTo(String), +} + +/// `macro.debug {name?, inputs?, action: start|step|continue|runTo|stop|state, step?, keep?, +/// breakpoints?}`. +pub fn debug(s: &mut Session, p: &Json) -> Result { + let action = match p.get("action") { + Some(Json::String(a)) => a.clone(), + // `{"action": {"runTo": {"step": "3"}}}` + Some(Json::Object(o)) => o.keys().next().cloned().unwrap_or_default(), + _ if s.macro_debug.is_some() => "state".into(), + _ => "start".into(), + }; + if action != "start" + && let (Some(d), Some(bps)) = (s.macro_debug.as_mut(), p.get("breakpoints")) + { + d.breakpoints = paths_param(bps)?; + if let Some(only) = p.get("breakpointsOnly").and_then(Json::as_bool) { + d.only_listed = only; + } + } + match action.as_str() { + "start" => start(s, p), + "step" => advance(s, Mode::Step), + "continue" => advance(s, Mode::Continue), + "runTo" => { + let target = p + .get("step") + .or_else(|| p.pointer("/action/runTo/step")) + .and_then(path_of) + .ok_or_else(|| bad("`runTo` needs `step`: the path of the step to stop at"))?; + let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?; + if !d.paths.contains(&target) { + return Err(bad(format!("macro '{}' has no step {target}", d.name))); + } + advance(s, Mode::RunTo(target)) + } + "stop" => stop(s, p.get("keep").and_then(Json::as_bool).unwrap_or(false)), + "state" => state(s), + other => Err(bad(format!("action must be start, step, continue, runTo, stop or state, not '{other}'"))), + } +} + +fn bad(msg: impl Into) -> EngineError { + crate::cmd::bad("macro.debug", msg) +} + +fn not_debugging() -> EngineError { + EngineError::Other("No macro is being debugged. Start one with action: start.".into()) +} + +/// A step path from JSON: `"2.1"` or `3`. +fn path_of(v: &Json) -> Option { + match v { + Json::String(t) if !t.trim().is_empty() => Some(t.trim().to_string()), + Json::Number(n) => n.as_u64().map(|n| n.to_string()), + _ => None, + } +} + +fn paths_param(v: &Json) -> Result> { + let list = v.as_array().ok_or_else(|| bad("`breakpoints` must be a list of step paths"))?; + list.iter().take(10_000).map(|x| path_of(x).ok_or_else(|| bad("a breakpoint is a step path such as \"3\" or \"2.1\""))).collect() +} + +fn start(s: &mut Session, p: &Json) -> Result { + if let Some(d) = &s.macro_debug { + return Err(EngineError::Other(format!("Macro '{}' is being debugged. Stop it first.", d.name))); + } + let name = p.get("name").and_then(Json::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("missing `name`"))?; + let b = super::run::begin(s, name, p)?; + let breakpoints = match p.get("breakpoints") { + Some(v) => paths_param(v)?, + None => BTreeSet::new(), + }; + let start_wb = s.doc()?.wb.clone(); + let token = s.group_begin(&format!("Run Macro: {}", b.mac.name))?; + // The run's machine: it checks each step for writes over data when the footprint was partial. + let mut machine = match super::run::make_way(s, &b) { + Ok((machine, _)) => machine, + Err(e) => { + let _ = s.group_rollback(token); + return Err(e); + } + }; + machine.restart_clock(); + let paths = all_paths(&b.mac.steps, ""); + s.macro_debug = Some(Debugger { + name: b.mac.name.clone(), + doc: b.prepared.doc, + machine: Some(machine), + token: Some(token), + breakpoints, + only_listed: p.get("breakpointsOnly").and_then(Json::as_bool).unwrap_or(false), + start: start_wb, + paths, + touched: BTreeSet::from([b.prepared.doc]), + }); + if s.macro_debug.as_ref().and_then(|d| d.machine.as_ref()).is_some_and(Machine::is_done) { + return finish(s, None); + } + state(s) +} + +fn all_paths(steps: &[gridcraft_model::Step], prefix: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for (i, st) in steps.iter().enumerate() { + let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) }; + match &st.kind { + gridcraft_model::StepKind::Repeat { body, .. } => out.extend(all_paths(body, &path)), + gridcraft_model::StepKind::If { then, otherwise, .. } => { + out.extend(all_paths(then, &format!("{path}.then"))); + out.extend(all_paths(otherwise, &format!("{path}.else"))); + } + _ => {} + } + out.insert(path); + } + out +} + +/// Runs one step (`Step`), or steps until a breakpoint, the target step or the end. +fn advance(s: &mut Session, mode: Mode) -> Result { + let d = s.macro_debug.as_mut().ok_or_else(not_debugging)?; + let breakpoints = d.breakpoints.clone(); + let only_listed = d.only_listed; + let mut machine = d.machine.take().ok_or_else(not_debugging)?; + machine.restart_clock(); + let before = versions(s); + let mut first = true; + let mut hit = false; + let mut outcome = Ok(()); + while !machine.is_done() { + if !first { + let Some((path, st)) = machine.current() else { break }; + if matches!(mode, Mode::Step) { + break; + } + if (st.breakpoint && !only_listed) || breakpoints.contains(&path) { + hit = true; + break; + } + if let Mode::RunTo(target) = &mode + && *target == path + { + break; + } + } + first = false; + outcome = machine.step(s); + if outcome.is_err() { + break; + } + } + let done = machine.is_done(); + if let Some(d) = s.macro_debug.as_mut() { + d.machine = Some(machine); + } + note_touched(s, &before); + if let Err(e) = outcome { + return finish(s, Some(e)); + } + if done { + return finish(s, None); + } + let mut out = state(s)?; + out["atBreakpoint"] = json!(hit); + Ok(out) +} + +/// Ends the run after its last step (kept as one undo step, unless a Stop step asked for a +/// rollback) or a failed step (rolled back). +fn finish(s: &mut Session, error: Option) -> Result { + let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) }; + let machine = d.machine.take(); + let changed = changed(s, &d); + let stopped = machine.as_ref().and_then(|m| m.stopped.clone()); + let executed = machine.as_ref().map_or(0, |m| m.executed); + let rollback = error.is_some() || matches!(stopped, Some((_, true))); + if let Some(token) = d.token.take() { + // The group is gone only if its workbook was closed; then there is nothing to end. + let _ = if rollback { s.group_rollback(token) } else { s.group_end(token).map(|_| ()) }; + } + let mut out = json!({"macro": d.name, "executed": executed}); + match (&error, &stopped) { + (Some(e), _) => { + out["state"] = json!("failed"); + out["error"] = serde_json::to_value(e).unwrap_or(Json::Null); + out["message"] = json!(format!("{} Nothing was changed.", e.describe())); + } + (None, Some((msg, rolled))) => { + out["state"] = json!("stopped"); + out["stopped"] = json!(msg); + out["kept"] = json!(!rolled); + } + (None, None) => out["state"] = json!("finished"), + } + if !rollback { + out["changed"] = json!(changed); + } + Ok(out) +} + +/// Stops the run: keeps what it did as one undo step, or rolls it back. +fn stop(s: &mut Session, keep: bool) -> Result { + let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) }; + let changed = changed(s, &d); + if let Some(token) = d.token.take() { + let _ = if keep { s.group_end(token).map(|_| ()) } else { s.group_rollback(token) }; + } + let executed = d.machine.as_ref().map_or(0, |m| m.executed); + let mut out = json!({"state": "stopped", "macro": d.name, "kept": keep, "executed": executed}); + if keep { + out["changed"] = json!(changed); + } + Ok(out) +} + +/// Where the run is: the next step with its parameters as they would run, the step that just +/// ran, and the cells changed so far. +fn state(s: &mut Session) -> Result { + let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?; + let machine = d.machine.as_ref().ok_or_else(not_debugging)?; + let wb = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()).ok_or(EngineError::NoDocument)?; + Ok(json!({ + "state": "paused", + "macro": d.name, + "current": machine.current_json(&wb), + "lastParams": machine.last_params, + "executed": machine.executed, + "breakpoints": d.breakpoints, + "changed": changed(s, d), + })) +} + +/// The cells changed since the run started, as `Sheet!A1:B2` ranges. +fn changed(s: &Session, d: &Debugger) -> Vec { + let Some(now) = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()) else { return vec![] }; + let mut out = Vec::new(); + for sh in &now.sheets { + let Some(before) = d.start.sheet_index(&sh.name).and_then(|i| d.start.sheets.get(i)) else { + out.push(format!("{} (new sheet)", sh.name)); + continue; + }; + if Arc::ptr_eq(before, sh) { + continue; + } + let mut cells = before.cells.diff(&sh.cells); + let q = super::resolve::quote_sheet(&sh.name); + out.extend(super::run::ranges(&mut cells).into_iter().map(|r| format!("{q}!{r}"))); + } + out +} + +/// After a command panicked while a macro was being debugged: if the run lost its place, roll +/// it back so its undo group doesn't stay open. +pub(crate) fn recover(s: &mut Session) { + if s.macro_debug.as_ref().is_some_and(|d| d.machine.is_none()) + && let Some(mut d) = s.macro_debug.take() + && let Some(token) = d.token.take() + { + let _ = s.group_rollback(token); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn a_run_that_lost_its_place_is_rolled_back() { + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "A2", "input": "y"}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + let start = s.doc().unwrap().wb.clone(); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + // As if a step had panicked with the machine out of the session. + s.macro_debug.as_mut().unwrap().machine = None; + super::recover(&mut s); + assert!(s.macro_debug.is_none()); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert!(!s.in_group(s.doc().unwrap().uid)); + s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap(); + } + + #[test] + fn a_workbook_in_the_touched_log_waits_until_the_run_ends() { + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "A2", "input": "y"}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + s.new_workbook(); + let other = s.doc().unwrap().uid; + s.set_active(0); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + assert!(!s.macro_debug.as_ref().unwrap().locks(other)); + // As if a step had changed the other workbook too. + let before = super::versions(&s); + let d = s.docs.iter_mut().find(|d| d.uid == other).unwrap(); + let mut wb = (*d.wb).clone(); + wb.props.title = "changed by the macro".into(); + d.wb = std::sync::Arc::new(wb); + super::note_touched(&mut s, &before); + assert!(s.macro_debug.as_ref().unwrap().locks(other)); + s.set_active(1); + assert!(s.execute("cell.set", json!({"cell": "B1", "input": "no"})).is_err()); + s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap(); + } +} diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 457f10987..b25f06cb1 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -14,6 +14,7 @@ use serde_json::{Value as Json, json}; use crate::cmd::stash; +pub mod debug; pub mod policy; pub mod resolve; pub mod run; diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 36fd1ac41..02a90b915 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -559,6 +559,49 @@ impl Machine { self.frames.is_empty() } + /// Starts the time limit again (the debugger gives each action its own). + pub fn restart_clock(&mut self) { + self.clock = Stopwatch::start(); + } + + /// The current step for the debugger: its path, kind, command, parameters as they would run + /// now, and the Repeat pass. + pub fn current_json(&self, wb: &Workbook) -> Json { + let Some((path, st)) = self.current() else { return Json::Null }; + let env = self.env(wb); + let mut out = json!({"step": path, "breakpoint": st.breakpoint}); + if !st.note.is_empty() { + out["note"] = json!(st.note); + } + match &st.kind { + StepKind::Command { id, params } => { + out["kind"] = json!("command"); + out["command"] = json!(id); + match resolve::resolve_params(params, &env) { + Ok(p) => out["params"] = p, + Err(e) => out["paramsError"] = json!(e.message()), + } + } + StepKind::Repeat { over, of, .. } => { + out["kind"] = json!("repeat"); + out["over"] = json!(resolve::resolve_ref(over, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string())); + out["of"] = json!(if *of == RepeatOf::Rows { "rows" } else { "columns" }); + } + StepKind::If { cell, .. } => { + out["kind"] = json!("if"); + out["cell"] = json!(resolve::resolve_ref(cell, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string())); + } + StepKind::Stop { message, .. } => { + out["kind"] = json!("stop"); + out["message"] = json!(message); + } + } + if let Some((at, count)) = self.iteration() { + out["iteration"] = json!({"at": at, "of": count}); + } + out + } + /// The step that runs next, with its path. pub fn current(&self) -> Option<(String, &Step)> { let f = self.frames.last()?; diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index a42a60dd7..207e4c944 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -409,6 +409,27 @@ fn a_token_in_a_path_needs_the_permission_and_approval() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn the_debugger_runs_a_token_path_only_with_the_permission_and_approval() { + let mut s = s(); + let dir = std::env::temp_dir().join(format!("gc-macro-debug-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 step = json!({"command": "file.exportCsv", "params": {"path": format!("{}/{{{{Input}}}}", dir.to_string_lossy())}}); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step]})); + let err = s.execute("macro.debug", json!({"action": "start", "name": "Export"})).unwrap_err().to_string(); + assert!(err.contains("writes a file"), "{err}"); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step], "permissions": ["file.exportCsv"]})); + assert!(matches!(s.execute("macro.debug", json!({"action": "start", "name": "Export"})), Err(EngineError::Approve(_)))); + assert_eq!(files(&dir), 0); + // Approved, it pauses before the step and writes the file only when the step runs. + s.execute("macro.debug", json!({"action": "start", "name": "Export", "approve": true, "inputs": "B2", "breakpoints": ["1"]})).unwrap(); + assert_eq!(files(&dir), 0, "paused before the step"); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(files(&dir), 1, "the debugger runs the approved step"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn the_policy_keeps_macros_away_from_files_and_the_app() { let mut s = s(); @@ -556,3 +577,207 @@ fn a_partial_footprint_doesnt_clear_later_writes() { assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "the shift is rolled back too"); let _ = std::fs::remove_dir_all(&dir); } + +// ---------------------------------------------------------------- debugger + +fn debug(s: &mut Session, p: Json) -> Json { + s.execute("macro.debug", p).unwrap() +} + +/// Four steps: two plain ones, a Repeat over the data rows (with a breakpoint on its body), and +/// a final one. +fn stepper(s: &mut Session) { + data(s); + put( + s, + json!({"name": "Walk", "inputs": [{"name": "Input", "header": true}], "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "E1", "input": "start"}}, + {"command": "cell.set", "params": {"cell": "Input.right.cell(1,1)", "input": "flag"}}, + {"type": "repeat", "over": "Input.data", "body": [ + {"command": "cell.set", "params": {"cell": "Row.right", "input": "seen"}, "breakpoint": true} + ]}, + {"command": "cell.set", "params": {"cell": "E9", "input": "end"}} + ]}), + ); +} + +#[test] +fn the_debugger_steps_continues_and_runs_to_a_step_as_one_undo_step() { + let mut s = s(); + stepper(&mut s); + let undo = undo_labels(&s); + let st = debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "action": "start"})); + assert_eq!(st["state"], "paused"); + assert_eq!( + st["current"], + json!({"step": "1", "kind": "command", "command": "cell.set", "params": {"cell": "E1", "input": "start"}, "breakpoint": false}) + ); + assert_eq!(st["executed"], 0); + + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["current"]["step"], "2"); + assert_eq!(st["current"]["params"]["cell"], "D2", "tokens resolve for the next step"); + assert_eq!(st["lastParams"], json!({"cell": "E1", "input": "start"})); + assert_eq!(st["changed"], json!(["Sheet1!E1"])); + assert_eq!(v(&s, "E1").display(), "start"); + + // Other edits wait; selecting cells and queries don't. + let r = s.execute("cell.set", json!({"cell": "A1", "input": "x"})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why == "Finish or stop the macro first"), "{r:?}"); + assert!(s.execute("macro.run", json!({"name": "Walk"})).is_err()); + assert!(s.execute("edit.undo", json!({})).is_err()); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + assert!(s.execute("macro.debug", json!({"name": "Walk", "action": "start"})).unwrap_err().to_string().contains("is being debugged")); + + // Continue stops at the breakpoint inside the Repeat, once per row. + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("3.1"), Some(true))); + assert_eq!(st["current"]["iteration"], json!({"at": 1, "of": 3})); + assert_eq!(st["current"]["params"]["cell"], "D3"); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!(st["current"]["iteration"], json!({"at": 2, "of": 3})); + // Run To stops at a breakpoint on the way, then at its step. + let st = debug(&mut s, json!({"action": "runTo", "step": "4", "breakpoints": []})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("3.1"), Some(true))); + assert_eq!(st["current"]["iteration"], json!({"at": 3, "of": 3})); + let st = debug(&mut s, json!({"action": "runTo", "step": "4"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("4"), Some(false))); + assert_eq!((v(&s, "D4").display(), v(&s, "D5").display()), ("seen".into(), "seen".into())); + // Breakpoints from the call: none left before the end. + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!(st["state"], "finished"); + assert_eq!(st["executed"], 7); + assert!(st["changed"].as_array().unwrap().contains(&json!("Sheet1!E9"))); + assert_eq!(v(&s, "E9").display(), "end"); + let after = undo_labels(&s); + assert_eq!(after.len(), undo.len() + 1, "{after:?}"); + assert_eq!(after.last().unwrap(), "Run Macro: Walk"); + // Edits work again; one undo takes the whole run back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E1").is_empty() && v(&s, "D3").is_empty() && v(&s, "E9").is_empty()); + set(&mut s, "A1", "free"); +} + +#[test] +fn a_breakpoint_list_can_replace_the_steps_own() { + let mut s = s(); + stepper(&mut s); + // Only step 4 stops the run: the flag on 3.1 is ignored. + let st = debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "breakpoints": ["4"], "breakpointsOnly": true})); + assert_eq!(st["current"]["step"], "1"); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("4"), Some(true))); + debug(&mut s, json!({"action": "stop"})); + // Clearing the list mid-run (an editor removing its last breakpoint) runs to the end. + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "breakpoints": ["3.1"], "breakpointsOnly": true})); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!(st["current"]["step"], "3.1"); + let st = debug(&mut s, json!({"action": "continue", "breakpoints": [], "breakpointsOnly": true})); + assert_eq!(st["state"], "finished"); +} + +#[test] +fn stopping_the_debugger_keeps_or_rolls_back() { + for keep in [false, true] { + let mut s = s(); + stepper(&mut s); + let (start, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s)); + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "breakpoints": ["2"]})); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("2"), Some(true))); + let st = debug(&mut s, json!({"action": "stop", "keep": keep})); + assert_eq!((st["state"].as_str(), st["kept"].as_bool()), (Some("stopped"), Some(keep))); + if keep { + assert_eq!(v(&s, "E1").display(), "start"); + assert_eq!(undo_labels(&s).len(), undo.len() + 1); + assert_eq!(st["changed"], json!(["Sheet1!E1"])); + } else { + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!(undo_labels(&s), undo); + } + set(&mut s, "A1", "free"); + assert!(s.execute("macro.debug", json!({"action": "step"})).unwrap_err().to_string().contains("No macro is being debugged")); + } +} + +#[test] +fn a_debugged_run_after_a_partial_footprint_stops_before_writing_over_data() { + let mut s = s(); + set(&mut s, "A1", "1"); + set(&mut s, "C1", "=A1"); + set(&mut s, "B1", "keep"); + 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"}}); + put(&mut s, json!({"name": "Late", "inputs": [{"name": "Input"}], "output": {"onConflict": "fail"}, "steps": [solver, late]})); + let start = s.doc().unwrap().wb.clone(); + debug(&mut s, json!({"name": "Late", "action": "start", "inputs": "A1"})); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "failed", "{st}"); + assert!(st["message"].as_str().unwrap().contains("It would write over data (B1 on Sheet1)"), "{st}"); + assert_eq!(v(&s, "B1").display(), "keep"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); +} + +#[test] +fn a_failing_step_ends_the_debugged_run_and_rolls_it_back() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Breaks", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "one"}}, + {"command": "cell.set", "params": {"cell": "Nowhere!A2", "input": "two"}}, + {"type": "stop", "message": "never"} + ]}), + ); + let start = s.doc().unwrap().wb.clone(); + debug(&mut s, json!({"name": "Breaks"})); + assert!(s.execute("macro.debug", json!({"action": "runTo", "step": "9"})).unwrap_err().to_string().contains("has no step 9")); + assert!(s.execute("macro.debug", json!({"action": "leap"})).is_err()); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "failed"); + assert_eq!(st["error"]["step"], "2"); + assert!(st["message"].as_str().unwrap().starts_with("Step 2 (cell.set):"), "{st}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + set(&mut s, "B1", "free again"); + // A run that ends at once (a Stop first) finishes on start. + put(&mut s, json!({"name": "Quick", "steps": [{"type": "stop", "message": "bye"}]})); + let st = debug(&mut s, json!({"name": "Quick"})); + assert_eq!(st["state"], "paused"); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!((st["state"].as_str(), st["stopped"].as_str()), (Some("stopped"), Some("bye"))); + put(&mut s, json!({"name": "Empty", "steps": []})); + assert_eq!(debug(&mut s, json!({"name": "Empty"}))["state"], "finished"); +} + +#[test] +fn only_the_workbooks_a_debugged_run_touched_wait() { + let mut s = s(); + stepper(&mut s); + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + // Another workbook stays editable, macros included. + s.new_workbook(); + set(&mut s, "A1", "elsewhere"); + put( + &mut s, + json!({"name": "Here", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A2", "input": "ran"}}]}), + ); + s.execute("macro.run", json!({"name": "Here"})).unwrap(); + assert_eq!(v(&s, "A2").display(), "ran"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A2").is_empty()); + // The debugged one waits. + s.set_active(0); + let r = s.execute("cell.set", json!({"cell": "A1", "input": "x"})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why == "Finish or stop the macro first"), "{r:?}"); + // The run carries on in its own workbook whichever one is in front. + s.set_active(1); + let st = debug(&mut s, json!({"action": "continue", "breakpoints": []})); + assert_eq!(st["state"], "paused"); + assert_eq!(s.active_index(), 0); + debug(&mut s, json!({"action": "stop"})); + set(&mut s, "A1", "free"); +} From c6dda8562f81241d5dcb109e9036208e19ac0aa2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:24:35 -0400 Subject: [PATCH 029/166] Engine: macro recorder keeps references on other sheets as they are The recorder made every unqualified reference relative to the example input, whatever sheet the step ran on. For example, starting a recording on Sheet1 with B2:C5 selected, activating Sheet2 and setting C4 recorded Input.cell(3,2), so playback wrote Sheet1 instead; a formula entered on Sheet2 was changed to refer to Sheet1's cells. Each step now keeps the sheet it ran on, and only references on the input's sheet become tokens. --- crates/engine/src/macros/recorder.rs | 62 ++++++++++++++++------------ crates/engine/src/tests/macros.rs | 35 ++++++++++++++++ docs/macros.md | 10 ++--- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index 56a64a72f..b15489873 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -9,9 +9,9 @@ //! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed //! between commands without one, and runs of `selection.set` collapse to the last; //! - with relative references (the default), the selection when recording started is the example -//! input: references in, next to or near it become tokens (`Input.col(2)`, `Input.below`, -//! `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's shape, -//! header and column types are inferred from it. +//! input: references on its sheet in, next to or near it become tokens (`Input.col(2)`, +//! `Input.below`, `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's +//! shape, header and column types are inferred from it. use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{ColType, InputKind, Macro, NamedInput, Step, Workbook}; @@ -85,39 +85,42 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & // Sheet and selection changes made without a command: where a command ran on another sheet, // or with another selection, than the one before it, say so (a command that moved the // selection itself does it again when played back, so the extra step is harmless). - let mut out: Vec<(String, Json, bool)> = Vec::new(); + // Each step keeps the name of the sheet it runs on. + let mut out: Vec<(String, Json, String, bool)> = Vec::new(); let (mut sheet, mut sel) = (start.sheet, start.selection.clone()); for (id, params, at_sheet, sheet_name, at_sel, kept) in cmds { if at_sheet != sheet { - out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), false)); + out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), false)); } // A recorded selection.set just before already put the selection there. let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set"); if (at_sel != sel || at_sheet != sheet) && !selected { - out.push(("selection.set".into(), selection_params(&at_sel), false)); + out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), false)); } (sheet, sel) = (at_sheet, at_sel); - out.push((id, params, kept)); + out.push((id, params, sheet_name, kept)); } // Runs of selection.set (or sheet.activate): the last one wins. - let mut collapsed: Vec<(String, Json, bool)> = Vec::new(); - for (id, params, kept) in out { + let mut collapsed: Vec<(String, Json, String, bool)> = Vec::new(); + for (id, params, on, kept) in out { if matches!(id.as_str(), "selection.set" | "sheet.activate") && let Some((last, ..)) = collapsed.last() && *last == id { collapsed.pop(); } - collapsed.push((id, params, kept)); + collapsed.push((id, params, on, kept)); } - // Relative references. + // Relative references. Only references on the input's sheet become tokens: on another sheet + // `C4` is that sheet's C4. let example = start.selection.current(); let mut used_input = false; let steps: Vec = collapsed .into_iter() - .map(|(id, params, kept)| { - let params = if start.relative && !kept { relative(¶ms, example, &start.sheet_name, &mut used_input, None) } else { params }; + .map(|(id, params, on, kept)| { + let here = on.eq_ignore_ascii_case(&start.sheet_name); + let params = if start.relative && !kept { relative(¶ms, example, &start.sheet_name, here, &mut used_input, None) } else { params }; Step::command(id, params) }) .collect(); @@ -133,20 +136,21 @@ fn selection_params(sel: &Selection) -> Json { json!({"range": ranges.join(","), "active": sel.active.a1()}) } -/// `params` with references near `example` made into tokens. -fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Option<&str>) -> Json { +/// `params` with references near `example` (on `sheet`) made into tokens; `here` says whether the +/// step runs on that sheet, so its unqualified references are on it. +fn relative(v: &Json, example: RangeRef, sheet: &str, here: bool, used: &mut bool, key: Option<&str>) -> Json { match v { Json::Object(o) => { let mut out = Map::new(); for (k, x) in o { - out.insert(k.clone(), relative(x, example, sheet, used, Some(k))); + out.insert(k.clone(), relative(x, example, sheet, here, used, Some(k))); } Json::Object(out) } - Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, used, key)).collect()), + Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, here, used, key)).collect()), Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => { // A list of areas ("A1:B2,D4") stays as it is unless every part has a token. - let parts: Option> = t.split(',').map(|part| ref_token(part, example, sheet)).collect(); + let parts: Option> = t.split(',').map(|part| ref_token(part, example, sheet, here)).collect(); match parts { Some(p) if p.len() == 1 => { *used = true; @@ -155,7 +159,7 @@ fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Opti _ => v.clone(), } } - Json::String(t) if t.starts_with('=') && example.count() > 1 => { + Json::String(t) if here && t.starts_with('=') && example.count() > 1 => { let (f, changed) = formula_tokens(t, example); *used |= changed; json!(f) @@ -164,11 +168,14 @@ fn relative(v: &Json, example: RangeRef, sheet: &str, used: &mut bool, key: Opti } } -/// The token for an A1 reference (on the example's sheet) near the example input. -fn ref_token(text: &str, example: RangeRef, sheet: &str) -> Option { +/// The token for an A1 reference on the example's sheet near the example input. `here` says whether +/// an unqualified reference is on that sheet. +fn ref_token(text: &str, example: RangeRef, sheet: &str, here: bool) -> Option { let (on, body) = crate::cmd::split_sheet(text.trim()); - if on.is_some_and(|s| !s.eq_ignore_ascii_case(sheet)) { - return None; + match on { + Some(on) if !on.eq_ignore_ascii_case(sheet) => return None, + None if !here => return None, + _ => {} } let t = RangeRef::parse(body)?; if t.is_full_cols() || t.is_full_rows() { @@ -367,10 +374,13 @@ mod tests { ("Z500", None), ("A:A", None), ] { - assert_eq!(ref_token(t, ex, "Sheet1").as_deref(), want, "{t}"); + assert_eq!(ref_token(t, ex, "Sheet1", true).as_deref(), want, "{t}"); } - assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1").as_deref(), Some("Input.cell(3,2)")); - assert_eq!(ref_token("Other!C4", ex, "Sheet1"), None); + assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", true).as_deref(), Some("Input.cell(3,2)")); + assert_eq!(ref_token("Other!C4", ex, "Sheet1", true), None); + // On another sheet only a reference qualified with the input's sheet is on it. + assert_eq!(ref_token("C4", ex, "Sheet1", false), None); + assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", false).as_deref(), Some("Input.cell(3,2)")); } #[test] diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index c32fa2d43..5b0b2ddbb 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1010,3 +1010,38 @@ fn a_recording_is_kept_when_saving_it_fails() { s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap(); assert_eq!(recorded(&mut s, "Late"), ["cell.set {\"cell\":\"B1\",\"input\":\"full\"}"]); } + +#[test] +fn a_relative_recording_keeps_references_on_another_sheet() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + data(&mut s); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + s.execute("home.bold", json!({"range": "B2:C2"})).unwrap(); + // On the other sheet, C4 and C3:C5 are that sheet's cells, not the input's. + s.execute("sheet.activate", json!({"sheet": "Two"})).unwrap(); + set(&mut s, "C4", "x"); + set(&mut s, "D2", "=SUM(C3:C5)"); + s.execute("macro.record", json!({"stop": true, "name": "Both"})).unwrap(); + let steps = recorded(&mut s, "Both"); + assert!(steps.contains(&"cell.set {\"cell\":\"C4\",\"input\":\"x\"}".to_string()), "{steps:?}"); + assert!(steps.contains(&"cell.set {\"cell\":\"D2\",\"input\":\"=SUM(C3:C5)\"}".to_string()), "{steps:?}"); + // Played back on another block of Sheet1, it writes Two's cells. + set(&mut s, "C4", ""); + set(&mut s, "D2", ""); + set(&mut s, "C3", "5"); + set(&mut s, "C5", "7"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + for (cell, input) in [("G10", "Who"), ("H10", "Paid"), ("G11", "A"), ("H11", "100"), ("G12", "B"), ("H12", "200"), ("G13", "C"), ("H13", "300")] { + set(&mut s, cell, input); + } + s.execute("macro.run", json!({"name": "Both", "inputs": "G10:H13", "onConflict": "overwrite"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(bold(&s, "G10") && bold(&s, "H10")); + // Nothing was written into or beside the input. + assert_eq!((v(&s, "H12").display(), v(&s, "I10").display()), ("200".into(), String::new())); + s.execute("sheet.activate", json!({"sheet": "Two"})).unwrap(); + assert_eq!((v(&s, "C4").display(), v(&s, "D2").display()), ("x".into(), "12".into())); +} diff --git a/docs/macros.md b/docs/macros.md index 8587ca877..6a30a1d56 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -122,11 +122,11 @@ of the step that just ran, and the cells changed so far. `description` when stopping). When saving the recording fails (a name the check refuses, a workbook at its macro limit), recording goes on, so stopping again keeps what was recorded. With relative references the selection when recording starts is the example input: references in and near it -become tokens, and the input's shape, header and column types are inferred. Toggles such as Bold -are recorded with the state they set (`"on": true`), so playing them back sets them rather than -flipping them. Commands a macro may not run are left out and listed in the result; a macro run -while recording is replaced by its steps when it has no inputs, and an Automate script run by the -script's steps. +on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and the input's shape, +header and column types are inferred. Toggles such as Bold are recorded with the state they set +(`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not +run are left out and listed in the result; a macro run while recording is replaced by its steps +when it has no inputs, and an Automate script run by the script's steps. ## Other commands and storage From ee7040a340e01ff50428c99b3531a7b6653694fc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:29:54 -0400 Subject: [PATCH 030/166] Docs: macro format and storage --- docs/macros.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/macros.md diff --git a/docs/macros.md b/docs/macros.md new file mode 100644 index 000000000..99050b0aa --- /dev/null +++ b/docs/macros.md @@ -0,0 +1,60 @@ +# Macros + +> **Last updated:** 2026-10-21 · **Change:** major (created: the macro format and storage) + +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. + +This page describes the macro format and how macros are saved in a workbook. Step parameters +can hold tokens such as `Input` and `Output`; the engine does not resolve or run them in this +version. + +## A macro + +```json +{ + "name": "Totals", + "description": "Adds a total next to the amounts", + "inputs": [{"name": "Input", "header": true, "cols": [2, 2], "colTypes": ["text", "number"]}], + "output": {"anchor": "Input.right(2).cell(1,2)", "onConflict": "ask"}, + "steps": [ + {"type": "command", "id": "cell.set", "params": {"cell": "Output", "input": "Total"}}, + {"type": "command", "id": "cell.set", "params": {"cell": "Output.offset(1,0)", "input": "=SUM({{Input.data.col(2)}})"}}, + {"type": "repeat", "over": "Input.data", "of": "rows", "max": 1000, "body": [ + {"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, + "then": [{"type": "command", "id": "home.bold", "params": {"range": "Row"}}]} + ]}, + {"type": "stop", "message": "Done."} + ], + "permissions": [] +} +``` + +- **Steps**: `command` (`id`, `params`), `repeat` (each row or column of `over`, at most `max` + times), `if` (`cell` compared with `value` by `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `empty`, + `notEmpty` or `contains`; `then` and `else` lists) and `stop` (`message`; `rollback: true` + undoes the run). Every step can carry `enabled`, `breakpoint` and `note`. The short form + `{"command": "cell.set", "params": {…}}` is accepted for a command step. +- **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum; + rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`), + `nonEmpty` and `defaultRef`. 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, and Repeat and If nested 4 deep. + +## Storage + +Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden +defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's +limits), which other spreadsheet applications keep but do not run. + +## Automate scripts and VBA + +Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`, +`deleteScript`) are an older, separate feature. They keep their commands, parameters, storage +(`_sc_scripts`) and results, and View › Macros (`view.macros`) still lists them. Macros neither +read nor change them; a workbook can hold a script and a macro with the same name. + +A VBA project in an `.xlsm` or `.xltm` file is preserved on save and never run. GridCraft macros +are stored apart from it. From ac7c1ae639ec6ced58fec5f9b88e6ea01e2fb19e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:26:37 -0400 Subject: [PATCH 031/166] Engine: a reference on a sheet named like a token stays a reference is_token looked only at the leading identifier, so on a workbook with a sheet called Output (or Row, Column, or the name of a declared input), "Output!A1:B2" was read as the Output token, and the step failed with "the macro has no output anchor" or "unexpected '!A1:B2'". This hit reference keys, $ref and {{...}} alike, including references the recorder writes for other sheets. A base followed by '!' is now a sheet name, and the text resolves as a plain reference. --- crates/engine/src/macros/resolve.rs | 36 ++++++++++++++++++++++++++++- docs/macros.md | 3 ++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index 8abf68ad4..e8c7b6613 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -110,7 +110,8 @@ pub fn is_ref_key(key: &str) -> bool { /// The most nesting [`resolve_params`] walks into. const MAX_JSON_DEPTH: usize = 64; -/// Whether `text` starts like a token (its base is one this environment knows). +/// Whether `text` starts like a token (its base is one this environment knows). A reference on a +/// sheet with such a name (`Output!A1`) is not a token. pub fn is_token(text: &str, env: &Env) -> bool { let t = text.trim(); if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { @@ -118,6 +119,9 @@ pub fn is_token(text: &str, env: &Env) -> bool { } let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum(); let base = t.get(..n).unwrap_or(""); + if t.get(n..).unwrap_or("").trim_start().starts_with('!') { + return false; + } is_builtin(base) || env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)) } @@ -641,6 +645,36 @@ mod tests { assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err()); } + #[test] + fn references_on_sheets_named_like_tokens_stay_references() { + // Sheets named after the built-in bases and after an input: `Output!A1` is a cell on the + // sheet Output, in a reference key, in `$ref` and in `{{…}}`. + let mut wb = Workbook::new(); + for name in ["Output", "Row", "Column", "Input", "Rates"] { + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new(name))); + } + let inputs = vec![("Input".to_string(), place("B2:D11"), true), ("Rates".to_string(), place("F2:F5"), false)]; + for output in [None, Some(place("H2"))] { + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output, item: Some((RepeatOf::Rows, place("B5:D5"))) }; + for (sheet, i) in [("Output", 1), ("Row", 2), ("Column", 3), ("Input", 4), ("Rates", 5)] { + for text in [format!("{sheet}!A1:B2"), format!("'{sheet}'!A1:B2")] { + let want = Place { sheet: i, range: RangeRef::parse("A1:B2").unwrap() }; + assert_eq!(resolve_ref(&text, &env), Ok(want), "{text}"); + let a = a1(want, &env); + let p = json!({"range": text, "target": {"$ref": text}, "formula": format!("=SUM({{{{{text}}}}})")}); + let r = resolve_params(&p, &env).unwrap(); + // A plain reference in a reference key is passed on as written. + assert_eq!(r, json!({"range": text, "target": a, "formula": format!("=SUM({a})")}), "{text}"); + } + } + assert!(!is_token("Output !A1", &env)); + // The bare names are still tokens. + check(&env, "Input.col(2)", "C2:C11"); + check(&env, "Rates", "F2:F5"); + check(&env, "Row", "B5:D5"); + } + } + #[test] fn sheet_names_are_quoted_when_needed() { for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] { diff --git a/docs/macros.md b/docs/macros.md index 2860aa33e..44bf572cf 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -60,7 +60,8 @@ Methods narrow or move a range and chain: `col(2)` or `col("Amount")`, `row(1)`, Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside any string, such as a formula. Plain A1 references stay absolute; a `Name:` anchor follows -inserted rows. +inserted rows. A reference with a sheet name is never a token, so `Output!A1` is a cell on a sheet +called Output, even when a token or input has that name. ## Storage From b51d588cac747db7ba0cc37953a6b01fbf7f1eaf Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:26:30 -0400 Subject: [PATCH 032/166] Engine: macros can't open, hide or show windows The policy denies window.* commands, but the View tab's window commands are view.*: a macro could run view.newWindow, which adds a window that the run's rollback doesn't remove, or view.hideWindow and view.unhideWindow, which change the session's hidden windows. The policy now denies those three. Arrange All changes nothing in the engine and Switch Windows only lists windows, so both stay allowed. --- crates/engine/src/macros/policy.rs | 12 ++++++++++-- crates/engine/src/tests/macros.rs | 2 ++ docs/macros.md | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs index 84bbc497b..8f23a5b40 100644 --- a/crates/engine/src/macros/policy.rs +++ b/crates/engine/src/macros/policy.rs @@ -30,8 +30,13 @@ const DENIED_PREFIXES: &[(&str, &str)] = &[ ]; /// 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")]; +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"]; @@ -97,6 +102,9 @@ mod tests { "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}"); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 2855f11e6..a928e9b53 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -419,6 +419,8 @@ fn the_policy_keeps_macros_away_from_files_and_the_app() { ("automate.runScript", json!({"name": "Script 1"})), ("edit.undo", json!({})), ("macro.run", json!({"name": "Self"})), + ("view.newWindow", json!({})), + ("view.hideWindow", json!({})), ] { put( &mut s, diff --git a/docs/macros.md b/docs/macros.md index 1e1b873fb..cf93148d3 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -75,8 +75,8 @@ called Output, even when a token or input has that name. `macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`: -1. **Policy.** File, app, window, automate and macro commands, and Undo and Redo, never run in a - macro. Reading or writing a file (`data.getData`, `data.fromTextCsv`, `insert.picture` or +1. **Policy.** File, app, window (including New Window, Hide and Unhide), automate and macro + commands, and Undo and Redo, never run in a macro. 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 ccea7a8db372a4fb553103f09268ffd3bb04200c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:19:50 -0400 Subject: [PATCH 033/166] Engine: macro debugger reports a step that panics as the step's failure When a debugged step panicked, the nested execute rolled the run back and dropped the debugger before advance could reattach the machine, so the caller got "No macro is being debugged" instead of the failed step. For example, stepping into a step whose command panics lost the step path and the panic message. advance now sees that the run was already rolled back and returns the usual failed result with the step error. --- crates/engine/src/macros/debug.rs | 65 ++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/macros/debug.rs b/crates/engine/src/macros/debug.rs index 1e599ab01..04da19834 100644 --- a/crates/engine/src/macros/debug.rs +++ b/crates/engine/src/macros/debug.rs @@ -188,6 +188,7 @@ fn all_paths(steps: &[gridcraft_model::Step], prefix: &str) -> BTreeSet /// Runs one step (`Step`), or steps until a breakpoint, the target step or the end. fn advance(s: &mut Session, mode: Mode) -> Result { let d = s.macro_debug.as_mut().ok_or_else(not_debugging)?; + let name = d.name.clone(); let breakpoints = d.breakpoints.clone(); let only_listed = d.only_listed; let mut machine = d.machine.take().ok_or_else(not_debugging)?; @@ -219,8 +220,16 @@ fn advance(s: &mut Session, mode: Mode) -> Result { } } let done = machine.is_done(); - if let Some(d) = s.macro_debug.as_mut() { - d.machine = Some(machine); + let executed = machine.executed; + match s.macro_debug.as_mut() { + Some(d) => d.machine = Some(machine), + // A step panicked and `recover` has already rolled the run back. + None => { + return match outcome { + Err(e) => Ok(failed(&name, executed, &e)), + Ok(()) => Err(not_debugging()), + }; + } } note_touched(s, &before); if let Err(e) = outcome { @@ -247,19 +256,17 @@ fn finish(s: &mut Session, error: Option) -> Result { // The group is gone only if its workbook was closed; then there is nothing to end. let _ = if rollback { s.group_rollback(token) } else { s.group_end(token).map(|_| ()) }; } + if let Some(e) = &error { + return Ok(failed(&d.name, executed, e)); + } let mut out = json!({"macro": d.name, "executed": executed}); - match (&error, &stopped) { - (Some(e), _) => { - out["state"] = json!("failed"); - out["error"] = serde_json::to_value(e).unwrap_or(Json::Null); - out["message"] = json!(format!("{} Nothing was changed.", e.describe())); - } - (None, Some((msg, rolled))) => { + match &stopped { + Some((msg, rolled)) => { out["state"] = json!("stopped"); out["stopped"] = json!(msg); out["kept"] = json!(!rolled); } - (None, None) => out["state"] = json!("finished"), + None => out["state"] = json!("finished"), } if !rollback { out["changed"] = json!(changed); @@ -267,6 +274,17 @@ fn finish(s: &mut Session, error: Option) -> Result { Ok(out) } +/// The result of a run that a failed step rolled back. +fn failed(name: &str, executed: u64, e: &StepError) -> Json { + json!({ + "macro": name, + "executed": executed, + "state": "failed", + "error": serde_json::to_value(e).unwrap_or(Json::Null), + "message": format!("{} Nothing was changed.", e.describe()), + }) +} + /// Stops the run: keeps what it did as one undo step, or rolls it back. fn stop(s: &mut Session, keep: bool) -> Result { let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) }; @@ -360,6 +378,33 @@ mod tests { s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap(); } + #[test] + fn a_step_that_panics_reports_the_step_failure() { + crate::register_test_command("test.debugStepPanics", |_, _| panic!("boom")); + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "test.debugStepPanics", "params": {}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + let start = s.doc().unwrap().wb.clone(); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + let r = s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(r["state"], "failed", "{r}"); + assert_eq!(r["macro"], "M"); + assert_eq!(r["error"]["step"], "2"); + assert!(r["message"].as_str().unwrap().contains("boom"), "{r}"); + assert!(s.macro_debug.is_none()); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert!(!s.in_group(s.doc().unwrap().uid)); + } + #[test] fn a_workbook_in_the_touched_log_waits_until_the_run_ends() { let mut s = Session::new(); From 3f9223c85f870a93bb1a49c38775331623289fea Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:25:28 -0400 Subject: [PATCH 034/166] Engine: macro recorder inlines a recorded macro run only when its steps stand alone A macro run while recording was replaced by every command in it, including both branches of an If and the body of a Repeat, whenever it had no inputs. For example, recording a run of a macro with an If played back both the then and the else commands without a condition, and commands using Row, Column or Output failed on playback. The run is now inlined only when the macro has no inputs, no output and only plain commands (its disabled steps are left out); otherwise it is left out and listed as skipped. --- crates/engine/src/macros/recorder.rs | 18 +++++++++------ crates/engine/src/tests/macros.rs | 33 ++++++++++++++++++++++++++++ docs/macros.md | 3 ++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index b15489873..bb2e30f74 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -4,8 +4,9 @@ //! with the sheet and selection it ran in. When recording stops: //! //! - commands a macro may not run (saving, opening files…) and other macros' commands are left -//! out; a macro run while recording is replaced by its steps when it has no inputs, and an -//! Automate script run while recording by the script's steps; +//! out; a macro run while recording is replaced by its steps when it has no inputs, no output +//! and only plain commands (otherwise it is left out), and an Automate script run while +//! recording by the script's steps; //! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed //! between commands without one, and runs of `selection.set` collapse to the last; //! - with relative references (the default), the selection when recording started is the example @@ -14,7 +15,7 @@ //! shape, header and column types are inferred from it. use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::{ColType, InputKind, Macro, NamedInput, Step, Workbook}; +use gridcraft_model::{ColType, InputKind, Macro, NamedInput, Step, StepKind, Workbook}; use serde_json::{Map, Value as Json, json}; use super::policy::{Policy, macro_policy}; @@ -51,12 +52,15 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & for st in steps.iter().filter(|st| st.doc == start.doc) { let id = st.id.as_str(); if id == "macro.run" { - // Another macro's steps, when they stand on their own. + // Another macro's steps, when they stand on their own: no inputs, no output and only + // plain commands (a Repeat, If or Stop means nothing outside its macro). let inner = st.params.get("name").and_then(Json::as_str).and_then(|n| super::find(wb, n)).and_then(|i| wb.macros.get(i)); match inner { - Some(m) if m.inputs.is_empty() => { - for (_, cid, params) in super::command_steps(&m.steps) { - cmds.push((cid.to_string(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + Some(m) if m.inputs.is_empty() && m.output.anchor.is_none() && m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) => { + for x in m.steps.iter().filter(|x| x.enabled) { + if let StepKind::Command { id: cid, params } = &x.kind { + cmds.push((cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + } } } _ => skipped.push(id.to_string()), diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 5b0b2ddbb..f3640f08a 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1045,3 +1045,36 @@ fn a_relative_recording_keeps_references_on_another_sheet() { s.execute("sheet.activate", json!({"sheet": "Two"})).unwrap(); assert_eq!((v(&s, "C4").display(), v(&s, "D2").display()), ("x".into(), "12".into())); } + +#[test] +fn a_recorded_macro_run_is_inlined_only_when_its_steps_stand_alone() { + let mut s = s(); + put( + &mut s, + json!({"name": "Branchy", "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "if", "cell": "A1", "op": "empty", + "then": [{"command": "cell.set", "params": {"cell": "B1", "input": "empty"}}], + "else": [{"command": "cell.set", "params": {"cell": "B1", "input": "full"}}]} + ]}), + ); + put( + &mut s, + json!({"name": "Placed", "output": {"anchor": "D1", "onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Output", "input": "out"}} + ]}), + ); + put( + &mut s, + json!({"name": "Plain", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "C1", "input": "plain"}}, + {"command": "cell.set", "params": {"cell": "C2", "input": "off"}, "enabled": false} + ]}), + ); + s.execute("macro.record", json!({"relative": false})).unwrap(); + for name in ["Branchy", "Placed", "Plain"] { + s.execute("macro.run", json!({"name": name})).unwrap(); + } + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["macro.run", "macro.run"])); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"C1\",\"input\":\"plain\"}"]); +} diff --git a/docs/macros.md b/docs/macros.md index 6a30a1d56..674a35673 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -126,7 +126,8 @@ on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and header and column types are inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not run are left out and listed in the result; a macro run while recording is replaced by its steps -when it has no inputs, and an Automate script run by the script's steps. +when it has no inputs, no output and only plain commands (otherwise it is left out and listed), and +an Automate script run by the script's steps. ## Other commands and storage From 7d31c5a4eda45cb7684991158aff28f786d916ef Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:25:53 -0400 Subject: [PATCH 035/166] Engine: a macro in a newer format is dropped with a warning, not saved back as version 1 Opening a workbook accepted a macro whose format version `v` was above MACRO_VERSION. Serde dropped the fields this build doesn't know, and the next save wrote the reduced macro back with `v: 2`, so a newer build would read it as a complete version 2 macro. A version above MACRO_VERSION is now refused by Macro::check_limits, and parse_macro checks the raw `v` before reading the steps. Such a macro takes the existing per-macro path: it is left out with the usual single warning (which now names a newer format), and the rest of the workbook opens. Tests cover JSON and xlsx hidden names, with current and missing versions as controls. --- crates/engine/src/macros/mod.rs | 8 +++++-- crates/engine/src/macros/tests.rs | 39 +++++++++++++++++++++++++++++++ crates/model/src/macros.rs | 14 +++++++++-- docs/macros.md | 6 +++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 6b2150e66..b74fc7e99 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -20,6 +20,10 @@ const MACRO_KEY: &str = "macro_"; /// Reads a macro from JSON, as `macro.save` and files give it. A command step may also use the /// short form `{"command": "cell.set", "params": {…}}`. pub fn parse_macro(v: &Json) -> Result { + // A newer format is refused before its steps are read, so the message says why. + if let Some(found) = v.get("v").and_then(Json::as_u64) { + gridcraft_model::check_version(found)?; + } let mut v = v.clone(); if let Some(steps) = v.get_mut("steps") { normalize_steps(steps, 1)?; @@ -99,7 +103,7 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { } /// After a file is opened: moves macros out of hidden names into `Workbook::macros` and drops -/// macros that are damaged or over the limits. Returns a warning for what was dropped. +/// macros that are damaged, over the limits or in a newer format. Returns a warning for what was dropped. pub fn load(wb: &mut Workbook) -> Vec { let mut warnings = Vec::new(); let mut bad = 0usize; @@ -156,7 +160,7 @@ pub fn load(wb: &mut Workbook) -> Vec { } if bad > 0 { warnings.push(format!( - "{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).", + "{bad} macro{} in the workbook couldn't be opened: {} damaged, made by a newer version of GridCraft, or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).", if bad == 1 { "" } else { "s" }, if bad == 1 { "it is" } else { "they are" }, gridcraft_model::MAX_MACROS, diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 9d5b90a37..63f8cef76 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -278,3 +278,42 @@ fn a_damaged_json_macro_drops_alone() { assert!(warnings.len() == 1 && warnings[0].starts_with("3 macros"), "{warnings:?}"); assert_eq!(back.sheet(0).unwrap().value(gridcraft_core::CellRef::new(0, 0)), gridcraft_core::Value::text("kept")); } + +#[test] +fn a_macro_in_a_newer_format_is_dropped_not_rewritten() { + // A newer build's macro (`v` 2, with a field this build doesn't know) would lose that field + // here and be saved back as if complete, so it is dropped with the usual warning instead. + // Version 1 and a missing version are read as before. + let future = json!({"name": "Future", "v": MACRO_VERSION + 1, "fromALaterBuild": {"x": 1}, "steps": [{"command": "cell.set"}]}); + let current = json!({"name": "Current", "v": MACRO_VERSION, "steps": [{"command": "cell.set"}]}); + let missing = json!({"name": "Missing"}); + let err = parse_macro(&future).unwrap_err(); + assert!(err.contains("newer version") && err.contains("format 2"), "{err}"); + assert!(parse_macro(&json!({"name": "Huge", "v": u64::from(u32::MAX) + 1})).unwrap_err().contains("newer version")); + // A macro built in code goes through the same check on every path. + assert!(Macro { name: "Built".into(), v: MACRO_VERSION + 1, ..Macro::default() }.check_limits().is_err()); + let check = |file: &str, back: &Workbook, warnings: &[String]| { + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, ["Current", "Missing"], "{file}"); + assert!(back.macros.iter().all(|m| m.v == MACRO_VERSION), "{file}"); + assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version"), "{file}: {warnings:?}"); + // Saving and opening again leaves no reduced copy of it behind. + let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(back, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(again.macros, back.macros, "{file}"); + }; + // The JSON format's field. + let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + v["macros"] = json!([future, current, missing]); + let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); + check("book.json", &back, &warnings); + // Hidden names in xlsx. + let mut wb = Workbook::new(); + for (i, m) in [&future, ¤t, &missing].into_iter().enumerate() { + wb.names.push(hidden(&format!("_gc_macro_{i}_0"), &m.to_string())); + } + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + assert!(back.names.is_empty(), "{:?}", names_of(&back)); + check("book.xlsx", &back, &warnings); +} diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 39f799615..8dc657f90 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -63,9 +63,10 @@ impl Macro { count(&self.steps) } - /// Checks the size limits ([`MAX_MACRO_STEPS`], [`MAX_MACRO_DEPTH`], name and text lengths) and - /// that the name has no control characters. + /// Checks the format version ([`check_version`]), the size limits ([`MAX_MACRO_STEPS`], + /// [`MAX_MACRO_DEPTH`], name and text lengths) and that the name has no control characters. pub fn check_limits(&self) -> Result<(), String> { + check_version(u64::from(self.v))?; let name = self.name.trim(); if name.is_empty() { return Err("A macro needs a name.".into()); @@ -119,6 +120,15 @@ impl Macro { } } +/// Refuses a format version newer than [`MACRO_VERSION`]: this build would drop the fields it +/// doesn't know, and a save would then write the rest back as if it were the whole macro. +pub fn check_version(v: u64) -> Result<(), String> { + if v > u64::from(MACRO_VERSION) { + return Err(format!("This macro was made by a newer version of GridCraft (macro format {v}); this version reads format {MACRO_VERSION}.")); + } + Ok(()) +} + /// Reads the `macros` field of a JSON workbook as raw entries, whatever they hold, so they can be /// checked one at a time. pub(crate) fn entries<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { diff --git a/docs/macros.md b/docs/macros.md index 99050b0aa..1c755e329 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -49,6 +49,12 @@ Macros are saved in the workbook: as a field in GridCraft's JSON format, and in defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's limits), which other spreadsheet applications keep but do not run. +Each macro carries its format version, `v` (1 in this version; a missing `v` reads as 1). When a +file is opened, a macro that is damaged, over the limits or in a newer format (a `v` above 1) is +left out with one warning, and the rest of the workbook and its other macros open. A newer +macro is never read in part, so a save can't write it back with its newer fields missing; a +macro that was left out is not in the saved file. + ## Automate scripts and VBA Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`, From 77150be4556c5b15a8e1f9dec456bee5ae82c3ba Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:27:28 -0400 Subject: [PATCH 036/166] Engine: col("Header") is refused on a range without a header row col("...") always matched against the first row of the range, whatever its header flag. On an input declared without a header row, Input.col("Amount") silently picked the first column whose first data cell read "Amount". After .data, the natural Input.data.col("Amount") failed, because the first row was then a data row. The header row is now carried through data, and col("...") is refused with a message when there is none (an input without a header, a Name: base, or after any other method). --- crates/engine/src/macros/resolve.rs | 40 ++++++++++++++++++++--------- docs/macros.md | 2 ++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index e8c7b6613..d71db8cd4 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -6,10 +6,11 @@ //! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or //! `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or //! a table's data); -//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by header), `.row(n)`, -//! `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the top-left cell; any whole numbers, -//! so `cell(0,1)` is the cell above), `.offset(r,c)`, `.resize(h,w)`, `.data` (without the -//! header row), `.below(n)` and `.right(n)` (the n rows below or columns right of the range). +//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by an input's header row, +//! also after `.data`), `.row(n)`, `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the +//! top-left cell; any whole numbers, so `cell(0,1)` is the cell above), `.offset(r,c)`, +//! `.resize(h,w)`, `.data` (without the header row), `.below(n)` and `.right(n)` (the n rows +//! below or columns right of the range). //! //! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole //! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending @@ -181,6 +182,8 @@ pub fn resolve_token(text: &str, env: &Env) -> R { let t = text.trim(); let mut p = Parser { s: t, at: 0 }; let (mut place, mut header) = p.base(env)?; + // The row of column names `col("…")` reads: the input's header row, kept through `data`. + let mut names_row = header.then_some(place.range.start.row); while p.eat('.') { let name = p.ident(); if name.is_empty() { @@ -188,10 +191,14 @@ pub fn resolve_token(text: &str, env: &Env) -> R { } let args = if p.eat('(') { p.args(t)? } else { vec![] }; let r = place.range; - let range = match (name.to_ascii_lowercase().as_str(), args.as_slice()) { + let method = name.to_ascii_lowercase(); + let range = match (method.as_str(), args.as_slice()) { ("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?, ("col", [Arg::Text(h)]) => { - let c = header_col(env, place, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?; + let Some(row) = names_row else { + return bad(format!("'{t}': col(\"{h}\") needs an input with a header row; use col(n).")); + }; + let c = header_col(env, place, row, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?; sub(r, None, Some((c, c)), t)? } ("row", [Arg::Int(n)]) => sub(r, Some((*n, *n)), None, t)?, @@ -231,8 +238,12 @@ pub fn resolve_token(text: &str, env: &Env) -> R { )); } }; - // After a method the result is plain cells: `data` and `col("…")` no longer apply. + // After a method the result is plain cells: `data` no longer applies, and only `data` + // keeps the header row for `col("…")`. header = false; + if method != "data" { + names_row = None; + } place = Place { sheet: place.sheet, range }; } p.skip_ws(); @@ -278,12 +289,12 @@ fn shift(c: CellRef, dr: i64, dc: i64, t: &str) -> R { } } -/// The 1-based column of `place` whose header (first row) reads `h`. -fn header_col(env: &Env, place: Place, h: &str) -> Option { +/// The 1-based column of `place` whose header (in sheet row `row`) reads `h`. +fn header_col(env: &Env, place: Place, row: u32, h: &str) -> Option { let sh = env.wb.sheet(place.sheet)?; let r = place.range; (r.start.col..=r.end.col) - .position(|c| crate::display::cell_text(env.wb, sh, CellRef::new(r.start.row, c)).trim().eq_ignore_ascii_case(h.trim())) + .position(|c| crate::display::cell_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim())) .map(|i| i as i64 + 1) } @@ -532,6 +543,7 @@ mod tests { ("Input.col(\"date\").data", "D2:D11"), ("Input.data", "B3:D11"), ("Input.data.col(2)", "C3:C11"), + ("Input.data.col(\"Amount\")", "C3:C11"), ("Input.row(1)", "B2:D2"), ("Input.rows(2..)", "B3:D11"), ("Input.rows(2..4)", "B3:D5"), @@ -569,13 +581,17 @@ mod tests { #[test] fn bad_tokens_say_why() { let wb = wb(); - let inputs = vec![("Input".to_string(), place("B2:D11"), false)]; + // The same cells, read with and without a header row. + let inputs = vec![("Input".to_string(), place("B2:D11"), false), ("Headed".to_string(), place("B2:D11"), true)]; let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; for (token, needle, edge) in [ ("Input.col(4)", "outside the range", false), ("Input.col(0)", "outside the range", false), ("Input.rows(5..2)", "outside the range", false), - ("Input.col(\"Nope\")", "no column is headed", false), + ("Headed.col(\"Nope\")", "no column is headed", false), + ("Input.col(\"Name\")", "needs an input with a header row", false), + ("Headed.col(2).col(\"Amount\")", "needs an input with a header row", false), + ("Name:Rates.col(\"Name\")", "needs an input with a header row", false), ("Input.colour(1)", "isn't a token method", false), ("Input.col(1", "expected", false), ("Input.col(x)", "whole number", false), diff --git a/docs/macros.md b/docs/macros.md index 44bf572cf..4a08e7228 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -56,6 +56,8 @@ References in step parameters can be tokens, worked out when the step runs: Methods narrow or move a range and chain: `col(2)` or `col("Amount")`, `row(1)`, `cols(2..3)`, `rows(2..)`, `cell(r,c)` (relative to the top-left cell; `cell(0,1)` is the cell above), `offset(r,c)`, `resize(h,w)`, `data` (without the header row), `below(n)` and `right(n)`. +`col("Amount")` looks the name up in an input's header row, directly or after `data`; on a range +without a header row it is refused. Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside From 06bbcb504aa2b777dfffc068aefdf8e17330559e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:27:29 -0400 Subject: [PATCH 037/166] Engine: macro approvals are keyed on the macro's canonical JSON, not a 64-bit hash Approvals were stored as (document, FNV-1a hash of the macro's JSON). FNV-1a is not collision resistant, so a crafted workbook could hold a harmless macro and a different one with the same hash, and approving the first would let the second run without asking. The key is now the macro's canonical JSON text itself (object keys sorted), so two different macros never share an approval. macros::hash is replaced by macros::fingerprint. --- crates/engine/src/lib.rs | 5 +++-- crates/engine/src/macros/mod.rs | 11 +++++------ crates/engine/src/macros/run.rs | 4 ++-- crates/engine/src/macros/tests.rs | 4 ++-- crates/engine/src/tests/macros.rs | 9 +++++++++ 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d188161ee..caac006bc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -322,8 +322,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, macro content hash). - pub macro_approvals: std::collections::HashSet<(u64, u64)>, + /// 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 55933c1ac..457f10987 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -88,12 +88,11 @@ pub fn find(wb: &Workbook, name: &str) -> Option { wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name)) } -/// A stable fingerprint of a macro's content: approval to run a macro from a file holds until -/// the macro changes. -pub fn hash(m: &Macro) -> u64 { - // FNV-1a over the macro's JSON. - let text = macro_json(m).to_string(); - text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +/// 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 diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 57a70562e..36fd1ac41 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -144,7 +144,7 @@ pub fn approve(s: &mut Session, m: &Macro, doc: u64, asks: &[String], approve: b if asks.is_empty() { return Ok(()); } - let key = (doc, super::hash(m)); + let key = (doc, super::fingerprint(m)); if s.macro_approvals.contains(&key) { return Ok(()); } @@ -1227,7 +1227,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { return Ok(out); } }; - let approved = s.macro_approvals.contains(&(doc, super::hash(&m))); + let approved = s.macro_approvals.contains(&(doc, super::fingerprint(&m))); if !asks.is_empty() && !approved { // Nothing from an unapproved macro runs, not even in a dry run. out["needsApproval"] = json!(asks); diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index f0845a54b..84792dc40 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -215,9 +215,9 @@ fn names() { assert_eq!(find(&wb, "Macro2"), None); let a = sample_macro("A"); let mut b = a.clone(); - assert_eq!(hash(&a), hash(&b)); + assert_eq!(fingerprint(&a), fingerprint(&b)); b.steps[0].note = "changed".into(); - assert_ne!(hash(&a), hash(&b)); + assert_ne!(fingerprint(&a), fingerprint(&b)); } #[test] diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index a928e9b53..a42a60dd7 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -486,6 +486,15 @@ fn macros_from_a_file_ask_before_running() { 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)); } #[test] From 62cececa63e2ebce48aeb2bf6c5296807fbcdbf1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:33:53 -0400 Subject: [PATCH 038/166] Docs: debugging macros --- docs/macros.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index 7c7953ccf..45b2b51f8 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-21 · **Change:** minor (running and checking macros: `macro.run`, `macro.check`) +> **Last updated:** 2026-10-21 · **Change:** minor (debugging macros: `macro.debug`) 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 @@ -9,8 +9,8 @@ error (a file it already wrote stays), checks where its results will land before and does not touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the macro format, its tokens, and running and checking macros. The engine has -no macro debugger, recorder or management commands in this version, and no macro interface. +This page describes the macro format, its tokens, and running, checking and debugging macros. +The engine has no macro recorder or management commands in this version, and no macro interface. ## A macro @@ -104,6 +104,16 @@ called Output, even when a token or input has that name. `macro.check {name, inputs?, timeLimit?}` reports the bound inputs, the output anchor, any approval needed and the footprint, without changing anything. +## Debugging + +`macro.debug {action, …}` with `start` (`name`, `inputs`, `breakpoints`), `step`, `continue`, +`runTo` (`step`: a path), `stop` (`keep: true` keeps the changes) and `state`. `breakpoints` can +be given with any action; `breakpointsOnly: true` makes the list replace the steps' own flags. The +run stays one undo step; while it is open, edits of the workbooks it touched (its own, and any +other its steps changed) are refused with "Finish or stop the macro first", and other workbooks +stay editable. Each answer gives the next step with its parameters as they would run, the +parameters of the step that just ran, and the cells changed so far. + ## Storage Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden From efce63f75ff132c3ced76465530897b0bc5e8aa0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:26:27 -0400 Subject: [PATCH 039/166] Engine: macro recorder applies the macro policy to the steps of a recorded script run An Automate script run while recording was replaced by all of its steps except automate.* ones, without the policy check that top-level commands get. For example, a script that runs window.activate or macro.list produced a recorded macro that the runner refused on playback. The steps of a recorded script run, and of an inlined macro run, now go through the same policy as top-level commands; refused ones are left out and listed as skipped. --- crates/engine/src/macros/recorder.rs | 16 ++++++++++++---- crates/engine/src/tests/macros.rs | 17 +++++++++++++++++ docs/macros.md | 7 ++++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index bb2e30f74..210872475 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -6,7 +6,7 @@ //! - commands a macro may not run (saving, opening files…) and other macros' commands are left //! out; a macro run while recording is replaced by its steps when it has no inputs, no output //! and only plain commands (otherwise it is left out), and an Automate script run while -//! recording by the script's steps; +//! recording by the script's steps, leaving out the steps a macro may not run; //! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed //! between commands without one, and runs of `selection.set` collapse to the last; //! - with relative references (the default), the selection when recording started is the example @@ -49,6 +49,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & // Commands as they'll be played back: (id, params, sheet before, selection before, whether // they came from another macro and stay as written). let mut cmds: Vec<(String, Json, usize, String, Selection, bool)> = Vec::new(); + let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)); for st in steps.iter().filter(|st| st.doc == start.doc) { let id = st.id.as_str(); if id == "macro.run" { @@ -58,9 +59,12 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & match inner { Some(m) if m.inputs.is_empty() && m.output.anchor.is_none() && m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) => { for x in m.steps.iter().filter(|x| x.enabled) { - if let StepKind::Command { id: cid, params } = &x.kind { - cmds.push((cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + let StepKind::Command { id: cid, params } = &x.kind else { continue }; + if refused(cid, params) { + skipped.push(cid.clone()); + continue; } + cmds.push((cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); } } _ => skipped.push(id.to_string()), @@ -72,6 +76,10 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & match st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)) { Some(steps) => { for (cid, params) in steps { + if refused(&cid, ¶ms) { + skipped.push(cid); + continue; + } cmds.push((cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); } } @@ -79,7 +87,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & } continue; } - if matches!(macro_policy(id, &st.params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)) { + if refused(id, &st.params) { skipped.push(id.to_string()); continue; } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index f3640f08a..e16e8be3e 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1078,3 +1078,20 @@ fn a_recorded_macro_run_is_inlined_only_when_its_steps_stand_alone() { assert_eq!(r["skipped"], json!(["macro.run", "macro.run"])); assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"C1\",\"input\":\"plain\"}"]); } + +#[test] +fn a_recorded_script_run_leaves_out_the_steps_a_macro_may_not_run() { + let mut s = s(); + let steps = json!([ + {"command": "cell.set", "params": {"cell": "B1", "input": "kept"}}, + {"command": "window.activate", "params": {"index": 0}}, + {"command": "macro.list", "params": {}} + ]); + s.execute("automate.newScript", json!({"name": "Mixed", "commands": steps})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Mixed"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["window.activate", "macro.list"])); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"B1\",\"input\":\"kept\"}"]); + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); +} diff --git a/docs/macros.md b/docs/macros.md index 674a35673..fab64144f 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -125,9 +125,10 @@ references the selection when recording starts is the example input: references on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and the input's shape, header and column types are inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not -run are left out and listed in the result; a macro run while recording is replaced by its steps -when it has no inputs, no output and only plain commands (otherwise it is left out and listed), and -an Automate script run by the script's steps. +run are left out and listed in the result, also from the steps of a macro or script run while +recording; a macro run while recording is replaced by its steps when it has no inputs, no output +and only plain commands (otherwise it is left out and listed), and an Automate script run by the +script's steps. ## Other commands and storage From 3c74dfcbb0b7feec9bad4c4a91fe2caff6f4a9d0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:27:10 -0400 Subject: [PATCH 040/166] Engine: renaming macros with clashing names on open takes near-linear work Opening a workbook gave each clashing macro name a suffix by trying " (2)", " (3)"... in turn, and each try scanned every macro accepted so far. A file with 1,000 macros of the same 255-character name made about 300 million name comparisons. Load now keeps a set of the names in use (ASCII case-insensitive, as find compares them) and, for each shortened name and suffix width, the next suffix to try, so each suffix is looked up about once. The result is unchanged: the smallest free suffix, within the name length limit, and the renamed macro still passes the final checks. unique_name keeps its signature. A test opens, saves and reopens 1,000 same-name macros in JSON and xlsx and keeps them all, and another counts lookups instead of timing them. --- crates/engine/src/macros/mod.rs | 75 ++++++++++++++++++++++++++----- crates/engine/src/macros/tests.rs | 50 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index b74fc7e99..9a580c791 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -7,7 +7,7 @@ //! the open document. Scripts made with Automate › New Script or Record Actions are a separate, //! older feature with their own storage; macros neither read nor change them. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use gridcraft_model::{DefinedName, Macro, Workbook}; use serde_json::{Value as Json, json}; @@ -142,6 +142,7 @@ pub fn load(wb: &mut Workbook) -> Vec { // All of them go through the same limits, and a clashing name gets a suffix. let mut all = std::mem::take(&mut wb.macros); all.extend(found); + let mut taken = Taken::default(); for m in all { if wb.macros.len() >= gridcraft_model::MAX_MACROS { bad += 1; @@ -149,12 +150,15 @@ pub fn load(wb: &mut Workbook) -> Vec { } let mut m = m; let checked = m.check_limits().and_then(|()| { - m.name = unique_name(wb, m.name.trim()); + m.name = taken.unique(m.name.trim()); // The macro as it is kept, renamed, must still pass, or the next save would lose it. m.check_limits().and_then(|()| check_size(&m)) }); match checked { - Ok(()) => wb.macros.push(m), + Ok(()) => { + taken.insert(&m.name); + wb.macros.push(m); + } Err(_) => bad += 1, } } @@ -181,19 +185,66 @@ fn macro_item(n: &DefinedName) -> Option<(String, Option)> { /// `name`, or `name (2)`, `name (3)`… if a macro already has it. A long name is shortened to make /// room for the suffix, so the result stays within [`gridcraft_model::MAX_MACRO_NAME`] characters. pub fn unique_name(wb: &Workbook, name: &str) -> String { - let name = if name.is_empty() { "Macro" } else { name }; - if find(wb, name).is_none() { - return name.to_string(); + let mut taken = Taken::default(); + for m in &wb.macros { + taken.insert(&m.name); } - (2..) - .map(|n| { + taken.unique(name) +} + +/// Macro names in use, compared as [`find`] compares them, and the next suffix to try for each +/// shortened name and suffix width, so naming many clashing macros takes near-linear work. +#[derive(Default)] +struct Taken { + names: HashSet, + next: HashMap<(String, usize), u64>, + /// Name lookups made, for tests. + probes: usize, +} + +impl Taken { + fn key(name: &str) -> String { + name.trim().to_ascii_lowercase() + } + + fn insert(&mut self, name: &str) { + self.names.insert(Taken::key(name)); + } + + fn contains(&mut self, name: &str) -> bool { + self.probes = self.probes.saturating_add(1); + self.names.contains(&Taken::key(name)) + } + + /// The first of `name`, `name (2)`, `name (3)`… not in use. + fn unique(&mut self, name: &str) -> String { + let name = if name.is_empty() { "Macro" } else { name }; + if !self.contains(name) { + return name.to_string(); + } + let mut n: u64 = 2; + loop { let suffix = format!(" ({n})"); let keep = gridcraft_model::MAX_MACRO_NAME.saturating_sub(suffix.chars().count()); let base: String = name.chars().take(keep).collect(); - format!("{}{suffix}", base.trim_end()) - }) - .find(|n| find(wb, n).is_none()) - .unwrap_or_else(|| name.to_string()) + let base = base.trim_end(); + // Every candidate for this base and suffix width below `next` is in use already + // (names are only added), so the search goes on from there. + let key = (Taken::key(base), suffix.len()); + let next = self.next.get(&key).copied().unwrap_or(n); + if next > n { + n = next; + continue; + } + let Some(after) = n.checked_add(1) else { return name.to_string() }; + self.next.insert(key, after); + let candidate = format!("{base}{suffix}"); + if !self.contains(&candidate) { + return candidate; + } + n = after; + } + } } #[cfg(test)] diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 63f8cef76..4bd2ac1bc 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -317,3 +317,53 @@ fn a_macro_in_a_newer_format_is_dropped_not_rewritten() { assert!(back.names.is_empty(), "{:?}", names_of(&back)); check("book.xlsx", &back, &warnings); } + +#[test] +fn many_macros_with_one_name_all_survive_save_and_reopen() { + // MAX_MACROS macros with the same name, at the length limit, so the suffixes run from " (2)" + // to " (1000)" and the kept part of the name shrinks as they widen. + let long = "é".repeat(MAX_MACRO_NAME); + for file in ["same.json", "same.xlsx"] { + let mut wb = Workbook::new(); + wb.macros = (0..MAX_MACROS).map(|i| Macro { name: long.clone(), description: format!("{i}"), ..Macro::default() }).collect(); + let (first, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&wb, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(first.macros.len(), MAX_MACROS, "{file}"); + let names: std::collections::HashSet = first.macros.iter().map(|m| m.name.to_lowercase()).collect(); + assert_eq!(names.len(), MAX_MACROS, "{file}: every name is different"); + assert!(first.macros.iter().all(|m| m.name.chars().count() <= MAX_MACRO_NAME && m.check_limits().is_ok()), "{file}"); + assert_eq!(first.macros[0].name, long); + assert_eq!(first.macros[9].name, format!("{} (10)", "é".repeat(MAX_MACRO_NAME - 5))); + assert_eq!(first.macros[MAX_MACROS - 1].name, format!("{} (1000)", "é".repeat(MAX_MACRO_NAME - 7))); + assert!(first.macros.iter().enumerate().all(|(i, m)| m.description == format!("{i}")), "{file}: order kept"); + let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&first, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(again.macros, first.macros, "{file}"); + } +} + +#[test] +fn naming_clashing_macros_takes_linear_work() { + // Each suffix is looked up about once, not once per earlier macro with the same name. + let mut taken = Taken::default(); + // Names a file may already use, in the way of the first suffixes. + for n in 2..=500 { + taken.insert(&format!("Same ({n})")); + } + let mut out = Vec::new(); + for _ in 0..MAX_MACROS { + let name = taken.unique("SAME"); + taken.insert(&name); + out.push(name); + } + assert_eq!(out[..3], ["SAME", "SAME (501)", "SAME (502)"]); + assert!(taken.probes <= 3 * MAX_MACROS, "{} lookups", taken.probes); + // The smallest free suffix is still the one used. + let mut taken = Taken::default(); + for name in ["A", "A (3)"] { + taken.insert(name); + } + assert_eq!(taken.unique("a"), "a (2)"); + taken.insert("a (2)"); + assert_eq!(taken.unique("a"), "a (4)"); +} From 0ea5d3ee336167432072c3fc24bfbf1e7c8e7f45 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:29:18 -0400 Subject: [PATCH 041/166] Engine: a Name: token reaches a defined name that holds periods `Name:` read a name only up to the first period, so `Name:Sales.Q1` looked up `Sales` and treated `Q1` as a method, and a workbook name called Sales.Q1 could not be used in a macro, alone or as `Name:Sales.Q1.cell(1,1)`. The name now runs through periods, and the longest defined name or table that exists and ends at a period (or the end) is used; what follows is read as methods. So `Name:Data.row` is a name called Data.row, and `Name:Sales.offset(1,0)` still moves Sales. When no name matches, the error names the text before the first method, and a bad method after a dotted name is still refused. Tests cover direct resolution, reference parameters, `$ref` and `{{...}}`, with and without a chained method. --- crates/engine/src/macros/resolve.rs | 102 +++++++++++++++++++++++++--- docs/macros.md | 4 +- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index d71db8cd4..47636bd61 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -5,7 +5,8 @@ //! //! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or //! `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or -//! a table's data); +//! a table's data; a name can hold periods, and the longest one that exists is used, so +//! `Name:Sales.Q1.cell(1,1)` is a cell of `Sales.Q1`); //! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by an input's header row, //! also after `.data`), `.row(n)`, `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the //! top-left cell; any whole numbers, so `cell(0,1)` is the cell above), `.offset(r,c)`, @@ -177,6 +178,33 @@ fn named(name: &str, env: &Env) -> R { bad(format!("There is no name or table called '{name}'.")) } +/// Token method names. +const METHODS: &[&str] = &["col", "row", "cols", "rows", "cell", "offset", "resize", "data", "below", "right"]; + +/// Where the name ends in `run`, the text after `Name:` up to the first character a name can't +/// hold: at the end or at a period, the longest choice that is a defined name or table. When +/// none is, the name ends before the first method (`Nope.cell`), so the error names it. +fn name_end(run: &str, env: &Env) -> usize { + // One pass over the names and tables, as a single lookup makes. + let fits = |name: &str| { + !name.is_empty() + && run.get(..name.len()).is_some_and(|head| head.eq_ignore_ascii_case(name)) + && run.get(name.len()..).is_some_and(|rest| rest.is_empty() || rest.starts_with('.')) + }; + let names = env.wb.names.iter().filter(|n| n.scope.is_none() || n.scope == Some(env.active_sheet)).map(|n| n.name.as_str()); + let tables = env.wb.sheets.iter().flat_map(|sh| sh.tables.iter().map(|t| t.name.as_str())); + if let Some(end) = names.chain(tables).filter(|n| fits(n)).map(str::len).max() { + return end; + } + run.match_indices('.') + .map(|(i, _)| i) + .find(|&i| { + let next = run.get(i + 1..).unwrap_or("").split('.').next().unwrap_or(""); + METHODS.iter().any(|m| m.eq_ignore_ascii_case(next)) + }) + .unwrap_or(run.len()) +} + /// Resolves a token (see the module docs). pub fn resolve_token(text: &str, env: &Env) -> R { let t = text.trim(); @@ -233,9 +261,7 @@ pub fn resolve_token(text: &str, env: &Env) -> R { RangeRef::new(left, shift(r.end, 0, n, t)?) } _ => { - return bad(format!( - "'{t}': '{name}' with these arguments isn't a token method (col, row, cols, rows, cell, offset, resize, data, below, right)." - )); + return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", "))); } }; // After a method the result is plain cells: `data` no longer applies, and only `data` @@ -351,14 +377,17 @@ impl Parser<'_> { self.skip_ws(); if self.rest().get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { self.at += 5; - // A name runs to the first `.` followed by a method, or the end. - let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\')).map(char::len_utf8).sum(); - let name = self.rest().get(..n).unwrap_or("").to_string(); - self.at += n; + // A name can hold periods (`Sales.Q1`), so it is the longest part of the run, up to + // a period or the end, that names something; methods follow it. + let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\' | '.')).map(char::len_utf8).sum(); + let run = self.rest().get(..n).unwrap_or("").to_string(); + let end = name_end(&run, env); + let name = run.get(..end).unwrap_or(""); if name.is_empty() { return bad("'Name:' needs a defined name after it."); } - return Ok((named(&name, env)?, false)); + self.at += end; + return Ok((named(name, env)?, false)); } let base = self.ident(); if base.eq_ignore_ascii_case("Output") { @@ -691,6 +720,61 @@ mod tests { } } + #[test] + fn defined_names_can_hold_periods() { + // `Sales.Q1` is one defined name, beside `Sales`; `Data.row` has a segment that reads like + // a method. The longest name that exists is used, and methods follow it. + let mut wb = wb(); + for (name, formula) in [("Sales", "Sheet1!$A$1"), ("Sales.Q1", "Sheet1!$B$2:$C$5"), ("Data.row", "'My Data'!$E$1:$E$3")] { + wb.names.push(gridcraft_model::DefinedName { + name: name.into(), + scope: None, + formula: formula.into(), + comment: String::new(), + hidden: false, + }); + } + let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None }; + for (token, want) in [ + ("Name:Sales.Q1", "B2:C5"), + ("Name:sales.q1", "B2:C5"), + ("Name:Sales.Q1.cell(1,1)", "B2"), + ("Name:Sales.Q1.col(2).row(4)", "C5"), + ("Name:Sales", "A1"), + ("Name:Sales.offset(1,0)", "A2"), + ("Name:Data.row", "'My Data'!E1:E3"), + ("Name:Data.row.row(2)", "'My Data'!E2"), + ("Name:Data.row.cell(1,1)", "'My Data'!E1"), + ("Sales.Q1", "B2:C5"), + ] { + check(&env, token, want); + } + // In a reference parameter, `$ref` and `{{…}}`, with and without a method. + let p = json!({ + "range": "Name:Sales.Q1", + "cell": "Name:Sales.Q1.cell(1,1)", + "a": {"$ref": "Name:Sales.Q1"}, + "b": {"$ref": "Name:Sales.Q1.cell(2,2)"}, + "formula": "=SUM({{Name:Sales.Q1}})+{{Name:Sales.Q1.cell(1,2)}}" + }); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"range": "B2:C5", "cell": "B2", "a": "B2:C5", "b": "C3", "formula": "=SUM(B2:C5)+C2"})); + // Unknown names and bad methods still fail, naming what is wrong. + for (token, needle) in [ + ("Name:Nope.Q1", "no name or table called 'Nope.Q1'"), + ("Name:Nope.cell(1,1)", "no name or table called 'Nope'"), + ("Name:Sales.Q1.bogus(1)", "'bogus' with these arguments isn't a token method"), + ("Name:Sales.Q1.cell(1", "expected"), + ("Name:Sales.Q1.", "method name"), + ("Name:.cell(1,1)", "needs a defined name"), + ] { + let e = resolve_ref(token, &env).unwrap_err(); + assert!(e.message().contains(needle), "{token}: {e}"); + let e = resolve_params(&json!({"formula": format!("={{{{{token}}}}}")}), &env).unwrap_err(); + assert!(e.message().contains(needle), "{token} in braces: {e}"); + } + } + #[test] fn sheet_names_are_quoted_when_needed() { for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] { diff --git a/docs/macros.md b/docs/macros.md index 4a08e7228..dd14951bc 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -57,7 +57,9 @@ Methods narrow or move a range and chain: `col(2)` or `col("Amount")`, `row(1)`, `rows(2..)`, `cell(r,c)` (relative to the top-left cell; `cell(0,1)` is the cell above), `offset(r,c)`, `resize(h,w)`, `data` (without the header row), `below(n)` and `right(n)`. `col("Amount")` looks the name up in an input's header row, directly or after `data`; on a range -without a header row it is refused. +without a header row it is refused. A defined name can hold periods: `Name:Sales.Q1` is the name +`Sales.Q1`, and `Name:Sales.Q1.cell(1,1)` its first cell, because the longest name or table that +exists is used and methods follow it. Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside From f1a46a2b53588c8138450cf97b5b5e7952ab03ae Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:27:48 -0400 Subject: [PATCH 042/166] Docs: the macro policy summary lists the file commands that do run The policy summary said file commands never run in a macro, but File > Properties runs, and exports and printing run: without a path they only produce bytes, and with one they need the permission and approval. The summary now names what is denied and what runs, and the paragraph is wrapped to the file's line length. --- docs/macros.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index cf93148d3..dc0c044c7 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -75,12 +75,16 @@ called Output, even when a token or input has that name. `macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`: -1. **Policy.** File, app, window (including New Window, Hide and Unhide), automate and macro - commands, and Undo and Redo, never run in a macro. 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 +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. 2. **Footprint.** The macro first runs in dry-run mode on the real workbook, and everything is put back. Cells it would write outside its inputs that hold data, and sheets and objects it would From f553619b333767e43f17a86769314c1dc0ac1baf Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:25:49 -0400 Subject: [PATCH 043/166] Engine: the macro debugger lock also refuses edits that another command runs Session::execute checked the debugger lock only for top-level undoable commands. A command that is not undoable could run edits as nested commands and skip it: stopping Record Actions while a macro in the same workbook was being debugged saved the script inside the run's undo group, and stopping the run without keeping its changes deleted the script again. The lock now applies at every depth, and only the debugged run's own steps go ahead. --- crates/engine/src/lib.rs | 7 ++++--- crates/engine/src/macros/debug.rs | 35 ++++++++++++++++++++++++++++++- docs/macros.md | 7 ++++--- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3d542469b..6807d1cce 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -412,9 +412,10 @@ impl Session { return Err(EngineError::Disabled(id.to_string(), why)); } // While a macro is being debugged its undo group is open: other edits of the workbooks it - // touched wait. Other workbooks stay editable. - let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid))); - if self.depth == 0 && spec.undoable && id != "macro.debug" && locked { + // touched wait, also when another command runs them (a script, or a recording that stops + // and saves). Only the run's own steps go ahead. Other workbooks stay editable. + let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping())); + if spec.undoable && id != "macro.debug" && locked { return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into())); } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); diff --git a/crates/engine/src/macros/debug.rs b/crates/engine/src/macros/debug.rs index 04da19834..b1e53cf9b 100644 --- a/crates/engine/src/macros/debug.rs +++ b/crates/engine/src/macros/debug.rs @@ -42,6 +42,11 @@ impl Debugger { pub fn locks(&self, doc: u64) -> bool { self.touched.contains(&doc) } + + /// Whether one of the run's steps is running (its machine is out of the session). + pub fn stepping(&self) -> bool { + self.machine.is_none() + } } /// Each open workbook's uid and its current version, to see which ones a step changes. @@ -339,7 +344,7 @@ fn changed(s: &Session, d: &Debugger) -> Vec { /// After a command panicked while a macro was being debugged: if the run lost its place, roll /// it back so its undo group doesn't stay open. pub(crate) fn recover(s: &mut Session) { - if s.macro_debug.as_ref().is_some_and(|d| d.machine.is_none()) + if s.macro_debug.as_ref().is_some_and(Debugger::stepping) && let Some(mut d) = s.macro_debug.take() && let Some(token) = d.token.take() { @@ -436,4 +441,32 @@ mod tests { s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap(); s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap(); } + + #[test] + fn edits_run_by_another_command_wait_too() { + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "A2", "input": "y"}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap(); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + // Record Actions changes nothing itself; stopping it saves the script with a nested + // command, which would land in the run's undo group. + let e = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap_err().to_string(); + assert!(e.contains(super::BUSY), "{e}"); + assert!(s.execute("automate.allScripts", json!({})).unwrap().as_array().unwrap().is_empty()); + // The run's own steps still go ahead. + let r = s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(r["executed"], 2, "{r}"); + assert!(s.macro_debug.is_none()); + } } diff --git a/docs/macros.md b/docs/macros.md index 45b2b51f8..4301989ae 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -110,9 +110,10 @@ approval needed and the footprint, without changing anything. `runTo` (`step`: a path), `stop` (`keep: true` keeps the changes) and `state`. `breakpoints` can be given with any action; `breakpointsOnly: true` makes the list replace the steps' own flags. The run stays one undo step; while it is open, edits of the workbooks it touched (its own, and any -other its steps changed) are refused with "Finish or stop the macro first", and other workbooks -stay editable. Each answer gives the next step with its parameters as they would run, the -parameters of the step that just ran, and the cells changed so far. +other its steps changed) are refused with "Finish or stop the macro first", also when another +command makes them (stopping Record Actions saves a script), and other workbooks stay +editable. Each answer gives the next step with its parameters as they would run, the parameters +of the step that just ran, and the cells changed so far. ## Storage From 8797b7183c94cba8f1675d31d0111a032981fd56 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:27:37 -0400 Subject: [PATCH 044/166] Engine: stopping a macro recording saves it in the workbook where recording started Stopping a recording converted and saved it against the active workbook. For example, starting a recording in Book1 and switching to Book2 before stopping saved the macro into Book2, inferred the input's header and column types from Book2's cells and looked up inlined macros and scripts there. The recording is now converted and saved against the workbook where it started, and the active workbook stays active. If that workbook was closed, stopping returns an error and ends the recording. --- crates/engine/src/cmd/macros.rs | 27 +++++++++++++++++++++------ crates/engine/src/tests/macros.rs | 31 +++++++++++++++++++++++++++++++ docs/macros.md | 12 +++++++----- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 953be1982..383471f53 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -184,12 +184,21 @@ fn start_recording(s: &mut Session, relative: bool) -> Result { Ok(if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) }) } -/// Stops recording and saves what was recorded as a macro. When saving fails (a bad name, a full -/// workbook) the recording goes on, so stopping again keeps what was recorded. +/// Stops recording and saves what was recorded as a macro, in the workbook where recording +/// started. When saving fails (a bad name, a full workbook) the recording goes on, so stopping +/// again keeps what was recorded; when that workbook was closed, the recording ends unsaved. fn stop_recording(s: &mut Session, p: &Json) -> Result { let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) }; let start = s.macro_recording.take(); - let saved = save_recording(s, p, &rec, start.clone()); + let at = match &start { + Some(st) => s + .docs + .iter() + .position(|d| d.uid == st.doc) + .ok_or_else(|| EngineError::Other("The workbook where recording started was closed, so the recording was not saved.".into()))?, + None => s.active_index(), + }; + let saved = save_recording(s, p, &rec, start.clone(), at); if saved.is_err() { s.recorder = Some(rec); s.macro_recording = start; @@ -197,8 +206,9 @@ fn stop_recording(s: &mut Session, p: &Json) -> Result { saved } -fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option) -> Result { - let d = s.doc()?; +/// Saves the recording into the workbook at index `at`. +fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option, at: usize) -> Result { + let d = s.docs.get(at).ok_or(EngineError::NoDocument)?; let start = start.unwrap_or_else(|| RecordingStart { doc: d.uid, sheet: d.wb.active_sheet, @@ -218,6 +228,11 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star } let steps = converted.mac.steps.len(); let inputs: Vec = converted.mac.inputs.iter().map(|i| i.name.clone()).collect(); - s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac)}))?; + // Saved into that workbook even when another one is active now. + let active = s.active_index(); + s.set_active(at); + let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac)})); + s.set_active(active); + saved?; Ok(json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped, "skipped": converted.skipped, "inputs": inputs})) } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index e16e8be3e..323b64ed4 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1095,3 +1095,34 @@ fn a_recorded_script_run_leaves_out_the_steps_a_macro_may_not_run() { assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"B1\",\"input\":\"kept\"}"]); s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); } + +#[test] +fn a_recording_is_saved_in_the_workbook_where_it_started() { + let mut s = s(); + data(&mut s); + let first = s.doc().unwrap().uid; + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "E2", "Total"); + s.new_workbook(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Here"})).unwrap(); + assert_eq!(r["saved"], "Here"); + // The active workbook stays active and gets nothing. + assert_ne!(s.doc().unwrap().uid, first); + assert!(s.doc().unwrap().wb.macros.is_empty()); + s.set_active(0); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Here"})).unwrap()).unwrap(); + // The input's shape comes from the workbook where recording started. + assert!(m.inputs[0].header); + assert_eq!(recorded(&mut s, "Here"), ["cell.set {\"cell\":\"Input.cell(1,4)\",\"input\":\"Total\"}"]); + + // When that workbook was closed, stopping says so and ends the recording. + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "E3", "x"); + s.set_active(1); + s.close_document(0); + let err = s.execute("macro.record", json!({"stop": true, "name": "Gone"})).unwrap_err().to_string(); + assert!(err.contains("was closed"), "{err}"); + assert!(!s.is_recording()); + assert!(s.doc().unwrap().wb.macros.is_empty()); +} diff --git a/docs/macros.md b/docs/macros.md index fab64144f..235d1f2b4 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -119,11 +119,13 @@ of the step that just ran, and the cells changed so far. ## Recording `macro.record` toggles recording (`relative: false` for absolute references; `name` and -`description` when stopping). When saving the recording fails (a name the check refuses, a workbook -at its macro limit), recording goes on, so stopping again keeps what was recorded. With relative -references the selection when recording starts is the example input: references in and near it -on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and the input's shape, -header and column types are inferred. Toggles such as Bold are recorded with the state they set +`description` when stopping). The macro is saved in the workbook where recording started, even if +another one is active; if that workbook was closed, stopping ends the recording without saving it. +When saving the recording fails (a name the check refuses, a workbook at its macro limit), +recording goes on, so stopping again keeps what was recorded. With relative references the +selection when recording starts is the example input: references in and near it on its sheet become +tokens (on another sheet `C4` stays that sheet's `C4`), and the input's shape, header and column +types are inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not run are left out and listed in the result, also from the steps of a macro or script run while recording; a macro run while recording is replaced by its steps when it has no inputs, no output From 6312c7cb4067842838e0dd970b1809b5769eef4f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:01:32 -0400 Subject: [PATCH 045/166] Engine: only hidden names are read as macro storage A visible workbook-scope name such as `_gc_macro_0_0` matched the storage key and was read as a macro and removed when the workbook opened. Macro storage is written hidden, so only hidden names are read back; a person's own name of that shape stays a name. --- crates/engine/src/macros/mod.rs | 4 +++- crates/engine/src/macros/tests.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 9a580c791..38016049d 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -177,7 +177,9 @@ pub fn load(wb: &mut Workbook) -> Vec { /// The item key (`macro_`, lower-cased) of a hidden macro name, and `n` when it is a number. fn macro_item(n: &DefinedName) -> Option<(String, Option)> { - let key = stash::item_key(&n.name).filter(|k| n.scope.is_none() && k.starts_with(MACRO_KEY))?; + // Only a hidden workbook-scope name is macro storage: a person's own visible name that + // happens to look like one stays a name. + let key = stash::item_key(&n.name).filter(|k| n.hidden && n.scope.is_none() && k.starts_with(MACRO_KEY))?; let i = key.strip_prefix(MACRO_KEY).and_then(|i| i.parse().ok()); Some((key, i)) } diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 4bd2ac1bc..9a28f14f9 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -127,6 +127,19 @@ fn hidden(name: &str, text: &str) -> DefinedName { DefinedName { name: name.into(), scope: None, formula: format!("\"{}\"", text.replace('"', "\"\"")), comment: String::new(), hidden: true } } +#[test] +fn a_visible_name_that_looks_like_macro_storage_is_left_alone() { + // Macro storage is hidden workbook-scope names; a visible `_gc_macro_0_0` someone defined + // is neither read as a macro nor removed when the workbook opens. + let mut wb = Workbook::new(); + let name = DefinedName { hidden: false, ..hidden("_gc_macro_0_0", "x") }; + wb.names.push(name.clone()); + let warnings = load(&mut wb); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(wb.macros.is_empty()); + assert_eq!(wb.names, vec![name]); +} + #[test] fn scripts_and_vba_stay_as_they_are() { // Automate scripts keep their own storage, and a VBA project is carried through unchanged. From 045cf428f5cc8362ab8f82bab96456c106a326f7 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:03:18 -0400 Subject: [PATCH 046/166] Engine: a column header made by a formula matches its token while formulas are shown `col("Amount")` read the header through the cell's display text, which is the formula while Show Formulas is on, so a header such as ="Amo"&"unt" matched nothing. The header is now the cell's value as formatted, whatever the sheet shows. --- crates/engine/src/macros/resolve.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index 47636bd61..b3cf438fa 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -320,7 +320,9 @@ fn header_col(env: &Env, place: Place, row: u32, h: &str) -> Option { let sh = env.wb.sheet(place.sheet)?; let r = place.range; (r.start.col..=r.end.col) - .position(|c| crate::display::cell_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim())) + // The header's value, however the sheet shows it: with Show Formulas on, the cell's + // display text is its formula. + .position(|c| crate::display::value_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim())) .map(|i| i as i64 + 1) } @@ -555,6 +557,20 @@ mod tests { } } + #[test] + fn a_formula_header_matches_while_formulas_are_shown() { + // The header was read through the cell's display text, which is the formula while Show + // Formulas is on, so `col("Amount")` found no column headed by a formula. + let mut wb = wb(); + let sh = wb.sheet_mut(0).unwrap(); + let formula = Some(std::sync::Arc::new(gridcraft_model::Formula::new("\"Amo\"&\"unt\""))); + sh.set_cell(CellRef::new(1, 2), gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default() }); + sh.show_formulas = true; + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + check(&env, "Input.col(\"Amount\")", "C2:C11"); + } + #[test] fn every_token_resolves() { let wb = wb(); From c0079aabe99be12b8edc928addf1cb6708de77bb Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:22:50 -0400 Subject: [PATCH 047/166] Docs: a failed macro run rolls back workbook changes; a file it already wrote stays The intro said a macro run rolls everything back on an error, but a step that exports or prints to a path writes the file before a later step can fail, and the Running section says that file stays. The intro now limits the guarantee to workbook changes. --- docs/macros.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index dc0c044c7..7c7953ccf 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -4,8 +4,9 @@ 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, runs as one undo step, rolls everything back on an error, checks -where its results will land before it writes them, and does not touch files without permission. +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), checks where its results will land before it writes them, +and does not touch files without permission. Agents run macros with `macro.run` like any other command. This page describes the macro format, its tokens, and running and checking macros. The engine has From 6a004bf610feba45d3efc0b166d81963066b4238 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:31:17 -0400 Subject: [PATCH 048/166] Engine: stopping Record Actions waits while a macro in its workbook is being debugged Stopping Record Actions saves a script into the workbook, which the debugger lock refuses while a macro there is paused. The refusal came after Record Actions had taken its recording, so the recording was lost. Stopping is now refused before anything changes, and stopping again after the run saves the script. --- crates/engine/src/lib.rs | 6 +++++- crates/engine/src/macros/debug.rs | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6807d1cce..242c86b97 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -415,7 +415,11 @@ impl Session { // touched wait, also when another command runs them (a script, or a recording that stops // and saves). Only the run's own steps go ahead. Other workbooks stay editable. let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping())); - if spec.undoable && id != "macro.debug" && locked { + // Stopping Record Actions saves a script into the workbook; refuse it before it takes the + // recording, so stopping again after the run keeps what was recorded. + let stops_record_actions = + id == "automate.recordActions" && (params.get("stop").and_then(Json::as_bool) == Some(true) || self.recording.is_some()); + if (spec.undoable || stops_record_actions) && id != "macro.debug" && locked { return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into())); } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); diff --git a/crates/engine/src/macros/debug.rs b/crates/engine/src/macros/debug.rs index b1e53cf9b..8c5b7e201 100644 --- a/crates/engine/src/macros/debug.rs +++ b/crates/engine/src/macros/debug.rs @@ -468,5 +468,10 @@ mod tests { let r = s.execute("macro.debug", json!({"action": "step"})).unwrap(); assert_eq!(r["executed"], 2, "{r}"); assert!(s.macro_debug.is_none()); + // The recording was kept: stopping now saves it. + let saved = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap(); + assert_eq!(saved["saved"], "S", "{saved}"); + let scripts = s.execute("automate.allScripts", json!({})).unwrap(); + assert_eq!(scripts[0]["name"], "S", "{scripts}"); } } From 20eb282e75bedcfee6dcb8cea6163ab22cc4af7a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:28:02 -0400 Subject: [PATCH 049/166] Docs: catalog count 275 / 291 in the roadmap and parity summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog went from 274 / 290 to 275 / 291 with View › Macros › Record Macro, and parity-checklist.md says so, but the ROADMAP.md headline, ui-parity.md and target-app-parity.md still said 274 / 290 (and 290 catalog ids). They now match, with revision rows. --- ROADMAP.md | 2 +- docs/target-app-parity.md | 6 +++--- docs/ui-parity.md | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2c0ee33cd..de39b787a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 275 / 291 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index de57d10d8..479583f29 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **274 / 290 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **275 / 291 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | @@ -54,7 +54,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus `SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only `ADD2`: **505**, all of them Excel names. -- **Ribbon catalog:** `crates/engine/src/catalog.rs` (290 unique ids across 15 tabs) against the +- **Ribbon catalog:** `crates/engine/src/catalog.rs` (291 unique ids across 15 tabs) against the 313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and identical to the committed `parity-checklist.md`. The catalog is our own transcription of Excel's main ribbon tabs; it leaves out most contextual tabs (Picture Format, Shape Format, @@ -332,7 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | Automation: typed macros in the engine and `macro.*` commands, no interface yet (no score change) | +| 2026-10-21 | minor | Automation: typed macros in the engine and `macro.*` commands, no interface yet (no score change); catalog 274 / 290 → 275 / 291 (View › Macros › Record Macro) | | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 34cb502f5..b4d66c271 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (ribbon catalog count 275 / 291) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (274 / 290). +separately in [parity-checklist.md](parity-checklist.md) (275 / 291). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Ribbon catalog count 274 / 290 → 275 / 291 (View › Macros › Record Macro) | | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | From 3f5f6ea4f1fc9f1ab6bb66d45f85d2693cd16b8e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:59:29 -0400 Subject: [PATCH 050/166] Engine: a name given when a recording stops never replaces an existing macro Stopping a recording with a name that a macro already has (in any case) saved over that macro's steps, inputs and description, with only Undo to get them back. A taken name now gets a suffix ("Tidy (2)"), and the save passes replace: false as a backstop. --- crates/engine/src/cmd/macros.rs | 7 ++++--- crates/engine/src/tests/macros.rs | 15 +++++++++++++++ docs/macros.md | 3 ++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 383471f53..cd1f36c67 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Record Macro", ["View", "Macros"], None, - "{stop?: bool, relative?: bool (default true; when starting), name?, description? (when stopping)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", + "{stop?: bool, relative?: bool (default true; when starting), name?, description? (when stopping; a taken name gets a suffix)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", has_doc, record ), @@ -217,8 +217,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star relative: true, }); let wb = d.wb.clone(); + // A name another macro has gets a suffix, so stopping never replaces a macro. let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { - Some(n) => n.to_string(), + Some(n) => crate::macros::unique_name(&wb, n), None => crate::macros::next_name(&wb), }; let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &wb); @@ -231,7 +232,7 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star // Saved into that workbook even when another one is active now. let active = s.active_index(); s.set_active(at); - let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac)})); + let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false})); s.set_active(active); saved?; Ok(json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped, "skipped": converted.skipped, "inputs": inputs})) diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 323b64ed4..0339ad5ee 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1126,3 +1126,18 @@ fn a_recording_is_saved_in_the_workbook_where_it_started() { assert!(!s.is_recording()); assert!(s.doc().unwrap().wb.macros.is_empty()); } + +#[test] +fn a_name_given_when_recording_stops_never_replaces_a_macro() { + let mut s = s(); + let original = json!({"command": "cell.set", "params": {"cell": "Z9", "input": "mine"}}); + put(&mut s, json!({"name": "Tidy", "description": "keep me", "steps": [original]})); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "recorded"); + let r = s.execute("macro.record", json!({"stop": true, "name": "tidy"})).unwrap(); + assert_eq!(r["saved"], "tidy (2)"); + let kept = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Tidy"})).unwrap()).unwrap(); + assert_eq!((kept.name.as_str(), kept.description.as_str(), kept.steps.len()), ("Tidy", "keep me", 1)); + assert_eq!(recorded(&mut s, "Tidy"), ["cell.set {\"cell\":\"Z9\",\"input\":\"mine\"}"]); + assert_eq!(recorded(&mut s, "tidy (2)"), ["cell.set {\"cell\":\"A1\",\"input\":\"recorded\"}"]); +} diff --git a/docs/macros.md b/docs/macros.md index 235d1f2b4..9f1461fc2 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -119,7 +119,8 @@ of the step that just ran, and the cells changed so far. ## Recording `macro.record` toggles recording (`relative: false` for absolute references; `name` and -`description` when stopping). The macro is saved in the workbook where recording started, even if +`description` when stopping; a name that another macro has gets a suffix, such as `Tidy (2)`, so +stopping never replaces a macro). The macro is saved in the workbook where recording started, even if another one is active; if that workbook was closed, stopping ends the recording without saving it. When saving the recording fails (a name the check refuses, a workbook at its macro limit), recording goes on, so stopping again keeps what was recorded. With relative references the From ea691f703c5cdbcdacef3123fd553750fb7508c8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:27:23 -0400 Subject: [PATCH 051/166] Engine: a stopped recording reports the steps cut at the macro step limit in dropped A recording keeps up to 100,000 commands, and inlined macro and script runs can add more, but a macro holds at most 10,000 steps. Stopping truncated the converted steps to that limit without saying so: dropped counted only the recorder's own overflow, so a 10,005-step recording saved a 10,000-step macro and reported nothing missing. The steps cut at the macro limit are now added to dropped. --- crates/engine/src/cmd/macros.rs | 6 +++++- crates/engine/src/tests/macros.rs | 22 ++++++++++++++++++++++ docs/macros.md | 9 +++++---- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index cd1f36c67..1af881587 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -223,6 +223,8 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star None => crate::macros::next_name(&wb), }; let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &wb); + // Steps past the macro limit are left out and counted with the ones the recorder dropped. + let cut = converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS); converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS); if let Some(desc) = str_param(p, "description") { converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect(); @@ -235,5 +237,7 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false})); s.set_active(active); saved?; - Ok(json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped, "skipped": converted.skipped, "inputs": inputs})) + Ok( + json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}), + ) } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 0339ad5ee..3f0901c94 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1141,3 +1141,25 @@ fn a_name_given_when_recording_stops_never_replaces_a_macro() { assert_eq!(recorded(&mut s, "Tidy"), ["cell.set {\"cell\":\"Z9\",\"input\":\"mine\"}"]); assert_eq!(recorded(&mut s, "tidy (2)"), ["cell.set {\"cell\":\"A1\",\"input\":\"recorded\"}"]); } + +#[test] +fn steps_past_the_macro_limit_are_counted_as_dropped() { + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + let (doc, selection) = (s.doc().unwrap().uid, s.doc().unwrap().selection.clone()); + let rec = s.recorder.as_mut().unwrap(); + for i in 0..gridcraft_model::MAX_MACRO_STEPS + 5 { + rec.push(crate::record::RecordedStep { + doc, + sheet: 0, + sheet_name: "Sheet1".into(), + selection: selection.clone(), + id: "cell.set".into(), + params: json!({"cell": "A1", "input": i.to_string()}), + }); + } + rec.dropped = 3; + let r = s.execute("macro.record", json!({"stop": true, "name": "Long"})).unwrap(); + assert_eq!(r["steps"], gridcraft_model::MAX_MACRO_STEPS); + assert_eq!(r["dropped"], 8, "{r}"); +} diff --git a/docs/macros.md b/docs/macros.md index 9f1461fc2..6cc67d4e8 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -123,10 +123,11 @@ of the step that just ran, and the cells changed so far. stopping never replaces a macro). The macro is saved in the workbook where recording started, even if another one is active; if that workbook was closed, stopping ends the recording without saving it. When saving the recording fails (a name the check refuses, a workbook at its macro limit), -recording goes on, so stopping again keeps what was recorded. With relative references the -selection when recording starts is the example input: references in and near it on its sheet become -tokens (on another sheet `C4` stays that sheet's `C4`), and the input's shape, header and column -types are inferred. Toggles such as Bold are recorded with the state they set +recording goes on, so stopping again keeps what was recorded. A recording keeps up to 100,000 +commands and a macro up to 10,000 steps; steps past either limit are left out and counted in +`dropped`. With relative references the selection when recording starts is the example input: +references in and near it on its sheet become tokens (on another sheet `C4` stays that sheet's +`C4`), and the input's shape, header and column types are inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not run are left out and listed in the result, also from the steps of a macro or script run while recording; a macro run while recording is replaced by its steps when it has no inputs, no output From bbf1bc441ba1c382187e5854cb4617c27b3004c0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:28:03 -0400 Subject: [PATCH 052/166] Engine: stopping a recording waits while a macro in its workbook is being debugged Stopping a recording saved the macro with a nested macro.save while a debugged run's undo group could be open on the same workbook. Stopping the run without keeping its changes then deleted the macro that macro.record had reported as saved. Stopping now fails with "Finish or stop the macro first" under macro.record before anything changes, and the recording goes on, as when a save fails for another reason, so stopping again after the run ends saves it. --- crates/engine/src/cmd/macros.rs | 9 +++++++-- crates/engine/src/tests/macros.rs | 19 +++++++++++++++++++ docs/macros.md | 22 +++++++++++----------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 1af881587..426d06492 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -185,8 +185,9 @@ fn start_recording(s: &mut Session, relative: bool) -> Result { } /// Stops recording and saves what was recorded as a macro, in the workbook where recording -/// started. When saving fails (a bad name, a full workbook) the recording goes on, so stopping -/// again keeps what was recorded; when that workbook was closed, the recording ends unsaved. +/// started. When saving fails (a bad name, a full workbook, a macro being debugged there) the +/// recording goes on, so stopping again keeps what was recorded; when that workbook was closed, +/// the recording ends unsaved. fn stop_recording(s: &mut Session, p: &Json) -> Result { let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) }; let start = s.macro_recording.take(); @@ -209,6 +210,10 @@ fn stop_recording(s: &mut Session, p: &Json) -> Result { /// Saves the recording into the workbook at index `at`. fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option, at: usize) -> Result { let d = s.docs.get(at).ok_or(EngineError::NoDocument)?; + // A macro being debugged there could still roll the saved macro back with its run. + if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) { + return Err(EngineError::Disabled("macro.record".into(), crate::macros::debug::BUSY.into())); + } let start = start.unwrap_or_else(|| RecordingStart { doc: d.uid, sheet: d.wb.active_sheet, diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 3f0901c94..df6e59606 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1163,3 +1163,22 @@ fn steps_past_the_macro_limit_are_counted_as_dropped() { assert_eq!(r["steps"], gridcraft_model::MAX_MACRO_STEPS); assert_eq!(r["dropped"], 8, "{r}"); } + +#[test] +fn a_recording_waits_while_a_macro_in_its_workbook_is_debugged() { + let mut s = s(); + stepper(&mut s); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "Z1", "typed"); + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + // Saved now, the macro would go into the run's undo group, and stopping the run without + // keeping its changes would delete it again. + let r = s.execute("macro.record", json!({"stop": true, "name": "Typed"})); + assert!(matches!(&r, Err(EngineError::Disabled(id, why)) if id == "macro.record" && why == crate::macros::debug::BUSY), "{r:?}"); + assert!(s.is_recording()); + debug(&mut s, json!({"action": "stop"})); + let r = s.execute("macro.record", json!({"stop": true, "name": "Typed"})).unwrap(); + assert_eq!(r["saved"], "Typed"); + assert_eq!(recorded(&mut s, "Typed"), ["cell.set {\"cell\":\"Z1\",\"input\":\"typed\"}"]); +} diff --git a/docs/macros.md b/docs/macros.md index 6cc67d4e8..503068707 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -122,17 +122,17 @@ of the step that just ran, and the cells changed so far. `description` when stopping; a name that another macro has gets a suffix, such as `Tidy (2)`, so stopping never replaces a macro). The macro is saved in the workbook where recording started, even if another one is active; if that workbook was closed, stopping ends the recording without saving it. -When saving the recording fails (a name the check refuses, a workbook at its macro limit), -recording goes on, so stopping again keeps what was recorded. A recording keeps up to 100,000 -commands and a macro up to 10,000 steps; steps past either limit are left out and counted in -`dropped`. With relative references the selection when recording starts is the example input: -references in and near it on its sheet become tokens (on another sheet `C4` stays that sheet's -`C4`), and the input's shape, header and column types are inferred. Toggles such as Bold are recorded with the state they set -(`"on": true`), so playing them back sets them rather than flipping them. Commands a macro may not -run are left out and listed in the result, also from the steps of a macro or script run while -recording; a macro run while recording is replaced by its steps when it has no inputs, no output -and only plain commands (otherwise it is left out and listed), and an Automate script run by the -script's steps. +When saving the recording fails (a name the check refuses, a workbook at its macro limit, a +macro being debugged in that workbook), recording goes on, so stopping again keeps what was +recorded. A recording keeps up to 100,000 commands and a macro up to 10,000 steps; steps past +either limit are left out and counted in `dropped`. With relative references the selection when +recording starts is the example input: references in and near it on its sheet become tokens (on +another sheet `C4` stays that sheet's `C4`), and the input's shape, header and column types are +inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so playing +them back sets them rather than flipping them. Commands a macro may not run are left out and +listed in the result, also from the steps of a macro or script run while recording; a macro run +while recording is replaced by its steps when it has no inputs, no output and only plain commands +(otherwise it is left out and listed), and an Automate script run by the script's steps. ## Other commands and storage From 18ec913c4e84a426700942ec96a4c3f2c44a4ba0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:57 -0400 Subject: [PATCH 053/166] Engine: a command keeps its group's name only for a step that is exactly the group's The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone. The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record. --- crates/engine/src/lib.rs | 32 +++++++++++++-------- crates/engine/src/tests/seams.rs | 48 ++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8614bc094..588a8a2a2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,6 +97,17 @@ struct OpenGroup { redo: Vec, } +/// The undo step the last [`Session::group_end`] made: its document and the workbook it led to. +/// The entry itself holds the workbook it started from, so a command can check that the step is +/// exactly its own (the same start and end as the command's) and keep the group's name for it. +/// A weak reference: the record keeps no workbook alive, and a workbook that is gone can't be +/// a command's result. +#[derive(Debug)] +struct GroupStep { + doc: u64, + after: std::sync::Weak, +} + /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. #[derive(Clone, Debug)] pub struct Clipboard { @@ -405,8 +416,9 @@ pub struct Session { next_group: u64, /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. group_epoch: u64, - /// The step the last group made: document, its undo length after the step, and the epoch. - group_step: Option<(u64, usize, u64)>, + /// The step the last `group_end` made, so `execute` can tell that a command's step is exactly + /// its group's. + group_step: Option, /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses /// [`cmd::format::approx_text_width`]. pub text_width: Option, @@ -567,12 +579,14 @@ impl Session { let mut label = spec.label.trim_end_matches('…').to_string(); if own_groups && let Some((_, len)) = start { // A command whose only step is its group's keeps that name ("Run Macro: Totals"). - // The step must have been made by a group this command ended (its epoch is - // past the one the command started with): an older record at the same length - // belongs to an earlier command, undone since. + // That is so only when the one entry the command added is a group's step from + // the command's own start to its own result: an entry a nested command made, + // a group step of an earlier command (undone since), or a group's step that + // the command edited past all name the step after the command instead. if d.undo.len() == len + 1 - && self.group_step.is_some_and(|(doc, n, epoch)| doc == uid && n == len + 1 && epoch > group_epoch) && let Some(last) = d.undo.last() + && Arc::ptr_eq(&last.wb, &wb) + && self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.after.upgrade().is_some_and(|after| Arc::ptr_eq(&after, &d.wb))) { label = last.label.clone(); } @@ -646,7 +660,7 @@ impl Session { let changed = !Arc::ptr_eq(&d.wb, &g.wb); if changed && !outer { push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection); - self.group_step = Some((g.doc, d.undo.len(), self.group_epoch)); + self.group_step = Some(GroupStep { doc: g.doc, after: Arc::downgrade(&d.wb) }); d.redo.clear(); d.revision += 1; } @@ -679,10 +693,6 @@ impl Session { d.undo.truncate(g.start); d.redo = g.redo; d.revision += 1; - // A step the rollback dropped is no longer anyone's to name. - if self.group_step.is_some_and(|(doc, n, _)| doc == g.doc && n > g.start) { - self.group_step = None; - } Ok(()) } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index b3397c724..a598bb7c7 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -226,6 +226,27 @@ fn register_group_commands() { }); // A command that is one group keeps the group's name. register("test.onlyGroup", |s, _| s.grouped("Named Step", |s| put(s, "D1", "x")).map(|_| json!({}))); + // A group, then an edit the command makes itself (no nested command, so no undo entry). + register("test.groupThenEdit", |s, _| { + s.grouped("Named Step", |s| put(s, "D1", "x"))?; + crate::cmd::edit(s, |cx| { + let at = gridcraft_core::CellRef::parse("D2").ok_or_else(|| EngineError::Other("D2".into()))?; + cx.sheet_mut(0)?.set_value(at, gridcraft_core::Value::Text("y".into())); + cx.touch(0, at); + Ok(()) + })?; + Ok(json!({})) + }); + // A group with a change, then one that is rolled back: the step is still the first group's. + register("test.groupThenRolledBack", |s, _| { + s.grouped("Named Step", |s| put(s, "D1", "x"))?; + let failed: crate::Result<()> = s.grouped("Doomed", |s| { + put(s, "D3", "gone")?; + Err(EngineError::Other("no".into())) + }); + assert!(failed.is_err()); + Ok(json!({})) + }); // An edit, then the rollback of the innermost group that was open before the command. register("test.rollbackOpenGroup", |s, _| { put(s, "E1", "gone")?; @@ -266,9 +287,9 @@ fn a_command_that_runs_its_own_group_makes_one_step() { #[test] fn a_stale_group_step_does_not_name_a_later_command() { - // The record of the last group step (document, length, epoch) survives the Undo of that step. - // A later command that ends a group of its own at the same history length, after rolling - // another back, must not take the name of the nested edit it made. + // The record of the last group step survives the Undo of that step. A later command that + // ends a group of its own at the same history length, after rolling another back, must not + // take the name of the nested edit it made. register_group_commands(); let mut s = s(); s.execute("test.onlyGroup", json!({})).unwrap(); @@ -285,6 +306,27 @@ fn a_stale_group_step_does_not_name_a_later_command() { assert!(v(&s, "B2").is_empty() && v(&s, "D1").is_empty()); } +#[test] +fn a_command_keeps_its_groups_name_only_for_exactly_that_step() { + // The name comes from the group only when the group's step is the command's whole step: an + // edit after the group (no nested command, so the history length matches) makes it the + // command's own, while a later group that rolled back changes nothing. + register_group_commands(); + let mut s = s(); + s.execute("test.groupThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["test.groupThenEdit"]); + assert_eq!((v(&s, "D1").display(), v(&s, "D2").display()), ("x".into(), "y".into())); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty() && v(&s, "D2").is_empty()); + assert_eq!(undo_labels(&s), Vec::::new()); + s.execute("test.groupThenRolledBack", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["Named Step"]); + assert_eq!(v(&s, "D1").display(), "x"); + assert!(v(&s, "D3").is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty()); +} + #[test] fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() { // The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback From a8e8bf851e7d7996c5321f21f3ce42d1e1a97280 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:57 -0400 Subject: [PATCH 054/166] Engine: the recording buffer is bounded in bytes and keeps the start of a recording The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps. --- crates/engine/src/record.rs | 15 +++++++++++++-- crates/engine/src/tests/seams.rs | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index dbbc1a8af..fefd07234 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -13,6 +13,10 @@ use crate::Selection; /// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`]. pub const RECORD_LIMIT: usize = 100_000; +/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full +/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so +/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive. +pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024; /// One recorded command and where it ran. #[derive(Clone, Debug, PartialEq, Serialize)] @@ -34,14 +38,21 @@ pub struct RecordedStep { #[derive(Clone, Debug, Default)] pub struct Recorder { pub steps: Vec, - /// Steps not kept because the recording reached [`RECORD_LIMIT`]. + /// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`]. + /// Once a step is dropped, every later one is too, so `steps` is always the beginning of + /// what ran, never a sequence with gaps. pub dropped: usize, + /// The size of `steps` serialized as JSON. + bytes: usize, } impl Recorder { /// Adds a step, or counts it as dropped once the buffer is full. pub fn push(&mut self, step: RecordedStep) { - if self.steps.len() < RECORD_LIMIT { + let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); + let fits = self.steps.len() < RECORD_LIMIT && self.bytes.saturating_add(size) <= RECORD_MAX_BYTES; + if fits && self.dropped == 0 { + self.bytes = self.bytes.saturating_add(size); self.steps.push(step); } else { self.dropped = self.dropped.saturating_add(1); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index a598bb7c7..c524b397d 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -494,6 +494,31 @@ fn the_recorder_has_a_cap() { assert_eq!(rec.dropped, 3); } +#[test] +fn the_recorder_has_a_byte_cap_and_keeps_a_prefix() { + // A `file.open` step holds the whole file as base64: the buffer is bounded in bytes too, and + // once a step is dropped every later one is, so what is kept is always how the recording began. + let mut rec = crate::record::Recorder::default(); + let step = |n: usize| crate::record::RecordedStep { + doc: 1, + sheet: 0, + sheet_name: "Sheet1".into(), + selection: crate::Selection::default(), + id: "file.open".into(), + params: json!({"base64": "A".repeat(n)}), + }; + let big = crate::record::RECORD_MAX_BYTES / 2; + rec.push(step(big)); + assert_eq!((rec.steps.len(), rec.dropped), (1, 0)); + rec.push(step(big)); + assert_eq!((rec.steps.len(), rec.dropped), (1, 1), "the second does not fit beside the first"); + rec.push(step(1)); + assert_eq!((rec.steps.len(), rec.dropped), (1, 2), "a small step after a dropped one is dropped too"); + let mut rec = crate::record::Recorder::default(); + rec.push(step(crate::record::RECORD_MAX_BYTES + 1)); + assert_eq!((rec.steps.len(), rec.dropped), (0, 1), "a step larger than the whole budget is not kept"); +} + #[test] fn passwords_stay_out_of_the_recording_buffer() { const PW: &str = "synthetic-pw-5521"; From 524826687ccc8b9abb700e7c55c42f03c379a45a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:57 -0400 Subject: [PATCH 055/166] Docs: the architecture revision row carries the date the change lands; document trust beside script trust The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178). --- docs/architecture.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b656afc66..f45774508 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -63,7 +63,9 @@ Actions made of many commands (macros, a debugger that runs one step per call) u (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and `FromFile` for workbooks opened from a file, and for workbooks that received formulas or - validation pasted from one. Active content in a `FromFile` workbook asks before it runs. + validation pasted from one. Active content in a `FromFile` workbook asks before it runs. This + is provenance, which no decision changes; `DocState::scripts_trusted` is the separate + per-session consent for stored `automate.*` scripts (`automate.trustScripts`). - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and 8,192-character limits, so it survives an xlsx round trip. The older single-name `_sc_` @@ -94,6 +96,6 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | -| 2026-10-11 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | From 5ca886507c91f8de8ef90a3743e9be299ea6f62c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:21:48 -0400 Subject: [PATCH 056/166] Engine: writing a stash item checks for a clashing visible name in one pass over the names The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged. --- crates/engine/src/cmd/stash.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 9043262d2..683c42c2d 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -65,14 +65,17 @@ pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { } let formulas = chunk_formulas(text); // A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside - // it with the same name would be the one an xlsx file drops. Refuse before changing anything. - for i in 0..formulas.len() { - let name = format!("{PREFIX}{key}_{i}"); - if wb.names.iter().any(|n| n.scope.is_none() && !n.hidden && n.name.eq_ignore_ascii_case(&name)) { - return Err(EngineError::Other(format!( - "The workbook has a defined name '{name}', which GridCraft needs for its own data. Rename it first." - ))); - } + // it with the same name would be the one an xlsx file drops. Refuse before changing anything, + // in one pass over the names (a file can carry many thousands). + let chunks = formulas.len() as u64; + let taken = |n: &&DefinedName| { + n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks)) + }; + if let Some(n) = wb.names.iter().find(taken) { + return Err(EngineError::Other(format!( + "The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.", + n.name + ))); } remove(wb, key); for (i, formula) in formulas.into_iter().enumerate() { From 114088d589bf661ecff010a9aa162470bbd2e11e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:33:42 -0400 Subject: [PATCH 057/166] Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at. A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits. The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it. --- crates/engine/src/lib.rs | 73 ++++++++++++++++++++++++-------- crates/engine/src/tests/seams.rs | 63 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 588a8a2a2..b44ed0852 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,15 +97,19 @@ struct OpenGroup { redo: Vec, } -/// The undo step the last [`Session::group_end`] made: its document and the workbook it led to. -/// The entry itself holds the workbook it started from, so a command can check that the step is -/// exactly its own (the same start and end as the command's) and keep the group's name for it. -/// A weak reference: the record keeps no workbook alive, and a workbook that is gone can't be -/// a command's result. +/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the +/// workbook and selection it ended at, the undo length then, and whether it made a step. A +/// command that ended the group tells its own later changes from the group's step by it, and +/// keeps the group's name only when the group's step is exactly its own (the entry holds the +/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no +/// workbook alive between commands. #[derive(Debug)] struct GroupStep { doc: u64, - after: std::sync::Weak, + after: Arc, + selection: Selection, + len: usize, + stepped: bool, } /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. @@ -416,8 +420,7 @@ pub struct Session { next_group: u64, /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. group_epoch: u64, - /// The step the last `group_end` made, so `execute` can tell that a command's step is exactly - /// its group's. + /// Where the last group ended, so `execute` can tell a command's own changes from its group's. group_step: Option, /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses /// [`cmd::format::approx_text_width`]. @@ -549,6 +552,7 @@ impl Session { } if self.depth == 0 { self.trim_history(); + self.group_step = None; } return Err(EngineError::Internal(id.to_string(), msg)); } @@ -563,21 +567,44 @@ impl Session { } self.push_journal(id, cmd::journal_params(¶ms)); } - // Inside an undo group, or when the command ended one that was open before it, the group - // owns the step. A command that opened and closed its own groups makes one step of its - // own, which replaces the steps those groups made. + // Inside an undo group the group owns the step. A command that ended a group open before + // it leaves the group its step and makes one of its own for what it changed after the end. + // A command that opened and closed its own groups makes one step of its own, which + // replaces the steps those groups made. let epoch_changed = self.group_epoch != group_epoch; let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid)); - let own_groups = epoch_changed && !in_group_now && start.is_some_and(|(was_open, _)| !was_open); - let grouped = !own_groups && (epoch_changed || in_group_now); + let was_open = start.is_some_and(|(was_open, _)| was_open); if spec.undoable && let Some((uid, wb, sel)) = before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&d.wb, &wb) { - if !grouped { - let mut label = spec.label.trim_end_matches('…').to_string(); - if own_groups && let Some((_, len)) = start { + let label = spec.label.trim_end_matches('…').to_string(); + if in_group_now { + // A group this command opened and left open (a debugger's start) takes the + // command's start as its own, so what the command changed before opening it is + // part of the group's step and not a step of its own. + if !was_open + && let Some((_, len)) = start + && let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id) + { + g.start = len; + g.wb = wb; + g.selection = sel; + } + } else if epoch_changed && was_open { + // The group it ended made its step; changes after the end, whether direct or by + // nested commands, are the command's own step from where the group ended. + if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid) + && (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb)) + { + d.undo.truncate(step.len); + push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone()); + d.redo.clear(); + } + } else { + let mut label = label; + if epoch_changed && let Some((_, len)) = start { // A command whose only step is its group's keeps that name ("Run Macro: Totals"). // That is so only when the one entry the command added is a group's step from // the command's own start to its own result: an entry a nested command made, @@ -586,7 +613,7 @@ impl Session { if d.undo.len() == len + 1 && let Some(last) = d.undo.last() && Arc::ptr_eq(&last.wb, &wb) - && self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.after.upgrade().is_some_and(|after| Arc::ptr_eq(&after, &d.wb))) + && self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb)) { label = last.label.clone(); } @@ -603,6 +630,7 @@ impl Session { // lengths it compared stay true. if self.depth == 0 { self.trim_history(); + self.group_step = None; } result } @@ -660,10 +688,13 @@ impl Session { let changed = !Arc::ptr_eq(&d.wb, &g.wb); if changed && !outer { push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection); - self.group_step = Some(GroupStep { doc: g.doc, after: Arc::downgrade(&d.wb) }); d.redo.clear(); d.revision += 1; } + if !outer { + self.group_step = + Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed }); + } // Inside a command, the outermost command applies the history limit when it finishes. if self.depth == 0 { self.trim_history(); @@ -693,6 +724,12 @@ impl Session { d.undo.truncate(g.start); d.redo = g.redo; d.revision += 1; + // The record of the last group step stays when the document is back exactly where that + // step left it (this group began there); otherwise the rollback is where the group ended. + let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb)); + if !kept && !self.groups.iter().any(|o| o.doc == g.doc) { + self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false }); + } Ok(()) } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index c524b397d..1f83bd67d 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -261,6 +261,20 @@ fn register_group_commands() { s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?; Ok(json!({})) }); + // The end of the innermost group that was open before the command, then an edit. + register("test.endOpenGroupThenEdit", |s, _| { + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }); + s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?; + put(s, "E1", "after")?; + Ok(json!({})) + }); + // An edit, then a group the command leaves open (a debugger's start). + register("test.editThenOpenGroup", |s, _| { + put(s, "A1", "first")?; + let _open = s.group_begin("Debug")?; + put(s, "A2", "second")?; + Ok(json!({})) + }); } #[test] @@ -327,6 +341,55 @@ fn a_command_keeps_its_groups_name_only_for_exactly_that_step() { assert!(v(&s, "D1").is_empty()); } +#[test] +fn what_a_command_changes_after_ending_a_group_is_its_own_step() { + // Ending a group that was open before the command leaves the group its step (if it made one) + // and makes a step of the command's own for what it changed after the end. + register_group_commands(); + let mut s = s(); + // The group made no step: the command's edit must still be undoable, under its own name. + let _unchanged = s.group_begin("Debug").unwrap(); + s.execute("test.endOpenGroupThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["test.endOpenGroupThenEdit"]); + assert_eq!(v(&s, "E1").display(), "after"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E1").is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "E1").display(), "after"); + // The group made a step: two steps, the group's and the command's. + s.execute("edit.undo", json!({})).unwrap(); + let _changed = s.group_begin("Debug").unwrap(); + set(&mut s, "B1", "inside"); + s.execute("test.endOpenGroupThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["Debug", "test.endOpenGroupThenEdit"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E1").is_empty()); + assert_eq!(v(&s, "B1").display(), "inside"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "B1").is_empty()); +} + +#[test] +fn a_group_a_command_leaves_open_starts_where_the_command_did() { + // A command that edits and then opens a group it leaves open has no undo step of its own: + // the group's step, made when it ends, covers the command's edit too, so no state from + // inside the command can come back. + register_group_commands(); + let mut s = s(); + set(&mut s, "C1", "base"); + s.execute("test.editThenOpenGroup", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert!(s.in_group(d.uid)); + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }).unwrap(); + assert!(s.group_end(token).unwrap()); + assert_eq!(undo_labels(&s), ["Enter Cell", "Debug"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A1").is_empty() && v(&s, "A2").is_empty()); + assert_eq!(v(&s, "C1").display(), "base"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("first".into(), "second".into())); +} + #[test] fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() { // The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback From 6ab8d97118a89eed61c289c333a00693c59e35e8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:35:45 -0400 Subject: [PATCH 058/166] Engine: the undo steps made inside a command are bounded while it runs The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once. Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop. --- crates/engine/src/lib.rs | 31 ++++++++++++++++++++++++++++++- crates/engine/src/tests/seams.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b44ed0852..6e37058dd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -422,6 +422,10 @@ pub struct Session { group_epoch: u64, /// Where the last group ended, so `execute` can tell a command's own changes from its group's. group_step: Option, + /// For each running command, innermost last: its document and that document's undo length + /// when it began. Steps before that length are not touched while the command runs (see + /// [`Session::bound_tail`]). + marks: Vec<(u64, usize)>, /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses /// [`cmd::format::approx_text_width`]. pub text_width: Option, @@ -533,9 +537,15 @@ impl Session { Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), None => (0, 0, String::new(), Selection::default()), }); + let doc_uid = before.as_ref().map(|(uid, ..)| *uid); + let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len)); + self.marks.extend(marked); self.depth = self.depth.saturating_add(1); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))); self.depth = self.depth.saturating_sub(1); + if marked.is_some() { + self.marks.pop(); + } let result = match result { Ok(r) => r, Err(p) => { @@ -627,10 +637,12 @@ impl Session { d.revision += 1; } // The history limit applies once the outermost command has collapsed its steps, so the - // lengths it compared stay true. + // lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own. if self.depth == 0 { self.trim_history(); self.group_step = None; + } else if let Some(uid) = doc_uid { + self.bound_tail(uid); } result } @@ -702,6 +714,23 @@ impl Session { Ok(changed) } + /// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per + /// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest. + /// Only steps after the latest mark go: a running command's start, an open group's start and + /// the end of the last group are positions the command or group collapses its steps by when + /// it finishes, so what is before them stays as it is until [`Session::trim_history`]. + fn bound_tail(&mut self, uid: u64) { + let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; + let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len); + let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start); + let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len); + let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len()); + let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes)); + while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) { + bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes); + } + } + /// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with /// an open group waits for the group to end, because the group remembers where its steps start. fn trim_history(&mut self) { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 1f83bd67d..3216cfd97 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -268,6 +268,19 @@ fn register_group_commands() { put(s, "E1", "after")?; Ok(json!({})) }); + // Many nested edits: the steps they make are bounded while the command runs. (Not undoable + // itself: like a script, its nested steps are the undo steps.) + crate::cmd::register_test_command("test.manyNestedEdits", |s, _| { + let start = s.doc()?.undo.len(); + for i in 0..crate::UNDO_LIMIT + 50 { + put(s, "A1", &i.to_string())?; + } + let len = s.doc()?.undo.len(); + if len > start + crate::UNDO_LIMIT { + return Err(EngineError::Other(format!("{} steps kept inside the command", len - start))); + } + Ok(json!({})) + }); // An edit, then a group the command leaves open (a debugger's start). register("test.editThenOpenGroup", |s, _| { put(s, "A1", "first")?; @@ -390,6 +403,25 @@ fn a_group_a_command_leaves_open_starts_where_the_command_did() { assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("first".into(), "second".into())); } +#[test] +fn steps_made_inside_a_command_are_bounded_while_it_runs() { + // The history limit applies when the outermost command finishes, so a command's bookkeeping + // holds; but a command that runs thousands of nested commands (a script, a subtotal) must not + // keep thousands of steps alive until then. Each nested step bounds the steps made since the + // command began, and leaves the history from before it alone. + register_group_commands(); + let mut s = s(); + let before = fill_history(&mut s); + s.execute("test.manyNestedEdits", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.undo.len(), crate::UNDO_LIMIT); + assert!(d.undo.iter().all(|e| e.label == "Enter Cell")); + assert!(!d.undo.iter().any(|e| before.iter().any(|w| std::sync::Arc::ptr_eq(w, &e.wb))), "the older history gave way to the newest steps"); + assert_eq!(v(&s, "A1").display(), "149"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "148"); +} + #[test] fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() { // The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback From 080b6d993b1682010a613ca41389dc953a22e6b1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:36:34 -0400 Subject: [PATCH 059/166] Engine: a grouped action that closes its document succeeds Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error. --- crates/engine/src/lib.rs | 12 ++++++++---- crates/engine/src/tests/seams.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6e37058dd..ad740e3f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -652,11 +652,15 @@ impl Session { /// is rolled back and its error is returned. Groups nest; the outermost one makes the step. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let token = self.group_begin(label)?; + let doc = token.doc(); match f(self) { - Ok(r) => { - self.group_end(token)?; - Ok(r) - } + Ok(r) => match self.group_end(token) { + Ok(_) => Ok(r), + // `f` closed the group's document (`file.close`, or `file.open` taking the place of + // an untouched new workbook), and the group went with it: nothing is left to undo. + Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r), + Err(e) => Err(e), + }, Err(e) => { // The error from `f` is the one to report. A rollback fails only when the group // has already ended, and then there is nothing left to undo. diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 3216cfd97..a5cd0d868 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -170,6 +170,27 @@ fn a_long_group_keeps_the_history_before_it() { assert_eq!(d.undo.iter().filter(|e| e.label == "Enter Cell").count(), 99); } +#[test] +fn a_grouped_action_that_closes_its_document_succeeds() { + // The group goes with the document; the action did what it was asked. + let mut s = s(); + let uid = s.doc().unwrap().uid; + s.grouped("Close", |s| s.execute("file.close", json!({}))).unwrap(); + assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid)); + // Opening a file into an untouched new workbook takes its place the same way. + s.new_workbook(); + let uid = s.doc().unwrap().uid; + let bytes = serde_json::to_vec(&gridcraft_model::Workbook::new()).unwrap(); + s.grouped("Open", |s| s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)}))).unwrap(); + assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid) && s.groups.is_empty()); + // A group ended by someone else while its document is still open is still an error. + let err = s.grouped("Ended", |s| { + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }); + s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?).map(|_| ()) + }); + assert!(err.unwrap_err().to_string().contains("already ended")); +} + #[test] fn closing_a_document_drops_its_groups() { let mut s = s(); From 7e383f460a069e2eb44140d9026d46351815e0d7 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:36:52 -0400 Subject: [PATCH 060/166] Docs: undo groups at a command's boundaries, and the bound on steps made inside a command --- docs/architecture.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index f45774508..e44e80083 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,10 @@ Actions made of many commands (macros, a debugger that runs one step per call) u While a group is open on a document, its commands add no undo steps of their own and Undo and Redo are disabled there. Groups nest; the outermost one makes the step. A command that opens and closes its own groups still makes one undo step for itself, covering its edits inside and outside - the groups. + the groups. A command that ends a group open before it leaves the group its step and makes one + of its own for what it changes after the end; a group a command opens and leaves open starts + where the command did. The history limit is applied when the outermost command finishes; until + then the steps made inside it are bounded on their own. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, and the buffer is independent of the journal From 66e69cb9466095f8b87380c5bcc47049c26d3f0f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:01:21 -0400 Subject: [PATCH 061/166] Engine: a linked paste carries the file's trust whatever it pastes Paste Link writes a formula into every cell whatever `what` says, so a linked paste of formats from a file now makes the destination FromFile as a paste of formulas does. Column widths and notes write no cells, linked or not, and still carry nothing. Regression test for the three kinds. --- crates/engine/src/cmd/edit.rs | 5 +++-- crates/engine/src/tests/seams.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5e30f14f5..f3ac6270a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1262,9 +1262,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { })?; // Formulas and validation from an untrusted file leave the destination untrusted; plain values, // formats (cell styles and merged areas, no conditional formats), column widths and notes - // carry nothing active (a linked paste of values still writes formulas). + // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, + // except for column widths and notes, which write no cells at all. let passive = - matches!(what, What::Formats | What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Values | What::ValuesAndNumberFormats)); + matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); if !same_doc && !passive { let d = s.doc_mut()?; d.trust = d.trust.receive(clip.trust); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index a5cd0d868..d3e9f78db 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -731,3 +731,25 @@ fn pasting_from_a_file_carries_its_trust() { assert_eq!(Trust::FromFile.receive(Trust::Local), Trust::FromFile); assert_eq!(Trust::Local.receive(Trust::Local), Trust::Local); } + +#[test] +fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() { + // Paste Link writes a formula into every cell whatever `what` says, so a linked paste of + // formats from a file carries the file's trust like one of formulas. Column widths and notes + // write no cells, linked or not. + let mut s = s(); + open_json(&mut s, &gridcraft_model::Workbook::new()); + set(&mut s, "A1", "=1+1"); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + let b1 = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).cloned(); + for what in ["columnWidths", "comments"] { + s.new_workbook(); + s.execute("edit.pasteSpecial", json!({"what": what, "link": true, "at": "B1"})).unwrap(); + assert_eq!(trust(&s), Trust::Local, "{what}"); + assert!(b1(&s).is_none(), "a linked paste of {what} writes no cell"); + } + s.new_workbook(); + s.execute("edit.pasteSpecial", json!({"what": "formats", "link": true, "at": "B1"})).unwrap(); + assert!(b1(&s).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula"); + assert_eq!(trust(&s), Trust::FromFile); +} From 6b7c48f1191e6de61e82ca6c4cf6517e3de7f977 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:01:21 -0400 Subject: [PATCH 062/166] Engine: a stash chunk number with leading zeros names someone else's name `_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first chunk and a visible one stopped `_gc_watch_0` from being written. Only the chunk number as `set` writes it counts now. Regression test. --- crates/engine/src/cmd/stash.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 683c42c2d..3c401a4dd 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -130,8 +130,10 @@ fn item_of(n: &DefinedName) -> Option<(&str, Option)> { fn parse_name(name: &str) -> Option<(&str, Option)> { if let Some(rest) = strip_prefix_ci(name, PREFIX) { let (key, k) = rest.rsplit_once('_')?; - let all_digits = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()); - if key.is_empty() || !all_digits { + // Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not + // chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`. + let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0')); + if key.is_empty() || !canonical { return None; } return k.parse().ok().map(|i| (key, Some(i))); @@ -358,6 +360,23 @@ mod tests { assert_eq!(wb.names, vec![own]); } + #[test] + fn a_chunk_number_with_leading_zeros_is_another_name() { + // `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a + // visible one is not in the way of writing `_gc_watch_0`. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_watch_00", "\"theirs\"")); + assert_eq!(get(&wb, "watch"), None); + assert!(keys(&wb, "").is_empty()); + let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") }; + wb.names.push(own.clone()); + set(&mut wb, "watch", "ours").unwrap(); + assert_eq!(get(&wb, "watch").as_deref(), Some("ours")); + assert_eq!(wb.names.len(), 3, "both other names stay"); + remove(&mut wb, "watch"); + assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]); + } + #[test] fn keys_list_items_by_prefix() { let mut wb = Workbook::new(); From a3b5f8e45c0fc82f21ea56dfdb8909dd7143ba47 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:08:40 -0400 Subject: [PATCH 063/166] Engine: a command that panics leaves the undo history as it found it The panic guard restored the workbook and selection but not the history, so a nested edit made before the panic left a no-op undo step and the redo history it had cleared stayed lost. The guard now truncates the steps made during the command and puts the redo history back. Regression test, and the architecture note says what the guard restores. --- crates/engine/src/lib.rs | 12 +++++++++++- crates/engine/src/tests/seams.rs | 22 ++++++++++++++++++++++ docs/architecture.md | 3 ++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ad740e3f6..9f79dd37f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -532,6 +532,9 @@ impl Session { let (first_group, group_epoch) = (self.next_group, self.group_epoch); // Whether a group was open on the document before the command, and how long its history was. let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); + // A command that panics leaves the history as it found it: the steps commands nested in it + // made go with the workbook, and the redo history they cleared comes back. + let redo_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| d.redo.clone())); // The recorder keeps top-level calls with the place they ran in. let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() { Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), @@ -551,13 +554,20 @@ impl Session { Err(p) => { let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); - // Restore the workbook as it was, and drop the undo groups the command opened. + // Restore the workbook and its history as they were, and drop the undo groups the + // command opened. self.groups.retain(|g| g.id < first_group); if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) { d.wb = wb.clone(); d.selection = sel.clone(); + if let Some((_, len)) = start { + d.undo.truncate(len); + } + if let Some(redo) = redo_before { + d.redo = redo; + } d.calc.rebuild(&d.wb); } if self.depth == 0 { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index d3e9f78db..dedd5c67b 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -543,6 +543,28 @@ fn a_group_ended_at_the_history_limit_replaces_only_the_oldest_step() { assert_undo_redo(&mut s, &[("F1", "one"), ("F2", "two")]); } +#[test] +fn a_command_that_panics_leaves_the_history_as_it_found_it() { + // A command that edits through a nested command and then panics: the nested step is gone + // with the workbook it changed, and the redo history the nested step cleared comes back. + crate::cmd::register_undoable_test_command("test.editThenPanic", |s, _| { + put(s, "A1", "half done")?; + panic!("after an edit"); + }); + let mut s = s(); + set(&mut s, "A1", "1"); + set(&mut s, "A1", "2"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (1, 1)); + let err = s.execute("test.editThenPanic", json!({})).unwrap_err().to_string(); + assert!(err.contains("after an edit"), "{err}"); + assert_eq!(v(&s, "A1").display(), "1", "the workbook is restored"); + assert_eq!(undo_labels(&s), ["Enter Cell"], "no step for the nested edit"); + assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history is back"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "2"); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { diff --git a/docs/architecture.md b/docs/architecture.md index e44e80083..aa5e1e64d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,8 @@ including tests, 2026-10-10. `Session::execute(id, params)`. Commands are `CommandSpec { id, label, menu, shortcut, params, enabled, run, journal, undoable }`; ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). - `Session::execute` catches escaped panics and restores the workbook (never-crash guard). + `Session::execute` catches escaped panics and restores the workbook, its selection and its + undo and redo history as they were before the command (never-crash guard). 2. **Edits** change the model through copy-on-write bands; undo keeps the previous snapshot. Undo retains at most 100 steps and trims older steps when its estimated retained cell storage exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget. From 456cf73886f361a25f5ff30503fe833ff75a9b3a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:08:40 -0400 Subject: [PATCH 064/166] Engine: a headless call that fails for want of a file is not recorded file.open with no file succeeds by queuing a dialog, which execute_headless turns into an error for callers with no UI; the recording kept the step. Recorder::truncate forgets it and keeps the byte count right. Regression test. --- crates/engine/src/lib.rs | 7 +++++++ crates/engine/src/record.rs | 9 +++++++++ crates/engine/src/tests/seams.rs | 13 +++++++++++++ 3 files changed, 29 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9f79dd37f..bf1100082 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -826,10 +826,17 @@ impl Session { /// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no /// file yet, `data.getData`) is an error instead of a silent success. pub fn execute_headless(&mut self, id: &str, params: Json) -> Result { + let recorded = self.recorder.as_ref().map(|r| (r.steps.len(), r.dropped)); let r = self.execute(id, params); let requests = self.take_ui_requests(); let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); if r.is_ok() && asked_for_file { + // The caller gets an error, so the recording keeps no step for the command. + if let Some((steps, dropped)) = recorded + && let Some(rec) = self.recorder.as_mut() + { + rec.truncate(steps, dropped); + } let what = match id { "file.open" => "`path` or `base64`", "data.getData" | "data.fromTextCsv" => "`path` or `text`", diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index fefd07234..5b8100ed4 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -58,4 +58,13 @@ impl Recorder { self.dropped = self.dropped.saturating_add(1); } } + + /// Forgets the steps after the first `steps` and puts the dropped count back to `dropped`, + /// for a command the caller received as failed after it was recorded. + pub fn truncate(&mut self, steps: usize, dropped: usize) { + for step in self.steps.drain(steps.min(self.steps.len())..) { + self.bytes = self.bytes.saturating_sub(serde_json::to_vec(&step).map_or(0, |v| v.len())); + } + self.dropped = dropped; + } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index dedd5c67b..8a87279d3 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -571,6 +571,19 @@ fn recorded_ids(s: &Session) -> Vec { s.recorder.as_ref().unwrap().steps.iter().map(|st| st.id.clone()).collect() } +#[test] +fn a_headless_call_that_fails_for_want_of_a_file_is_not_recorded() { + // `file.open` with no file queues a dialog and succeeds; a headless caller gets an error for + // it instead, so the recording keeps no step for it either. + let mut s = s(); + s.start_recording(); + set(&mut s, "A1", "1"); + assert!(s.execute_headless("file.open", json!({})).is_err()); + assert_eq!(recorded_ids(&s), ["cell.set"]); + let rec = s.stop_recording().unwrap(); + assert_eq!(rec.dropped, 0); +} + #[test] fn the_recorder_keeps_top_level_calls_with_where_they_ran() { let mut s = s(); From d1e51a4af4d991522f221b1a8dac5ebc156db5dc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:08:40 -0400 Subject: [PATCH 065/166] Engine: a later stash chunk on its own is not a listed item stash::keys reported a key that get read as missing, because only a later chunk of it existed. keys now counts chunk 0 and legacy names only, as get does. Regression assertions in the hostile-names test. --- crates/engine/src/cmd/stash.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 3c401a4dd..bbef7f3ec 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -103,7 +103,8 @@ pub fn keys(wb: &Workbook, prefix: &str) -> Vec { let mut keys: Vec = wb .names .iter() - .filter_map(|n| item_of(n).map(|(k, _)| k.to_ascii_lowercase())) + // A later chunk on its own is no item, as `get` reads it. + .filter_map(|n| item_of(n).filter(|(_, chunk)| chunk.is_none_or(|i| i == 0)).map(|(k, _)| k.to_ascii_lowercase())) .filter(|k| k.starts_with(&prefix.to_ascii_lowercase())) .collect(); keys.sort(); @@ -294,11 +295,13 @@ mod tests { wb.names.push(name("_gc_gap_0", "\"a\"")); wb.names.push(name("_gc_gap_2", "\"c\"")); assert_eq!(get(&wb, "gap").as_deref(), Some("a")); - // Only a later chunk: no item, and the legacy name is still read. + // Only a later chunk: no item, not listed, and the legacy name is still read. wb.names.push(name("_gc_orphan_2", "\"c\"")); assert_eq!(get(&wb, "orphan"), None); + assert_eq!(keys(&wb, "orphan"), Vec::::new()); wb.names.push(name("_sc_orphan", "\"old\"")); assert_eq!(get(&wb, "orphan").as_deref(), Some("old")); + assert_eq!(keys(&wb, "orphan"), ["orphan"]); // Chunks out of order, a duplicate, junk around the literals, an unterminated literal. wb.names.push(name("_gc_mix_1", "\"b\"&A1&")); wb.names.push(name("_gc_mix_0", "SUM(\"a\")")); From 8440427c530cb4bc0b2e1ec43e3a4b5ceb03ffad Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:15:05 -0400 Subject: [PATCH 066/166] Tests: a linked paste of column widths or notes copies its metadata and no cell The source now carries a note and a column width, and the test checks that the destination received them while its trust stays Local and no cell is written. --- crates/engine/src/tests/seams.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 8a87279d3..cb0902cdf 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -775,16 +775,26 @@ fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() { let mut s = s(); open_json(&mut s, &gridcraft_model::Workbook::new()); set(&mut s, "A1", "=1+1"); + s.execute("review.newComment", json!({"cell": "A1", "text": "theirs"})).unwrap(); + let wide = gridcraft_model::LineInfo { size: Some(123.0), ..Default::default() }; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(0, wide); s.execute("edit.copy", json!({"range": "A1"})).unwrap(); - let b1 = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).cloned(); + let b1 = gridcraft_core::CellRef::parse("B1").unwrap(); + let sheet = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().clone(); for what in ["columnWidths", "comments"] { s.new_workbook(); s.execute("edit.pasteSpecial", json!({"what": what, "link": true, "at": "B1"})).unwrap(); assert_eq!(trust(&s), Trust::Local, "{what}"); - assert!(b1(&s).is_none(), "a linked paste of {what} writes no cell"); + let sh = sheet(&s); + assert!(sh.cell(b1).is_none(), "a linked paste of {what} writes no cell"); + if what == "comments" { + assert_eq!(sh.comments.get(&b1).map(|c| c.text.as_str()), Some("theirs")); + } else { + assert_eq!(sh.cols.get(&1).and_then(|c| c.size), Some(123.0)); + } } s.new_workbook(); s.execute("edit.pasteSpecial", json!({"what": "formats", "link": true, "at": "B1"})).unwrap(); - assert!(b1(&s).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula"); + assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula"); assert_eq!(trust(&s), Trust::FromFile); } From d7c3c18345f5e172340906c3bb0529db17dc31a3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:47:40 -0400 Subject: [PATCH 067/166] Engine: the panic guard restores entries a nested Undo removed Truncating the undo history to its length at the command's start cannot bring back an entry that a nested edit.undo popped. The guard now keeps the undo and redo history as they were and puts both back. The regression test gains the undo-then-panic case. --- crates/engine/src/lib.rs | 11 +++++------ crates/engine/src/tests/seams.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf1100082..aaa8db4f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -533,8 +533,9 @@ impl Session { // Whether a group was open on the document before the command, and how long its history was. let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); // A command that panics leaves the history as it found it: the steps commands nested in it - // made go with the workbook, and the redo history they cleared comes back. - let redo_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| d.redo.clone())); + // made or undid, and the redo history they cleared, come back. A length is not enough, + // because a nested `edit.undo` removes entries. + let history_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))); // The recorder keeps top-level calls with the place they ran in. let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() { Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), @@ -562,10 +563,8 @@ impl Session { { d.wb = wb.clone(); d.selection = sel.clone(); - if let Some((_, len)) = start { - d.undo.truncate(len); - } - if let Some(redo) = redo_before { + if let Some((undo, redo)) = history_before { + d.undo = undo; d.redo = redo; } d.calc.rebuild(&d.wb); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index cb0902cdf..9c9aeb6ad 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -563,6 +563,20 @@ fn a_command_that_panics_leaves_the_history_as_it_found_it() { assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history is back"); s.execute("edit.redo", json!({})).unwrap(); assert_eq!(v(&s, "A1").display(), "2"); + + // A command that undoes through a nested command and then panics: the entry Undo removed + // comes back, and the redo entry it made goes. + crate::cmd::register_undoable_test_command("test.undoThenPanic", |s, _| { + s.execute("edit.undo", json!({}))?; + panic!("after an undo"); + }); + assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (2, 0)); + assert!(s.execute("test.undoThenPanic", json!({})).is_err()); + assert_eq!(v(&s, "A1").display(), "2", "the workbook is restored"); + assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell"], "the undone step is back"); + assert_eq!(s.doc().unwrap().redo.len(), 0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "1"); } // ---------------------------------------------------------------- recording buffer From 378ce3a5a08c69b22d0897afc918c06b9077dd31 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:28:05 -0400 Subject: [PATCH 068/166] Docs: the macro page describes this version The inputs paragraph described how a run binds inputs and the message it shows, which the runner adds in a later pull request. It now says what the declaration is for and that this version stores and checks it. --- docs/macros.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index 1c755e329..65160fad4 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-21 · **Change:** major (created: the macro format and storage) +> **Last updated:** 2026-10-24 · **Change:** major (created: the macro format and storage) 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 @@ -38,9 +38,8 @@ version. `{"command": "cell.set", "params": {…}}` is accepted for a command step. - **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum; rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`), - `nonEmpty` and `defaultRef`. 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." + `nonEmpty` and `defaultRef`. These declare what a range must look like for the macro to work + on it. This version stores and checks the declaration; it does not bind inputs or run macros. - **Limits**: 1,000 macros a workbook, 10,000 steps a macro, and Repeat and If nested 4 deep. ## Storage From 3ca12525715a7feee8e265152ac3cb4def8e6995 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:36:09 -0400 Subject: [PATCH 069/166] Engine: a group a panicking command ended keeps its step A group open before a command and ended or rolled back inside it lost its undo step when the command then panicked: the restored history held no entry for the group's changes, so Undo skipped the state before the group. The panic path now pushes the outermost such group's step from its start to the restored workbook. The group stays ended, because its token went with the command. Regression test for a group ended and one rolled back. --- crates/engine/src/lib.rs | 18 +++++++++++++++ crates/engine/src/tests/seams.rs | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aaa8db4f6..09e39523b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -536,6 +536,13 @@ impl Session { // made or undid, and the redo history they cleared, come back. A length is not enough, // because a nested `edit.undo` removes entries. let history_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))); + // The groups open on the document before the command (usually none), for the same reason. + let groups_before: Vec<(u64, String, Arc, Selection)> = match &before { + Some((uid, ..)) => { + self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect() + } + None => Vec::new(), + }; // The recorder keeps top-level calls with the place they ran in. let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() { Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), @@ -558,6 +565,7 @@ impl Session { // Restore the workbook and its history as they were, and drop the undo groups the // command opened. self.groups.retain(|g| g.id < first_group); + let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid)); if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) { @@ -567,6 +575,16 @@ impl Session { d.undo = undo; d.redo = redo; } + // A group open before the command and ended or rolled back inside it stays + // ended (its token went with the command), and keeps a step from its start to + // the restored workbook, so Undo still returns to the state before the group. + if !group_open + && let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0) + && !Arc::ptr_eq(&wb, &d.wb) + { + push_entry(&mut d.undo, label, wb, &d.wb, selection); + d.redo.clear(); + } d.calc.rebuild(&d.wb); } if self.depth == 0 { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 9c9aeb6ad..48105da25 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -579,6 +579,45 @@ fn a_command_that_panics_leaves_the_history_as_it_found_it() { assert_eq!(v(&s, "A1").display(), "1"); } +#[test] +fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() { + // The command takes a token the test left for it, ends or rolls back that group, and panics. + // The group's token went with the command, so the group stays ended; its step runs from the + // group's start to the restored workbook, so Undo still returns to the state before the group. + static TOKEN: std::sync::Mutex> = std::sync::Mutex::new(None); + crate::cmd::register_undoable_test_command("test.endGroupThenPanic", |s, _| { + let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take(); + if let Some(token) = token { + s.group_end(token)?; + } + panic!("after ending a group"); + }); + crate::cmd::register_undoable_test_command("test.rollBackGroupThenPanic", |s, _| { + let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take(); + if let Some(token) = token { + s.group_rollback(token)?; + } + panic!("after rolling back a group"); + }); + for cmd in ["test.endGroupThenPanic", "test.rollBackGroupThenPanic"] { + let mut s = s(); + set(&mut s, "A1", "before"); + let token = s.group_begin("Outer").unwrap(); + set(&mut s, "A1", "inside"); + set(&mut s, "A2", "inside too"); + *TOKEN.lock().unwrap() = Some(token); + assert!(s.execute(cmd, json!({})).is_err()); + let uid = s.doc().unwrap().uid; + assert!(!s.in_group(uid), "{cmd}: the group stays ended"); + assert_eq!(undo_labels(&s), ["Enter Cell", "Outer"], "{cmd}"); + assert_eq!(v(&s, "A1").display(), "inside", "{cmd}: the workbook is as the command found it"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("before".into(), String::new()), "{cmd}"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A2").display(), "inside too", "{cmd}"); + } +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { From 7ee9d77483c124e3ee4bcbf6135b7ca73762169f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:36:09 -0400 Subject: [PATCH 070/166] Docs: list every paste that carries a file's trust The architecture note named formulas and validation only; it now lists the Paste Special kinds and linked pastes that write formulas, and the kinds that carry nothing. --- docs/architecture.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index aa5e1e64d..f51f3a61b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,8 +66,11 @@ Actions made of many commands (macros, a debugger that runs one step per call) u Commands run by other commands are not recorded, and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and - `FromFile` for workbooks opened from a file, and for workbooks that received formulas or - validation pasted from one. Active content in a `FromFile` workbook asks before it runs. This + `FromFile` for workbooks opened from a file, and for workbooks that received a paste from one + that writes formulas or validation: Paste, Paste Special › All, All Except Borders, Formulas, + Formulas and Number Formats or Validation, and any linked paste other than column widths or + notes. Values, formats, column widths and notes carry nothing active. Active content in a + `FromFile` workbook asks before it runs. This is provenance, which no decision changes; `DocState::scripts_trusted` is the separate per-session consent for stored `automate.*` scripts (`automate.trustScripts`). - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden From 259b6e67e88324780815a75912dc4159be5b472a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:54 -0400 Subject: [PATCH 071/166] Model: Repeat and If nest four deep, as the limit says The depth check refused any step list at the fifth level, so a fourth Repeat or If could not even hold an empty body: three levels nested, not the four MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If steps themselves, in the model's limits and in the parser's pass over the JSON. The test asks for four and refuses a fifth. --- crates/engine/src/macros/mod.rs | 8 +++++--- crates/engine/src/macros/tests.rs | 19 ++++++++++++++----- crates/model/src/macros.rs | 7 ++++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 38016049d..2aa45e12c 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -53,9 +53,6 @@ fn check_size(m: &Macro) -> Result<(), String> { /// before the steps are deserialized. fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> { let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) }; - if level > gridcraft_model::MAX_MACRO_DEPTH { - return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH)); - } for st in list { let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) }; if !o.contains_key("type") @@ -64,6 +61,11 @@ fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> { o.insert("type".into(), json!("command")); o.insert("id".into(), id); } + // A step with a body is a Repeat or an If: one at the top is nested 1 deep. + let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key)); + if nested && level > gridcraft_model::MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH)); + } for key in ["body", "then", "else"] { if let Some(body) = o.get_mut(key) { normalize_steps(body, level + 1)?; diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 7b498f0b8..5fbc6d38c 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -55,16 +55,25 @@ fn macros_over_the_limits_are_refused() { let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]}); assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok()); assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps")); - // Nested Repeat steps: 4 levels are fine, 5 are not. - let nest = |levels: usize| { + // Nested Repeat steps: 4 Repeats one inside the other are fine (the innermost holds a + // command, or nothing), a 5th is not. + let nest = |repeats: usize| { let mut step = json!({"command": "cell.set"}); - for _ in 1..levels { + for _ in 0..repeats { step = json!({"type": "repeat", "over": "Input", "body": [step]}); } json!({"name": "Deep", "steps": [step]}) }; - assert!(parse_macro(&nest(4)).is_ok()); - assert!(parse_macro(&nest(5)).unwrap_err().contains("4 deep")); + assert!(parse_macro(&nest(MAX_MACRO_DEPTH)).is_ok()); + assert_eq!(parse_macro(&nest(MAX_MACRO_DEPTH)).unwrap().step_count(), MAX_MACRO_DEPTH + 1); + assert!(parse_macro(&nest(MAX_MACRO_DEPTH + 1)).unwrap_err().contains("4 deep")); + let mut empty = json!({"type": "repeat", "over": "Input", "body": []}); + for _ in 1..MAX_MACRO_DEPTH { + empty = json!({"type": "if", "cell": "A1", "op": "eq", "value": 1, "then": [empty], "else": []}); + } + let deep = parse_macro(&json!({"name": "Deep", "steps": [empty]})).unwrap(); + assert_eq!(deep.step_count(), MAX_MACRO_DEPTH); + assert!(deep.check_limits().is_ok(), "the model's check agrees with the parser"); // Far deeper than serde would allow is refused before it is read. assert!(parse_macro(&nest(200)).is_err()); for bad in [ diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 8dc657f90..03cb03cac 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -81,10 +81,8 @@ impl Macro { return Err(format!("A macro description can be at most {MAX_MACRO_TEXT} characters long.")); } // Count without recursing past the depth limit (a hostile file can nest deeper). + // A Repeat or If at the top is nested 1 deep; one inside it, 2 deep. fn walk(steps: &[Step], level: usize, count: &mut usize) -> Result<(), String> { - if level > MAX_MACRO_DEPTH { - return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); - } for s in steps { *count = count.saturating_add(1); if *count > MAX_MACRO_STEPS { @@ -93,6 +91,9 @@ impl Macro { if s.note.chars().count() > MAX_MACRO_TEXT { return Err(format!("A step note can be at most {MAX_MACRO_TEXT} characters long.")); } + if s.kind.children().next().is_some() && level > MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); + } for body in s.kind.children() { walk(body, level + 1, count)?; } From 8219c18b921d573192f01863a602a373f512ea35 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:54 -0400 Subject: [PATCH 072/166] Engine: macro storage shares the list, saves in one pass, keeps unknown names, checks once - Workbook::macros is a shared list (Macros, a copy-on-write newtype): a workbook copy made for a cell edit or an undo snapshot holds the same list, and the undo budget charges a list once a snapshot owns its own copy. Before, every mutating command copied every step of every macro, and a hundred undo entries kept a hundred copies the budget never saw. - Saving to xlsx drops the last save's items and writes the macros in one pass each (stash::set_many), instead of a scan of every name per macro. - A hidden _gc_macro__ name with a key this build did not write is kept on open and on save, as a visible one is, instead of being dropped without a warning. - macros::check is the one check for every path that adds a macro (JSON, a file, built in code): the model's limits and the xlsx size limit. Regression tests for each. --- crates/engine/src/cmd/stash.rs | 46 ++++++++++++-------- crates/engine/src/lib.rs | 4 ++ crates/engine/src/macros/mod.rs | 40 +++++++++++------- crates/engine/src/macros/tests.rs | 70 +++++++++++++++++++++++++++++++ crates/model/src/macros.rs | 69 ++++++++++++++++++++++++++++++ crates/model/src/workbook.rs | 6 +-- 6 files changed, 200 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index bbef7f3ec..e36635aff 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -8,7 +8,7 @@ //! one name each, `_sc_`; reading accepts that form as well, so those items can move to this //! format later, and a write of the same key replaces it. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use gridcraft_model::{DefinedName, Workbook}; use serde_json::Value as Json; @@ -59,28 +59,40 @@ pub fn get(wb: &Workbook, key: &str) -> Option { /// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it. pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { - check_key(key)?; - if text.chars().count() > MAX_ITEM_CHARS { - return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024)))); + set_many(wb, &[(key.to_string(), text.to_string())]) +} + +/// Stores every `(key, text)` of `items` as [`set`] would, with the checks done before anything +/// changes and one pass over the names for all of them, so writing a thousand items into a +/// workbook with many thousands of names stays near-linear. +pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> { + let mut planned = Vec::new(); + let mut keys: HashSet = HashSet::new(); + for (key, text) in items { + check_key(key)?; + if text.chars().count() > MAX_ITEM_CHARS { + return Err(EngineError::Other(format!( + "The data is too large to keep in the workbook ({} MB at most).", + MAX_ITEM_CHARS / (1024 * 1024) + ))); + } + keys.insert(key.to_ascii_lowercase()); + for (i, formula) in chunk_formulas(text).into_iter().enumerate() { + planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); + } } - let formulas = chunk_formulas(text); - // A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside - // it with the same name would be the one an xlsx file drops. Refuse before changing anything, - // in one pass over the names (a file can carry many thousands). - let chunks = formulas.len() as u64; - let taken = |n: &&DefinedName| { - n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks)) - }; - if let Some(n) = wb.names.iter().find(taken) { + // A visible name of an item's shape is someone's own (see `item_of`); a hidden name beside + // it with the same name would be the one an xlsx file drops. Refuse before changing anything. + let names: HashSet = planned.iter().map(|n| n.name.to_ascii_lowercase()).collect(); + if let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && !n.hidden && names.contains(&n.name.to_ascii_lowercase())) { return Err(EngineError::Other(format!( "The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.", n.name ))); } - remove(wb, key); - for (i, formula) in formulas.into_iter().enumerate() { - wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); - } + // What was there under these keys goes, in either format, then the new chunks come. + wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| keys.contains(&k.to_ascii_lowercase()))); + wb.names.extend(planned); Ok(()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 886e845ce..4dad22d02 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -224,6 +224,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize { let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells); bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next)); } + // Snapshots share the macro list until a change to it; a snapshot with its own list keeps it. + if !older.macros.shares(&newer.macros) { + bytes = bytes.saturating_add(older.macros.estimated_bytes()); + } bytes } diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 2aa45e12c..5a779758d 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -35,11 +35,18 @@ pub fn parse_macro(v: &Json) -> Result { } let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?; m.name = m.name.trim().to_string(); - m.check_limits()?; - check_size(&m)?; + check(&m)?; Ok(m) } +/// Every check a macro must pass before it is kept in a workbook: the model's limits +/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro, +/// from JSON, from a file or built in code, goes through this one check. +pub fn check(m: &Macro) -> Result<(), String> { + m.check_limits()?; + check_size(m) +} + /// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of /// JSON), so saving a workbook never fails because of one of its macros. fn check_size(m: &Macro) -> Result<(), String> { @@ -89,17 +96,18 @@ pub fn find(wb: &Workbook, name: &str) -> Option { /// 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. pub fn with_names(wb: &Workbook) -> crate::Result> { - let stale = !stash::keys(wb, MACRO_KEY).is_empty(); - if wb.macros.is_empty() && !stale { + // Only the numbered items are this build's: a hidden `_gc_macro__` name with some + // other key stays, as `load` leaves it. + let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some()); + if wb.macros.is_empty() && !wb.names.iter().any(stored) { return Ok(None); } let mut out = wb.clone(); - for key in stash::keys(&out, MACRO_KEY) { - stash::remove(&mut out, &key); - } - for (i, m) in wb.macros.iter().enumerate() { - stash::set(&mut out, &format!("{MACRO_KEY}{i}"), ¯o_json(m).to_string())?; - } + // One pass drops the items of the last save, and one writes the macros (a file can hold a + // thousand macros and many thousands of names). + out.names.retain(|n| !stored(n)); + let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect(); + stash::set_many(&mut out, &items)?; out.macros.clear(); Ok(Some(out)) } @@ -125,8 +133,9 @@ pub fn load(wb: &mut Workbook) -> Vec { match macro_item(&n) { None => kept.push(n), Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n), - // A `macro_` item that isn't numbered isn't one this build wrote; it goes unread. - Some((_, None)) => {} + // A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is, + // unread, and a save keeps it. + Some((_, None)) => kept.push(n), } } wb.names = kept; @@ -154,7 +163,7 @@ pub fn load(wb: &mut Workbook) -> Vec { let checked = m.check_limits().and_then(|()| { m.name = taken.unique(m.name.trim()); // The macro as it is kept, renamed, must still pass, or the next save would lose it. - m.check_limits().and_then(|()| check_size(&m)) + check(&m) }); match checked { Ok(()) => { @@ -186,8 +195,9 @@ fn macro_item(n: &DefinedName) -> Option<(String, Option)> { Some((key, i)) } -/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A long name is shortened to make -/// room for the suffix, so the result stays within [`gridcraft_model::MAX_MACRO_NAME`] characters. +/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A free name comes back as it is; +/// a renamed one is shortened to make room for the suffix, so it stays within +/// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`]. pub fn unique_name(wb: &Workbook, name: &str) -> String { let mut taken = Taken::default(); for m in &wb.macros { diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 5fbc6d38c..77e9fb106 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -149,6 +149,76 @@ fn a_visible_name_that_looks_like_macro_storage_is_left_alone() { assert_eq!(wb.names, vec![name]); } +#[test] +fn a_hidden_macro_name_this_build_did_not_write_is_kept() { + // `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a + // save with macros keeps it beside the items written. + let mut wb = Workbook::new(); + let theirs = hidden("_gc_macro_index_0", "{}"); + wb.names.push(theirs.clone()); + wb.macros.push(sample_macro("Totals")); + let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + let names = names_of(&raw); + assert!(names.contains(&"_gc_macro_index_0".to_string()) && names.contains(&"_gc_macro_0_0".to_string()), "{names:?}"); + let (back, warnings) = crate::io::open_bytes("book.xlsx", &crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.macros, wb.macros); + assert_eq!(names_of(&back), ["_gc_macro_index_0"]); +} + +#[test] +fn workbook_snapshots_share_the_macro_list_until_it_changes() { + // An undo snapshot or a copy made for a cell edit holds the same list, not a copy of every + // step; the undo budget charges a list only once it is a snapshot's own. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("Totals")); + let snapshot = wb.clone(); + assert!(wb.macros.shares(&snapshot.macros)); + assert_eq!(crate::history_retained_bytes(&snapshot, &wb), std::mem::size_of::()); + wb.macros.push(sample_macro("More")); + assert!(!wb.macros.shares(&snapshot.macros), "a write copies the list first"); + assert_eq!(snapshot.macros.len(), 1); + assert!(crate::history_retained_bytes(&snapshot, &wb) > std::mem::size_of::()); + // The copy is per list, not per workbook copy: cloning again shares the new list. + let again = wb.clone(); + assert!(wb.macros.shares(&again.macros)); + let rebuilt: Macros = wb.macros.iter().cloned().collect(); + assert_eq!(rebuilt, wb.macros); + assert_eq!(rebuilt.into_iter().count(), 2); +} + +#[test] +fn a_macro_built_in_code_goes_through_the_size_check_too() { + // `check` is the one check for every path, so a macro that passes it never makes a save fail. + let big = Macro { + name: "Big".into(), + steps: vec![Step::command("cell.set", json!({"input": "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS)}))], + ..Macro::default() + }; + assert!(big.check_limits().is_ok(), "the model's limits say nothing about the size"); + assert!(check(&big).unwrap_err().contains("too large")); + let mut wb = Workbook::new(); + wb.macros.push(big); + assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save"); +} + +#[test] +fn a_save_refuses_a_clashing_visible_name_before_changing_anything() { + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + wb.macros.push(sample_macro("B")); + let own = DefinedName { hidden: false, ..hidden("_GC_MACRO_1_0", "mine") }; + wb.names.push(own.clone()); + wb.names.push(hidden("_gc_macro_7_0", "stale")); + let err = crate::io::save_bytes(&wb, "book.xlsx").unwrap_err().to_string(); + assert!(err.contains("_GC_MACRO_1_0"), "{err}"); + assert_eq!(wb.names.len(), 2, "nothing changed"); + wb.names.remove(0); + let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + let names = names_of(&raw); + assert_eq!(names, ["_gc_macro_0_0", "_gc_macro_1_0"], "the stale item went in the same pass"); +} + #[test] fn scripts_and_vba_stay_as_they_are() { // Automate scripts keep their own storage, and a VBA project is carried through unchanged. diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 03cb03cac..92afac61a 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -121,6 +121,75 @@ impl Macro { } } +/// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so +/// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every +/// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list +/// when another snapshot shares it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Macros(std::sync::Arc>); + +impl Macros { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Whether both hold the one list, with no copy made between them. + pub fn shares(&self, other: &Macros) -> bool { + std::sync::Arc::ptr_eq(&self.0, &other.0) + } + + /// A rough size of the list in memory, for the undo budget. + pub fn estimated_bytes(&self) -> usize { + self.0.iter().fold(0usize, |bytes, m| { + let text = m.name.len().saturating_add(m.description.len()); + let steps = m.step_count().saturating_mul(std::mem::size_of::().saturating_add(64)); + bytes.saturating_add(std::mem::size_of::()).saturating_add(text).saturating_add(steps) + }) + } +} + +impl From> for Macros { + fn from(list: Vec) -> Macros { + Macros(std::sync::Arc::new(list)) + } +} + +impl std::ops::Deref for Macros { + type Target = Vec; + fn deref(&self) -> &Vec { + &self.0 + } +} + +impl std::ops::DerefMut for Macros { + fn deref_mut(&mut self) -> &mut Vec { + std::sync::Arc::make_mut(&mut self.0) + } +} + +impl IntoIterator for Macros { + type Item = Macro; + type IntoIter = std::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + std::sync::Arc::unwrap_or_clone(self.0).into_iter() + } +} + +impl<'a> IntoIterator for &'a Macros { + type Item = &'a Macro; + type IntoIter = std::slice::Iter<'a, Macro>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl FromIterator for Macros { + fn from_iter>(iter: I) -> Macros { + Macros(std::sync::Arc::new(iter.into_iter().collect())) + } +} + /// Refuses a format version newer than [`MACRO_VERSION`]: this build would drop the fields it /// doesn't know, and a save would then write the rest back as if it were the whole macro. pub fn check_version(v: u64) -> Result<(), String> { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 57acd2a8d..5a47b75ed 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -181,8 +181,8 @@ pub struct Workbook { pub csv_delimiter: Option, /// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them. /// The JSON format keeps them as this field; xlsx keeps them in hidden defined names. - #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] - pub macros: Vec, + #[serde(default, skip_serializing_if = "crate::macros::Macros::is_empty", skip_deserializing)] + pub macros: crate::macros::Macros, /// The `macros` entries of a JSON file as read, before the engine checks each one on its own /// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening. /// Never written. @@ -234,7 +234,7 @@ impl Workbook { phonetics: vec![], vba_project: None, csv_delimiter: None, - macros: vec![], + macros: crate::macros::Macros::default(), macros_read: vec![], preserved: None, source_dir: None, From 712bb766ecdf8063d28f74f94303bd5efa63ce6e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:46:25 -0400 Subject: [PATCH 073/166] Engine: a grouped action that panics is rolled back before the panic goes on Session::grouped ran its action without a panic guard, so a caller outside a command that caught the panic found the group still open: Undo and Redo disabled, the partial edits kept, and the next grouped action nested in the abandoned group. The group is now rolled back and the panic resumed. Regression test. --- crates/engine/src/lib.rs | 12 +++++++++--- crates/engine/src/tests/seams.rs | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 09e39523b..8a8d8bcfa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -680,20 +680,26 @@ impl Session { pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let token = self.group_begin(label)?; let doc = token.doc(); - match f(self) { - Ok(r) => match self.group_end(token) { + // A panic in `f` rolls the group back before it goes on, so a caller that catches it + // (outside `execute`, which drops a command's groups itself) finds no group left open. + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) { + Ok(Ok(r)) => match self.group_end(token) { Ok(_) => Ok(r), // `f` closed the group's document (`file.close`, or `file.open` taking the place of // an untouched new workbook), and the group went with it: nothing is left to undo. Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r), Err(e) => Err(e), }, - Err(e) => { + Ok(Err(e)) => { // The error from `f` is the one to report. A rollback fails only when the group // has already ended, and then there is nothing left to undo. let _ = self.group_rollback(token); Err(e) } + Err(panic) => { + let _ = self.group_rollback(token); + std::panic::resume_unwind(panic) + } } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 48105da25..0f1bc3072 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -579,6 +579,31 @@ fn a_command_that_panics_leaves_the_history_as_it_found_it() { assert_eq!(v(&s, "A1").display(), "1"); } +#[test] +fn a_grouped_action_that_panics_is_rolled_back_before_the_panic_goes_on() { + // Outside a command, a caller that catches the panic finds the group rolled back and no + // group left open, so Undo is enabled and the next grouped action does not nest. + let mut s = s(); + set(&mut s, "A1", "before"); + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + s.grouped::<()>("Doomed", |s| { + set(s, "A1", "half done"); + panic!("inside the group"); + }) + })); + assert!(caught.is_err(), "the panic goes on to the caller"); + let uid = s.doc().unwrap().uid; + assert!(!s.in_group(uid)); + assert_eq!(v(&s, "A1").display(), "before"); + assert_eq!(undo_labels(&s), ["Enter Cell"]); + s.grouped("Next", |s| { + set(s, "A1", "after"); + Ok(()) + }) + .unwrap(); + assert_eq!(undo_labels(&s), ["Enter Cell", "Next"]); +} + #[test] fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() { // The command takes a token the test left for it, ends or rolls back that group, and panics. From 4672b6da51b5cf89093e919f7fe1720c69dc4fd9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:48:53 -0400 Subject: [PATCH 074/166] Model: an input name that reads as a cell reference is refused A macro could declare an input called Q1, AB12 or Sum1, and a step written with plain A1 text, as a recorded one is, would then resolve to the input's range instead of the cell. check_limits refuses such names; the docs say so. Regression test. --- crates/engine/src/macros/tests.rs | 14 ++++++++++++++ crates/model/src/macros.rs | 4 ++++ docs/macros.md | 5 ++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 77e9fb106..99235ec68 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -149,6 +149,20 @@ fn a_visible_name_that_looks_like_macro_storage_is_left_alone() { assert_eq!(wb.names, vec![name]); } +#[test] +fn input_names_that_read_as_cell_references_are_refused() { + // A recorded step holds plain A1 text; an input called `Q1` would take its place. + let with_input = |name: &str| json!({"name": "M", "inputs": [{"name": name}]}); + for name in ["Q1", "AB12", "Sum1", "a1"] { + let err = parse_macro(&with_input(name)).unwrap_err(); + assert!(err.contains("reads as a cell reference"), "{name}: {err}"); + } + for name in ["Amount", "Q", "Sales_Q1", "Total1a"] { + assert!(parse_macro(&with_input(name)).is_ok(), "{name}"); + } + assert!(parse_macro(&with_input("Row")).unwrap_err().contains("token of its own")); +} + #[test] fn a_hidden_macro_name_this_build_did_not_write_is_kept() { // `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 92afac61a..54f975b04 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -113,6 +113,10 @@ impl Macro { if ["Output", "Row", "Column", "Name"].iter().any(|r| r.eq_ignore_ascii_case(n)) { return Err(format!("'{n}' is a token of its own and can't name an input.")); } + // A step written with plain A1 text (a recorded one) must keep meaning the cell. + if gridcraft_core::RangeRef::parse(n).is_some() { + return Err(format!("'{n}' reads as a cell reference and can't name an input.")); + } if self.inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) { return Err(format!("Two inputs are called '{n}'.")); } diff --git a/docs/macros.md b/docs/macros.md index 65160fad4..f98435bb5 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -39,7 +39,10 @@ version. - **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum; rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`), `nonEmpty` and `defaultRef`. These declare what a range must look like for the macro to work - on it. This version stores and checks the declaration; it does not bind inputs or run macros. + on it. An input's name is letters, digits and `_`, starting with a letter, and is neither a + token of its own (`Output`, `Row`, `Column`, `Name`) nor something that reads as a cell + reference (`Q1`), so a step written with plain A1 text keeps meaning the cell. This version + stores and checks the declaration; it does not bind inputs or run macros. - **Limits**: 1,000 macros a workbook, 10,000 steps a macro, and Repeat and If nested 4 deep. ## Storage From 00b63468e6f529ae32d3e9f551e877b34a33d7e6 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:02:45 -0400 Subject: [PATCH 075/166] Engine: macro tokens resolve only where they mean a reference, and refuse instead of guessing Six fixes from a review of the resolver, each with a regression test that fails without it: - A bare Row or Column under a general reference key (to, from, at, area, source, target, destination, location) stays text: Change Spelling's replacement word and a pivot field's area were being rewritten into the current row's A1 range. Row.col(2), {{Row}} and {"$ref": "Row"} still resolve there, and the bare word still resolves under cell and range keys. - {{{{ in a string is a literal {{, so text that needs the braces can be written; an unknown token is still an error. - Input.data on a range that is only a header row, and Name: of a table with no data rows, are refused instead of reading the header. - below(0), below(-3) and right(0) are refused like resize(0, 1) is, instead of becoming 1. - A defined name on a sheet whose name holds a dollar sign resolves: the dollar anchors are stripped from the reference part only. - quote_sheet is the engine's one rule (gridcraft_core::quote_sheet), which quotes a sheet named like a cell reference, an R1C1 token or a boolean and leaves a period unquoted. docs/macros.md says which keys take a bare token and how to write a literal brace. --- crates/engine/src/macros/resolve.rs | 167 ++++++++++++++++++++++++---- docs/macros.md | 11 +- 2 files changed, 156 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index b3cf438fa..03b9988da 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -109,6 +109,19 @@ pub fn is_ref_key(key: &str) -> bool { REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end)) } +/// Reference keys that other commands use for plain words: `to` is a replacement word in +/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a +/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token. +fn is_general_key(key: &str) -> bool { + ["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key)) +} + +/// `Row` or `Column` on its own, with no method or sheet after it. +fn is_bare_item_word(text: &str) -> bool { + let t = text.trim(); + t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column") +} + /// The most nesting [`resolve_params`] walks into. const MAX_JSON_DEPTH: usize = 64; @@ -159,13 +172,15 @@ fn plain_ref(t: &str, env: &Env) -> R { /// A defined name (by its formula) or a table (its data rows). fn named(name: &str, env: &Env) -> R { if let Some(n) = env.wb.name(name, env.active_sheet) { - let f = n.formula.trim().trim_start_matches('=').replace('$', ""); - let (sheet, body) = crate::cmd::split_sheet(&f); + // The sheet part keeps its characters (a sheet may be called `US$`); only the reference + // loses its `$` anchors. + let f = n.formula.trim().trim_start_matches('='); + let (sheet, body) = crate::cmd::split_sheet(f); let si = match sheet { Some(s) => env.wb.sheet_index(&s), None => n.scope.or(Some(env.active_sheet)), }; - if let (Some(sheet), Some(range)) = (si, RangeRef::parse(body)) { + if let (Some(sheet), Some(range)) = (si, RangeRef::parse(&body.replace('$', ""))) { return Ok(Place { sheet, range }); } return bad(format!("The name '{name}' doesn't refer to a range.")); @@ -173,7 +188,12 @@ fn named(name: &str, env: &Env) -> R { if let Some((si, ti)) = env.wb.table(name) && let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti)) { - return Ok(Place { sheet: si, range: t.data_range().unwrap_or(t.range) }); + // A table stands for its data rows; one with none (a header, perhaps a totals row) has + // nothing to give. + let Some(range) = t.data_range() else { + return bad(format!("The table '{name}' has no data rows.")); + }; + return Ok(Place { sheet: si, range }); } bad(format!("There is no name or table called '{name}'.")) } @@ -244,19 +264,23 @@ pub fn resolve_token(text: &str, env: &Env) -> R { RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?) } ("data", []) => { - if header && r.height() > 1 { + if header { + // A header row with nothing under it has no data rows to give. + if r.height() < 2 { + return bad(format!("'{t}': the range is only a header row, with no data under it.")); + } RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) } else { r } } ("below", []) | ("below", [Arg::Int(_)]) => { - let n = arg_or_one(&args); + let n = count_arg(&args, t)?; let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?; RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?) } ("right", []) | ("right", [Arg::Int(_)]) => { - let n = arg_or_one(&args); + let n = count_arg(&args, t)?; let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?; RangeRef::new(left, shift(r.end, 0, n, t)?) } @@ -279,10 +303,12 @@ pub fn resolve_token(text: &str, env: &Env) -> R { Ok(place) } -fn arg_or_one(args: &[Arg]) -> i64 { +/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1. +fn count_arg(args: &[Arg], t: &str) -> R { match args.first() { - Some(Arg::Int(n)) => (*n).max(1), - _ => 1, + Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")), + Some(Arg::Int(n)) => Ok(*n), + _ => Ok(1), } } @@ -453,11 +479,9 @@ pub fn a1(place: Place, env: &Env) -> String { /// `Sheet1` or `'My Sheet'` (quotes doubled). pub fn quote_sheet(name: &str) -> String { - if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') && !name.starts_with(|c: char| c.is_ascii_digit()) { - name.to_string() - } else { - format!("'{}'", name.replace('\'', "''")) - } + // The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean + // is quoted too, or `Q1!A1` would be read as a reference to cell Q1. + gridcraft_core::quote_sheet(name) } /// Step parameters with every token replaced by A1 text. @@ -485,7 +509,7 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { // Items of a list keep their key's kind (`ranges: ["Input", …]`). Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::>>()?), Json::String(s) => { - if key.is_some_and(is_ref_key) && is_token(s, env) { + if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && is_token(s, env) { Json::String(a1(resolve_token(s, env)?, env)) } else { Json::String(interpolate(s, env)?) @@ -495,7 +519,8 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { }) } -/// Replaces each `{{token}}` in `s`. +/// Replaces each `{{token}}` in `s`. `{{{{` is a literal `{{`, for text that needs the braces +/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`). pub fn interpolate(s: &str, env: &Env) -> R { if !s.contains("{{") { return Ok(s.to_string()); @@ -504,6 +529,11 @@ pub fn interpolate(s: &str, env: &Env) -> R { let mut rest = s; while let Some(i) = rest.find("{{") { out.push_str(rest.get(..i).unwrap_or("")); + if rest.get(i..i + 4) == Some("{{{{") { + out.push_str("{{"); + rest = rest.get(i + 4..).unwrap_or(""); + continue; + } let after = rest.get(i + 2..).unwrap_or(""); let Some(j) = after.find("}}") else { out.push_str(rest.get(i..).unwrap_or("")); @@ -793,8 +823,107 @@ mod tests { #[test] fn sheet_names_are_quoted_when_needed() { - for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] { - assert_eq!(quote_sheet(name), want); + // The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean + // is quoted too, and a period on its own is not a reason to quote. + for (name, want) in [ + ("Sheet1", "Sheet1"), + ("My Data", "'My Data'"), + ("O'Hara", "'O''Hara'"), + ("2024", "'2024'"), + ("", "''"), + ("Q1", "'Q1'"), + ("R1C1", "'R1C1'"), + ("TRUE", "'TRUE'"), + ("Sales.Q1", "Sales.Q1"), + ] { + assert_eq!(quote_sheet(name), want, "{name}"); + } + } + + #[test] + fn a_bare_row_or_column_under_a_general_key_is_text() { + // `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a + // pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not; + // the token forms still resolve there, and the bare word resolves under `range`. + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] { + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item }; + let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"}); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}"); + } + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) }; + let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"}); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"})); + } + + #[test] + fn a_header_row_alone_and_a_table_without_data_rows_are_refused() { + let mut wb = wb(); + let sh = wb.sheet_mut(0).unwrap(); + sh.tables.push(gridcraft_model::Table { + id: 1, + name: "Empty".into(), + range: RangeRef::parse("H1:J1").unwrap(), + header_row: true, + totals_row: false, + columns: Vec::new(), + style: String::new(), + banded_rows: false, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: false, + filter: Vec::new(), + table_type: None, + connection_id: None, + }); + let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row")); + assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row")); + check(&env, "Plain.data", "B2:D2"); + check(&env, "Input.col(2)", "C2"); + assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows")); + } + + #[test] + fn doubled_braces_are_literal_braces() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},"); + assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}"); + assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error"); + } + + #[test] + fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() { + let mut wb = wb(); + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("US$"))); + wb.names.push(gridcraft_model::DefinedName { + name: "Dollars".into(), + scope: None, + formula: "'US$'!$F$20".into(), + comment: String::new(), + hidden: false, + }); + let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None }; + check(&env, "Name:Dollars", "'US$'!F20"); + } + + #[test] + fn below_and_right_refuse_a_count_under_one() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + check(&env, "Input.below", "B12:D12"); + check(&env, "Input.below(2)", "B12:D13"); + check(&env, "Input.right(1)", "E2:E11"); + for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] { + assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}"); } } } diff --git a/docs/macros.md b/docs/macros.md index dd14951bc..6daf54c9e 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -63,9 +63,14 @@ exists is used and methods follow it. Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside -any string, such as a formula. Plain A1 references stay absolute; a `Name:` anchor follows -inserted rows. A reference with a sheet name is never a token, so `Output!A1` is a cell on a sheet -called Output, even when a token or input has that name. +any string, such as a formula; `{{{{` in a string is a literal `{{`. Under the general keys that +other commands use for plain words (`to`, `from`, `at`, `area`, `source`, `target`, +`destination`, `location`), a bare `Row` or `Column` stays text (a replacement word, a pivot +area); write `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` there. Plain A1 references stay +absolute; a `Name:` anchor follows inserted rows. A reference with a sheet name is never a token, +so `Output!A1` is a cell on a sheet called Output, even when a token or input has that name. +`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. ## Storage From bbf36000d4ec11d85daa8c0104aacd7ad74475d5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:08:36 -0400 Subject: [PATCH 076/166] Engine: the recorder counts a dropped step without serializing it Once a step has been dropped, or the step count is full, push serialized each later step only to count it; a file.open step holds a whole file. The checks that need no size come first. --- crates/engine/src/record.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 5b8100ed4..7206c21a3 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -49,9 +49,14 @@ pub struct Recorder { impl Recorder { /// Adds a step, or counts it as dropped once the buffer is full. pub fn push(&mut self, step: RecordedStep) { + // Once a step has been dropped, or the step count is full, the rest are counted without + // being serialized (a `file.open` step holds a whole file). + if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT { + self.dropped = self.dropped.saturating_add(1); + return; + } let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); - let fits = self.steps.len() < RECORD_LIMIT && self.bytes.saturating_add(size) <= RECORD_MAX_BYTES; - if fits && self.dropped == 0 { + if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { self.bytes = self.bytes.saturating_add(size); self.steps.push(step); } else { From 43b0e39e8e5ed89c16928454ad2d91b766e22d6b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:08:49 -0400 Subject: [PATCH 077/166] Engine: set_many refuses one key given twice Two items under one key, in any case, would leave two hidden names of one name, of which an xlsx file keeps one, and get would mix their chunks. A caller's mistake, refused before anything changes. Regression test. --- crates/engine/src/cmd/stash.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index e36635aff..d714bf6c8 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -76,7 +76,11 @@ pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> { MAX_ITEM_CHARS / (1024 * 1024) ))); } - keys.insert(key.to_ascii_lowercase()); + // Two items under one key (names compare without case) would leave two hidden names of + // one name, of which an xlsx file keeps one: a caller's mistake, refused. + if !keys.insert(key.to_ascii_lowercase()) { + return Err(EngineError::Other(format!("`{key}` is given twice."))); + } for (i, formula) in chunk_formulas(text).into_iter().enumerate() { planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); } @@ -375,6 +379,18 @@ mod tests { assert_eq!(wb.names, vec![own]); } + #[test] + fn set_many_refuses_one_key_twice() { + // Two items under one key, in any case, would leave two hidden names of one name. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_other_0", "\"stays\"")); + let err = set_many(&mut wb, &[("k".into(), "a".into()), ("K".into(), "b".into())]).unwrap_err().to_string(); + assert!(err.contains("given twice"), "{err}"); + assert_eq!(wb.names.len(), 1, "nothing changed"); + set_many(&mut wb, &[("k".into(), "a".into()), ("k2".into(), "b".into())]).unwrap(); + assert_eq!((get(&wb, "k").as_deref(), get(&wb, "K2").as_deref(), get(&wb, "other").as_deref()), (Some("a"), Some("b"), Some("stays"))); + } + #[test] fn a_chunk_number_with_leading_zeros_is_another_name() { // `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a From 551eda7e8eed3fe5db97dc1b2742bd93c612bae9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:08:49 -0400 Subject: [PATCH 078/166] Engine: the xlsx copy gets an empty macro list, not a copy of the shared one cleared out.macros shares its list with the workbook being saved, so clear() copied every macro first. --- crates/engine/src/macros/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 5a779758d..f0c83e549 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -108,7 +108,8 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { out.names.retain(|n| !stored(n)); let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect(); stash::set_many(&mut out, &items)?; - out.macros.clear(); + // An empty list of its own, not a copy of the shared one cleared. + out.macros = gridcraft_model::Macros::default(); Ok(Some(out)) } From 461395d0a058f512a10e7293248b13b9ca0437e4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:09:02 -0400 Subject: [PATCH 079/166] Engine: col and cols keep the header state, so a later data takes the header off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input.col("date").data resolved to the whole column, header included, because every method cleared the header state. col and cols keep it; the other methods still clear it, and only data keeps the header row for col("…"). The token test asks for D3:D11, cols(2..3).data and col(2).data.col(1). --- crates/engine/src/macros/resolve.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index 03b9988da..4e84c0965 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -288,9 +288,12 @@ pub fn resolve_token(text: &str, env: &Env) -> R { return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", "))); } }; - // After a method the result is plain cells: `data` no longer applies, and only `data` - // keeps the header row for `col("…")`. - header = false; + // After a method the result is plain cells, except that `col` and `cols` keep the input's top + // row, so a later `data` still takes the header off; only `data` keeps the header row for + // `col("…")`. + if !matches!(method.as_str(), "col" | "cols") { + header = false; + } if method != "data" { names_row = None; } @@ -615,7 +618,9 @@ mod tests { ("input", "B2:D11"), ("Input.col(2)", "C2:C11"), ("Input.col(\"Amount\")", "C2:C11"), - ("Input.col(\"date\").data", "D2:D11"), + ("Input.col(\"date\").data", "D3:D11"), + ("Input.cols(2..3).data", "C3:D11"), + ("Input.col(2).data.col(1)", "C3:C11"), ("Input.data", "B3:D11"), ("Input.data.col(2)", "C3:C11"), ("Input.data.col(\"Amount\")", "C3:C11"), From 74ff8ac6254c681ae50cd7657119cae9d54c6da9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:13:18 -0400 Subject: [PATCH 080/166] Engine: macro runs refuse a shift that moves an input, stay clean when nothing changed, take the answer however ask was given, and share one step budget Four fixes from a review of the runner, each with a regression test that fails without it: - A shift inserts cells over the block of every written cell, which moves that block's columns (or rows) from there on. When a bound input lay in the way, its data moved and sheared while the run kept the old coordinates and reported success. The shift is now refused, naming the input and its place. The output is not counted: it is where the macro writes, and the second footprint catches data a shift moved into it. - A dry run or a refused run left a clean workbook dirty: view.zoom, sheet.activate and the view modes move the saved mark to the current workbook on a clean document, and the rollback put the workbook back but not the mark. Saved keeps and restores it. - overwrite: true answers the question however ask was given; with an explicit onConflict: ask the answer was ignored and the question came back forever. - The dry runs and the run share one step budget as they share one clock; each phase counted its own 100,000 steps before. ran reports the run's own steps. --- crates/engine/src/macros/run.rs | 79 +++++++++++++++++------ crates/engine/src/tests/macros.rs | 104 ++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 36fd1ac41..4699a5e7c 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -769,6 +769,9 @@ fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct Footprint { + /// Steps executed by the end of the dry run that made this footprint (the run's one budget). + #[serde(skip)] + pub executed: u64, pub sheets: Vec, pub sheets_added: Vec, pub sheets_removed: Vec, @@ -839,6 +842,9 @@ impl Footprint { struct Saved { active: usize, docs: Vec, + /// The workbook as last saved: `sheet.activate` and the view commands move it to the + /// current workbook on a clean document, and the rollback puts the workbook back but not this. + saved: Option>, sheet_selections: Vec, unlocked_ranges: Vec<(String, String)>, trust: Trust, @@ -863,6 +869,7 @@ impl Saved { Saved { active: s.active, docs: s.docs.iter().map(|d| d.uid).collect(), + saved: d.map(|d| d.saved.clone()), sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), trust: d.map_or(Trust::Local, |d| d.trust), @@ -885,6 +892,9 @@ impl Saved { fn restore(self, s: &mut Session, doc: u64) { s.docs.retain(|d| self.docs.contains(&d.uid)); if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { + if let Some(saved) = self.saved { + d.saved = saved; + } d.sheet_selections = self.sheet_selections; d.unlocked_ranges = self.unlocked_ranges; d.trust = self.trust; @@ -908,7 +918,7 @@ impl Saved { /// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would /// change. The dry run stops once `time_limit_ms` has passed on `clock`. -pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64) -> Result { +pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result { let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; s.set_active(i); let saved = Saved::take(s, p.doc); @@ -918,7 +928,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: d.touched = Some(Vec::new()); } let mut m = Machine::new(p, true); - (m.clock, m.time_limit_ms) = (clock, time_limit_ms); + (m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed); let outcome = m.run_to_end(s); let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), @@ -929,6 +939,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: rolled?; let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.executed = m.executed; fp.error = outcome.err(); fp.stopped = m.stopped.map(|(msg, _)| msg); fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); @@ -1069,16 +1080,38 @@ pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { } /// Inserts blank cells where the footprint lands (moving what is there down or right), so the -/// macro writes into empty cells. -fn make_room(s: &mut Session, fp: &Footprint, down: bool) -> Result<()> { +/// macro writes into empty cells. The block is every written cell's, so a row the macro writes +/// stays together. A shift that would move one of the macro's inputs is refused: the inputs were +/// checked and bound where they are, and only the block's columns (or rows) would move. +fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> { if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); } let wb = s.doc()?.wb.clone(); + let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + let mut moves = Vec::new(); for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { - let Some(bounds) = sh.bounds_range else { continue }; - let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; - let range = resolve::a1(Place { sheet: sh.index, range: bounds }, &env); + let Some(block) = sh.bounds_range else { continue }; + // What the insertion moves: the block's columns from its top row down, or its rows from + // its left column rightwards. + let moved = |r: &RangeRef| { + if down { + r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row + } else { + r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col + } + }; + // The output is where the macro writes, so moving what is there is the point; the second + // footprint catches data the shift moved into it. + if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) { + return Err(EngineError::Other(format!( + "Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.", + resolve::a1(pl, &env) + ))); + } + moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env)); + } + for range in moves { s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; } Ok(()) @@ -1127,7 +1160,9 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { if rule == OnConflict::Overwrite { return Ok((machine, false)); } - let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?; + // The dry runs and the run share one step budget as they share one clock. + let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; + machine.executed = fp.executed; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1145,8 +1180,9 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))); } OnConflict::ShiftDown | OnConflict::ShiftRight => { - make_room(s, &fp, rule == OnConflict::ShiftDown)?; - fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?; + make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?; + fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; + machine.executed = fp.executed; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1176,11 +1212,13 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { s.grouped(&label, |s| { let (mut machine, shifted) = make_way(s, &b)?; let name = &b.mac.name; + // `ran` counts the run's own steps; the budget also holds the dry runs'. + let before_run = machine.executed; 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} Nothing was changed."))), stopped => { - let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs}); + let mut out = json!({"macro": name, "ran": machine.executed.saturating_sub(before_run), "inputs": b.inputs}); if let Some((msg, _)) = stopped { out["stopped"] = json!(msg); } @@ -1198,17 +1236,18 @@ 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`, or `overwrite: true` (the answer to the -/// question), else the macro's own. +/// 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 { - if let Some(t) = p.get("onConflict").and_then(Json::as_str) { - return OnConflict::parse(t) - .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'."))); - } - if p.get("overwrite").and_then(Json::as_bool) == Some(true) { + 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(m.output.on_conflict) + Ok(rule) } /// `macro.check`: what running the macro would do, without changing anything. @@ -1247,7 +1286,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { if !asks.is_empty() && !approved { return Ok(out); } - let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p))?; + let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p), 0)?; out["ok"] = json!(fp.error.is_none()); out["conflicts"] = json!(fp.conflict_count()); if let Some(e) = &fp.error { diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index a42a60dd7..d1babe4eb 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -232,6 +232,110 @@ fn shifting_makes_room_and_is_checked_again() { assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); } +#[test] +fn a_shift_that_would_move_an_input_is_refused() { + // A shift inserts cells over the conflicting block, which moves the block's columns (or rows) + // from there on. When one of the macro's own inputs lies in the way, its data would move and + // shear while the run keeps the old coordinates, so the shift is refused and nothing changes. + let mut s = s(); + data(&mut s); + set(&mut s, "B1", "Old title"); + put( + &mut s, + json!({"name": "Title", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Title", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input (") && err.contains("B2:C5"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing changed"); + assert_eq!((v(&s, "B1").display(), v(&s, "B3").display()), ("Old title".into(), "Ann".into())); + // Writing left of the input and shifting right would move it too. + put( + &mut s, + json!({"name": "Left", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.offset(1,-1).cell(1,1)", "input": "x"}} + ]}), + ); + set(&mut s, "A3", "in the way"); + let err = s.execute("macro.run", json!({"name": "Left", "inputs": "B2:C5", "onConflict": "shiftRight"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // The output counts as well. + put( + &mut s, + json!({"name": "Out", "inputs": [{"name": "Input", "header": true}], "output": {"anchor": "Input.below(1)"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let err = s.execute("macro.run", json!({"name": "Out", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // A shift that moves nothing of the macro's own still works, over the block of every written + // cell, so a row the macro writes stays together. + set(&mut s, "H1", "elsewhere"); + put( + &mut s, + json!({"name": "Wide", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "H1", "input": "over"}}, + {"command": "cell.set", "params": {"cell": "H9", "input": "far"}} + ]}), + ); + s.execute("macro.run", json!({"name": "Wide", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap(); + assert_eq!((v(&s, "H1").display(), v(&s, "H9").display(), v(&s, "H10").display()), ("over".into(), "far".into(), "elsewhere".into())); + assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move"); +} + +#[test] +fn a_dry_run_leaves_a_clean_workbook_clean() { + // `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean + // document; the dry run's rollback puts the workbook back, so the mark must come back too. + let mut s = s(); + data(&mut s); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(!s.doc().unwrap().is_dirty()); + put(&mut s, json!({"name": "Look", "steps": [{"command": "view.zoom", "params": {"percent": 120}}]})); + s.execute("macro.check", json!({"name": "Look"})).unwrap(); + assert!(!s.doc().unwrap().is_dirty(), "a dry run changed nothing"); + // A run refused by the conflict rule changed nothing either. + put(&mut s, totals()); + set(&mut s, "E3", "taken"); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).is_err()); + assert!(!s.doc().unwrap().is_dirty()); +} + +#[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 the_step_limit_is_one_budget_across_the_dry_run_and_the_run() { + // 300 x 300 passes is under the limit for one phase; the dry run and the run together are not. + let mut s = s(); + put( + &mut s, + json!({"name": "Spin", "steps": [{"type": "repeat", "over": "A1:A300", "body": [ + {"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); + assert!(err.contains("more than 100000 steps"), "{err}"); + // With no dry run (overwrite), the same macro fits. + s.execute("macro.run", json!({"name": "Spin", "onConflict": "overwrite"})).unwrap(); +} + #[test] fn the_footprint_covers_other_sheets_objects_and_edges() { let mut s = s(); From fb4acb04a2d70a88db7ec0fd0c998f08775cca0c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:30:14 -0400 Subject: [PATCH 081/166] Engine: closing the debugged workbook ends the debugger file.close is not an edit, so the debugger's lock let it through, and the session kept the debugger with a token for a group that went with the document: the next workbook could not start a debugger, and the state reported that no document was open. close_document (also the path that replaces an untouched blank workbook on file.open) now ends a debugger whose workbook it closes. Regression test. --- crates/engine/src/lib.rs | 4 ++++ crates/engine/src/tests/macros.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 399bb1c88..c1de0c401 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -454,6 +454,10 @@ impl Session { if i < self.docs.len() { let d = self.docs.remove(i); self.groups.retain(|g| g.doc != d.uid); + // A macro being debugged in this workbook ends with it; its group just went. + if self.macro_debug.as_ref().is_some_and(|m| m.doc == d.uid) { + self.macro_debug = None; + } if self.active >= self.docs.len() { self.active = self.docs.len().saturating_sub(1); } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 207e4c944..95e3b632e 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -232,6 +232,26 @@ fn shifting_makes_room_and_is_checked_again() { assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); } +#[test] +fn closing_the_debugged_workbook_ends_the_debugger() { + // `file.close` is not an edit, so the debugger's lock lets it through; the group went with + // the document, and so must the debugger, or the next workbook could not debug anything and + // the state would report that no document is open. + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + assert!(s.macro_debug.is_some()); + s.execute("file.close", json!({"force": true})).unwrap(); + assert!(s.macro_debug.is_none(), "the debugger ended with its workbook"); + s.new_workbook(); + data(&mut s); + put(&mut s, totals()); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + assert!(s.execute("macro.debug", json!({})).is_ok(), "the new workbook can be debugged"); + s.execute("macro.debug", json!({"action": "stop"})).unwrap(); +} + #[test] fn the_footprint_covers_other_sheets_objects_and_edges() { let mut s = s(); From a626705c958040ecf0c39af748c97351443e6ab7 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:30:35 -0400 Subject: [PATCH 082/166] Engine: a dry run puts the journal back even after the journal trimmed itself Every nested command a dry run executes is journaled, so a long macro pushes the journal past its limit and the journal drops its oldest entries. The restore cut the journal back to its old length, which then kept entries from the dry run and pointed Record Actions at the wrong place. The session now counts the entries its journal has dropped (journal_dropped) and truncates with its size bookkeeping (truncate_journal); the restore keeps the old length less what was dropped meanwhile and moves the recording's start back by the same count. Regression test with 12,000 nested steps. --- crates/engine/src/lib.rs | 19 +++++++++++++++++++ crates/engine/src/macros/run.rs | 11 +++++++++-- crates/engine/src/tests/macros.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 80084f7ca..1a89aa2b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -405,6 +405,9 @@ pub struct Session { /// Serialized size of each journal entry, measured once when it is pushed, and their sum. journal_sizes: Vec, journal_bytes: usize, + /// Entries the journal has dropped from its front to stay within its limits, ever; a dry run + /// that puts the journal back needs to know how many went while it ran. + journal_dropped: u64, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). pub format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -529,10 +532,26 @@ impl Session { self.journal.drain(..n); self.journal_sizes.drain(..n); self.journal_bytes = kept; + self.journal_dropped = self.journal_dropped.saturating_add(n as u64); self.recording = self.recording.map(|r| r.saturating_sub(n)); } } + /// Entries dropped from the journal's front so far, for a caller that puts the journal back. + pub(crate) fn journal_dropped(&self) -> u64 { + self.journal_dropped + } + + /// Cuts the journal back to its first `keep` entries, with its size bookkeeping. + pub(crate) fn truncate_journal(&mut self, keep: usize) { + if self.journal_sizes.len() == self.journal.len() { + self.journal_sizes.truncate(keep); + self.journal_bytes = self.journal_sizes.iter().sum(); + } + self.journal.truncate(keep); + self.recording = self.recording.map(|r| r.min(self.journal.len())); + } + pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 4699a5e7c..889d3dd0f 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -851,7 +851,10 @@ struct Saved { clipboard: Option, prefs: crate::Prefs, mode: crate::Mode, + /// The journal's length and how many entries it had dropped from its front; a dry run that + /// pushes past the journal's limits drops more, which a length alone cannot see. journal: usize, + journal_dropped: u64, recording: Option, ui_requests: usize, format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -877,6 +880,7 @@ impl Saved { prefs: s.prefs.clone(), mode: s.mode, journal: s.journal.len(), + journal_dropped: s.journal_dropped(), recording: s.recording, ui_requests: s.ui_requests.len(), format_painter: s.format_painter.clone(), @@ -903,8 +907,11 @@ impl Saved { s.clipboard = self.clipboard; s.prefs = self.prefs; s.mode = self.mode; - s.journal.truncate(self.journal); - s.recording = self.recording; + // Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good, + // and the recording's start moves back with them. + let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX); + s.truncate_journal(self.journal.saturating_sub(dropped)); + s.recording = self.recording.map(|r| r.saturating_sub(dropped)); s.ui_requests.truncate(self.ui_requests); s.format_painter = self.format_painter; s.last_find = self.last_find; diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index d1babe4eb..d2aa786d8 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -306,6 +306,36 @@ fn a_dry_run_leaves_a_clean_workbook_clean() { assert!(!s.doc().unwrap().is_dirty()); } +#[test] +fn a_dry_run_puts_the_journal_back_even_after_it_trimmed_it() { + // Every nested command a dry run executes is journaled, so a long macro pushes the journal + // past its limit and the journal drops its oldest entries. Putting the journal back by its + // old length would then keep entries from the dry run and point a recording at the wrong + // place; the restore counts what was dropped. + let mut s = s(); + data(&mut s); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let journal = s.journal.clone(); + s.recording = Some(journal.len().saturating_sub(1)); + put( + &mut s, + json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A200", "body": [ + {"type": "repeat", "over": "A1:A60", "body": [{"command": "cell.set", "params": {"cell": "Row.cell(1,1)", "input": "x"}}]} + ]}]}), + ); + s.execute("macro.check", json!({"name": "Long"})).unwrap(); + let sets = |j: &[(String, Json)]| j.iter().filter(|(id, _)| id == "cell.set").count(); + assert!(sets(&s.journal) <= sets(&journal), "no dry-run entry stays"); + assert!(s.journal.len() <= journal.len(), "{} entries, had {}", s.journal.len(), journal.len()); + assert!(journal.ends_with(&s.journal), "what stays is the journal's own tail"); + assert!(s.recording.is_some_and(|r| r <= s.journal.len()), "{:?}", s.recording); + // A short dry run leaves the journal exactly as it was. + put(&mut s, json!({"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "x"}}]})); + let (journal, recording) = (s.journal.clone(), s.recording); + s.execute("macro.check", json!({"name": "Short"})).unwrap(); + assert_eq!((&s.journal, s.recording), (&journal, recording)); +} + #[test] fn the_answer_to_the_question_counts_however_ask_was_given() { let mut s = s(); From 879b96f858dfc12e10662392d181f44e83ba5d09 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:30:35 -0400 Subject: [PATCH 083/166] Docs: the footprint dry run is skipped when the conflict rule is overwrite --- docs/macros.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index b757d26cb..a9f7f1340 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -96,8 +96,9 @@ refused rather than read as the header. 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. -2. **Footprint.** The macro first runs in dry-run mode on the real workbook, and everything is put - back. Cells it would write outside its inputs that hold data, and sheets and objects it would +2. **Footprint.** Unless the conflict rule is `overwrite`, the macro first runs in dry-run mode on + the real workbook, and everything is put back. Cells it would write outside its + inputs that hold data, and sheets and objects it would delete, are conflicts, handled by `onConflict` (or the macro's `output.onConflict`): `ask` (the call fails with `confirm: …`; `overwrite: true` answers it), `fail`, `overwrite`, `shiftDown` or `shiftRight` (Insert Cells makes room, then the footprint is checked again). Steps that touch From 2215934c9d3b105c5f22b7d519caf3563285505d Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:55:39 -0400 Subject: [PATCH 084/166] Engine: a shift is refused when the footprint cannot follow every write The touch log keeps at most a million writes; past that, a write that kept a cell's value is missing from the footprint, so a shift derived from the block of written cells could split a row the macro writes. The footprint now says when the log filled up (logFull in macro.check), and a shift is refused then, as it is when the macro deletes sheets or objects. Regression test with a million-cell fill in a dry run. --- crates/engine/src/cmd/mod.rs | 4 +++- crates/engine/src/macros/run.rs | 8 ++++++++ crates/engine/src/tests/macros.rs | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7527b37aa..ec73e2e2a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -401,7 +401,9 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } /// The most written cells a macro's footprint check records. -const TOUCH_LOG_LIMIT: usize = 1_000_000; +/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes +/// that kept a cell's value, so a shift is refused then. +pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 889d3dd0f..ab67a9ce2 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -772,6 +772,10 @@ pub struct Footprint { /// Steps executed by the end of the dry run that made this footprint (the run's one budget). #[serde(skip)] pub executed: u64, + /// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value + /// may be missing from `cells`: a shift cannot trust the block. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub log_full: bool, pub sheets: Vec, pub sheets_added: Vec, pub sheets_removed: Vec, @@ -946,6 +950,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: rolled?; let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT; fp.executed = m.executed; fp.error = outcome.err(); fp.stopped = m.stopped.map(|(msg, _)| msg); @@ -1094,6 +1099,9 @@ fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Resul if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); } + if fp.log_full { + return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into())); + } let wb = s.doc()?.wb.clone(); let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; let mut moves = Vec::new(); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index d2aa786d8..285716b83 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -285,6 +285,28 @@ fn a_shift_that_would_move_an_input_is_refused() { assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move"); } +#[test] +fn a_shift_is_refused_when_the_footprint_cannot_follow_every_write() { + // The touch log keeps at most a million writes; past that, a write that kept a cell's value + // is missing from the footprint, so a shift could split a row the macro writes. The footprint + // says so and the shift is refused (overwrite, with no dry run, still runs). + let mut s = s(); + set(&mut s, "A1", "x"); + set(&mut s, "E2", "old"); + put( + &mut s, + json!({"name": "Flood", "steps": [ + {"command": "edit.fillDown", "params": {"range": "A1:A1000001"}}, + {"command": "cell.set", "params": {"cell": "E2", "input": "new"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Flood"})).unwrap(); + assert_eq!(c["footprint"]["logFull"], true, "{c}"); + let err = s.execute("macro.run", json!({"name": "Flood", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("more cells than the footprint can follow"), "{err}"); + assert_eq!(v(&s, "E2").display(), "old", "nothing changed"); +} + #[test] fn a_dry_run_leaves_a_clean_workbook_clean() { // `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean From bb3db53489b50da833c4ae89516f12e56830fdfa Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:56:32 -0400 Subject: [PATCH 085/166] Engine: the macro recorder keeps a step's own sheet, stays bounded, and can be cancelled Six fixes from a review of the recorder, each with a regression test (four fail against the old recorder; the other two bound work that grew with the input): - A step that names another sheet with its sheet parameter (cell.set and about fifty other commands) was tokenized as if on the input's sheet, so a recorded cell.set {sheet: Two, cell: C4} ran on Two at the input's relative place. Such a step keeps its references, like a sheet-qualified one. - Inlining recorded runs of other macros and scripts stops at the macro's step limit and counts the rest in dropped, instead of expanding every run first (a thousand runs of a long macro made millions of steps). - macro.record {cancel: true} ends a recording without saving it: the way out of a recording whose macro is too large for the workbook, which could neither be saved nor ended. - Formula tokenization reads each reference in the formula and matches it, instead of listing every column and row of the example (an example as large as a sheet made a million candidates per formula). - A selection of several areas keeps its active cell as it is; a token for the active cell alone put it outside the areas on playback. - Steps recorded in another workbook are counted in dropped. --- crates/engine/src/cmd/macros.rs | 14 +++- crates/engine/src/macros/recorder.rs | 119 ++++++++++++++++++--------- crates/engine/src/tests/macros.rs | 98 ++++++++++++++++++++++ docs/macros.md | 3 +- 4 files changed, 189 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 426d06492..6cfd069f7 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Record Macro", ["View", "Macros"], None, - "{stop?: bool, relative?: bool (default true; when starting), name?, description? (when stopping; a taken name gets a suffix)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", + "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, description? (when stopping; a taken name gets a suffix)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", has_doc, record ), @@ -163,6 +163,13 @@ fn check(s: &mut Session, p: &Json) -> Result { } fn record(s: &mut Session, p: &Json) -> Result { + // `cancel` ends a recording without saving it: the way out of a recording whose macro + // cannot be saved (too large for the workbook, for one). + if bool_param(p, "cancel").unwrap_or(false) { + let was = s.stop_recording().is_some(); + s.macro_recording = None; + return Ok(json!({"recording": false, "cancelled": was})); + } if bool_param(p, "stop").unwrap_or(false) || s.is_recording() { return stop_recording(s, p); } @@ -228,8 +235,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star None => crate::macros::next_name(&wb), }; let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &wb); - // Steps past the macro limit are left out and counted with the ones the recorder dropped. - let cut = converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS); + // Steps past the macro limit, and ones recorded in another workbook, are left out and counted + // with the ones the recorder dropped. + let cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS)); converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS); if let Some(desc) = str_param(p, "description") { converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect(); diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index 210872475..257278b02 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -15,7 +15,7 @@ //! shape, header and column types are inferred from it. use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::{ColType, InputKind, Macro, NamedInput, Step, StepKind, Workbook}; +use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook}; use serde_json::{Map, Value as Json, json}; use super::policy::{Policy, macro_policy}; @@ -41,15 +41,28 @@ pub struct Converted { pub mac: Macro, /// Commands left out (ids), in order. pub skipped: Vec, + /// Steps not kept: past the macro's step limit (a recorded run of a long macro adds all of + /// its steps) or recorded in another workbook. + pub cut: usize, } /// Builds a macro called `name` from the recorded steps. pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &Workbook) -> Converted { let mut skipped = Vec::new(); // Commands as they'll be played back: (id, params, sheet before, selection before, whether - // they came from another macro and stay as written). + // they came from another macro and stay as written). At most the macro's step limit are + // gathered: a recording of a thousand runs of a long macro would otherwise expand to + // millions of steps before the limit cut them back. let mut cmds: Vec<(String, Json, usize, String, Selection, bool)> = Vec::new(); + let mut cut = steps.iter().filter(|st| st.doc != start.doc).count(); let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)); + let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, bool)>, cmd: (String, Json, usize, String, Selection, bool)| { + if cmds.len() < MAX_MACRO_STEPS { + cmds.push(cmd); + } else { + cut = cut.saturating_add(1); + } + }; for st in steps.iter().filter(|st| st.doc == start.doc) { let id = st.id.as_str(); if id == "macro.run" { @@ -64,7 +77,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & skipped.push(cid.clone()); continue; } - cmds.push((cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); } } _ => skipped.push(id.to_string()), @@ -80,7 +93,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & skipped.push(cid); continue; } - cmds.push((cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + push(&mut cmds, (cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); } } None => skipped.push(id.to_string()), @@ -91,7 +104,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & skipped.push(id.to_string()); continue; } - cmds.push((id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), false)); + push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), false)); } // Sheet and selection changes made without a command: where a command ran on another sheet, @@ -131,7 +144,14 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & let steps: Vec = collapsed .into_iter() .map(|(id, params, on, kept)| { - let here = on.eq_ignore_ascii_case(&start.sheet_name); + // A step that names another sheet with its `sheet` parameter runs there, whatever + // sheet was active: its unqualified references are that sheet's cells. + let names_other_sheet = params.get("sheet").is_some_and(|v| match v { + Json::String(n) => !n.eq_ignore_ascii_case(&start.sheet_name), + Json::Number(i) => i.as_u64() != Some(start.sheet as u64), + _ => false, + }); + let here = on.eq_ignore_ascii_case(&start.sheet_name) && !names_other_sheet; let params = if start.relative && !kept { relative(¶ms, example, &start.sheet_name, here, &mut used_input, None) } else { params }; Step::command(id, params) }) @@ -140,7 +160,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & if start.relative && used_input { mac.inputs = vec![infer_input(wb, start.sheet, example)]; } - Converted { mac, skipped } + Converted { mac, skipped, cut } } fn selection_params(sel: &Selection) -> Json { @@ -153,22 +173,28 @@ fn selection_params(sel: &Selection) -> Json { fn relative(v: &Json, example: RangeRef, sheet: &str, here: bool, used: &mut bool, key: Option<&str>) -> Json { match v { Json::Object(o) => { + // A selection of several areas stays as it is, its active cell with it: a token for + // the active cell alone would put it outside the areas on playback. + let several_areas = o.get("range").and_then(Json::as_str).is_some_and(|r| r.contains(',')); let mut out = Map::new(); for (k, x) in o { - out.insert(k.clone(), relative(x, example, sheet, here, used, Some(k))); + let x = if several_areas && k == "active" { x.clone() } else { relative(x, example, sheet, here, used, Some(k)) }; + out.insert(k.clone(), x); } Json::Object(out) } Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, here, used, key)).collect()), Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => { - // A list of areas ("A1:B2,D4") stays as it is unless every part has a token. - let parts: Option> = t.split(',').map(|part| ref_token(part, example, sheet, here)).collect(); - match parts { - Some(p) if p.len() == 1 => { + // One area becomes a token; a list of areas ("A1:B2,D4") stays as it is. + if t.contains(',') { + return v.clone(); + } + match ref_token(t, example, sheet, here) { + Some(token) => { *used = true; - json!(p.join("")) + json!(token) } - _ => v.clone(), + None => v.clone(), } } Json::String(t) if here && t.starts_with('=') && example.count() > 1 => { @@ -255,22 +281,9 @@ fn token(t: RangeRef, r: RangeRef) -> Option { /// Replaces references in a formula that are exactly the example input, one of its columns or /// rows, or a column without its first row, with `{{token}}`. fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) { - let mut candidates: Vec<(String, String)> = vec![(r.a1(), "Input".into())]; - for (i, col) in (r.start.col..=r.end.col).enumerate() { - let n = i + 1; - candidates.push((RangeRef::new(CellRef::new(r.start.row, col), CellRef::new(r.end.row, col)).a1(), format!("Input.col({n})"))); - if r.height() > 2 { - candidates - .push((RangeRef::new(CellRef::new(r.start.row + 1, col), CellRef::new(r.end.row, col)).a1(), format!("Input.rows(2..).col({n})"))); - } - } - if r.width() > 1 { - for (i, row) in (r.start.row..=r.end.row).enumerate() { - candidates.push((RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), format!("Input.row({})", i + 1))); - } - } - // Longest first, so `B2:B10` isn't taken for a `B2` cell reference. - candidates.sort_by_key(|(a1, _)| std::cmp::Reverse(a1.len())); + // Each reference in the formula text is read and matched on its own, so the work is the + // formula's length whatever the example's size (an example of a whole sheet has a million + // rows, which no list of candidates may grow with). let mut out = String::with_capacity(f.len()); let mut changed = false; let (mut i, mut in_text) = (0, false); @@ -282,19 +295,20 @@ fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) { in_text = !in_text; } let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':')); - if !in_text && prev_ok { - let hit = candidates.iter().find(|(a1, _)| { - rest.starts_with(a1.as_str()) - && !rest - .get(a1.len()..) - .and_then(|x| x.chars().next()) - .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '!' | '(' | '_')) - }); - if let Some((a1, tok)) = hit { + if !in_text && prev_ok && ch.is_ascii_alphabetic() { + // The longest run of reference characters here: letters, digits and one `:`. + let n = rest.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b':').count(); + let text = rest.get(..n).unwrap_or(""); + let followed = rest.get(n..).and_then(|x| x.chars().next()).is_some_and(|c| matches!(c, '!' | '(' | '_' | '$')); + if !followed + && !text.contains('$') + && let Some(t) = RangeRef::parse(text) + && let Some(tok) = formula_token(t, r) + { out.push_str("{{"); - out.push_str(tok); + out.push_str(&tok); out.push_str("}}"); - i += a1.len(); + i += n; changed = true; continue; } @@ -305,6 +319,29 @@ fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) { (out, changed) } +/// The token for a reference inside a formula: exactly the example input, one of its columns or +/// rows, or a column without its first row. Other references stay as they are. +fn formula_token(t: RangeRef, r: RangeRef) -> Option { + if t == r { + return Some("Input".into()); + } + if !r.contains_range(&t) { + return None; + } + let col = usize::try_from(t.start.col.checked_sub(r.start.col)?).ok()?.checked_add(1)?; + let row = usize::try_from(t.start.row.checked_sub(r.start.row)?).ok()?.checked_add(1)?; + if t.start.col == t.end.col && t.start.row == r.start.row && t.end.row == r.end.row { + return Some(format!("Input.col({col})")); + } + if t.start.col == t.end.col && r.height() > 2 && t.start.row == r.start.row.checked_add(1)? && t.end.row == r.end.row { + return Some(format!("Input.rows(2..).col({col})")); + } + if r.width() > 1 && t.start.row == t.end.row && t.start.col == r.start.col && t.end.col == r.end.col { + return Some(format!("Input.row({row})")); + } + None +} + /// The example input's shape: a header when the first row is all text over other kinds of /// values, and each column's type when all its values agree. fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput { diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index df6e59606..3e246ee71 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1046,6 +1046,104 @@ fn a_relative_recording_keeps_references_on_another_sheet() { assert_eq!((v(&s, "C4").display(), v(&s, "D2").display()), ("x".into(), "12".into())); } +#[test] +fn a_step_that_names_another_sheet_keeps_its_references() { + // `sheet` names where the step runs whatever sheet is active, so its C4 is that sheet's C4. + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + data(&mut s); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + s.execute("cell.set", json!({"sheet": "Two", "cell": "C4", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"sheet": "Sheet1", "cell": "C4", "input": "90"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Named"})).unwrap(); + let steps = recorded(&mut s, "Named"); + assert!(steps.contains(&"cell.set {\"cell\":\"C4\",\"input\":\"x\",\"sheet\":\"Two\"}".to_string()), "{steps:?}"); + assert!(steps.contains(&"cell.set {\"cell\":\"Input.cell(3,2)\",\"input\":\"90\",\"sheet\":\"Sheet1\"}".to_string()), "{steps:?}"); +} + +#[test] +fn inlining_recorded_runs_stops_at_the_step_limit() { + // A recorded run of a long macro adds all of its steps; the conversion gathers at most the + // macro's step limit and counts the rest, instead of expanding every run first. + let mut s = s(); + let steps: Vec = (0..6_000).map(|_| json!({"command": "cell.set", "params": {"cell": "A1", "input": "x"}})).collect(); + put(&mut s, json!({"name": "Big", "output": {"onConflict": "overwrite"}, "steps": steps})); + s.execute("macro.record", json!({})).unwrap(); + s.execute("macro.run", json!({"name": "Big"})).unwrap(); + s.execute("macro.run", json!({"name": "Big"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Twice"})).unwrap(); + assert_eq!((r["steps"].as_u64(), r["dropped"].as_u64()), (Some(10_000), Some(2_000)), "{r}"); +} + +#[test] +fn a_recording_can_be_cancelled() { + let mut s = s(); + data(&mut s); + let before = s.execute("macro.list", json!({})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "E2", "x"); + let r = s.execute("macro.record", json!({"cancel": true})).unwrap(); + assert_eq!(r, json!({"recording": false, "cancelled": true})); + assert!(!s.is_recording() && s.macro_recording.is_none()); + assert_eq!(s.execute("macro.list", json!({})).unwrap(), before, "nothing was saved"); + assert_eq!(s.execute("macro.record", json!({"cancel": true})).unwrap()["cancelled"], false); + // Recording works again afterwards. + s.execute("macro.record", json!({})).unwrap(); + assert!(s.is_recording()); + s.execute("macro.record", json!({"cancel": true})).unwrap(); +} + +#[test] +fn formulas_are_tokenized_by_reading_their_references() { + // An example as large as a sheet has a million rows: each reference in the formula is read + // and matched on its own, instead of a list of every column and row of the example. + let mut s = s(); + s.execute("selection.set", json!({"range": "A1:Z1000000"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "AB1", "input": "=SUM(A1:A1000000)+SUM(A1:A3)+B2"})).unwrap(); + s.execute("cell.set", json!({"cell": "AB2", "input": "=SUM(A1:Z1000000)&\"A1:A1000000\""})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Wide"})).unwrap(); + let steps = recorded(&mut s, "Wide"); + // A single cell inside a formula stays as it is: only the input, its columns and its rows + // become tokens there. + assert!(steps.iter().any(|st| st.contains("=SUM({{Input.col(1)}})+SUM(A1:A3)+B2")), "{steps:?}"); + assert!(steps.iter().any(|st| st.contains("=SUM({{Input}})&\\\"A1:A1000000\\\"")), "{steps:?}"); +} + +#[test] +fn a_selection_of_several_areas_keeps_its_active_cell_as_it_is() { + let mut s = s(); + data(&mut s); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + // Several areas picked with the mouse: no command, so the recording adds a selection.set + // with the active cell before the next step. + let (a, b) = (gridcraft_core::RangeRef::parse("B2:C5").unwrap(), gridcraft_core::RangeRef::parse("E2:E5").unwrap()); + s.doc_mut().unwrap().selection = crate::Selection { active: b.start, anchor: b.start, ranges: vec![a, b] }; + s.execute("home.bold", json!({})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Areas"})).unwrap(); + let steps = recorded(&mut s, "Areas"); + assert!(steps.contains(&"selection.set {\"active\":\"E2\",\"range\":\"B2:C5,E2:E5\"}".to_string()), "{steps:?}"); + assert!(steps.iter().all(|st| !st.contains("\"active\":\"Input")), "{steps:?}"); +} + +#[test] +fn steps_recorded_in_another_workbook_are_counted() { + let mut s = s(); + data(&mut s); + let home = s.active_index(); + s.execute("macro.record", json!({})).unwrap(); + s.new_workbook(); + set(&mut s, "A1", "elsewhere"); + set(&mut s, "A2", "too"); + s.set_active(home); + set(&mut s, "E2", "here"); + let r = s.execute("macro.record", json!({"stop": true, "name": "Home"})).unwrap(); + assert_eq!((r["steps"].as_u64(), r["dropped"].as_u64()), (Some(1), Some(2)), "{r}"); +} + #[test] fn a_recorded_macro_run_is_inlined_only_when_its_steps_stand_alone() { let mut s = s(); diff --git a/docs/macros.md b/docs/macros.md index 503068707..22742ac85 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -120,7 +120,8 @@ of the step that just ran, and the cells changed so far. `macro.record` toggles recording (`relative: false` for absolute references; `name` and `description` when stopping; a name that another macro has gets a suffix, such as `Tidy (2)`, so -stopping never replaces a macro). The macro is saved in the workbook where recording started, even if +stopping never replaces a macro; `cancel: true` ends a recording without saving it, the way out of +one whose macro is too large for the workbook). The macro is saved in the workbook where recording started, even if another one is active; if that workbook was closed, stopping ends the recording without saving it. When saving the recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged in that workbook), recording goes on, so stopping again keeps what was From 359cce4bc3d4860c10406cf123ae49d5b37f9124 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:36:36 -0400 Subject: [PATCH 086/166] Tests and docs: merge fallout from main and the lower branches automate.allScripts reports trusted since #2178, and scripts reopened from a file are not trusted yet, so the side-by-side test expects the flag and compares names and steps after the round trip. cargo xtask parity gives 276 / 292 now (upstream added a command), so the ROADMAP and ui-parity summaries say so, and this branch's revision rows (275 / 291 to 276 / 292) sit at the top of their tables with the date they land. --- ROADMAP.md | 4 ++-- crates/engine/src/tests/macros.rs | 6 ++++-- docs/parity-checklist.md | 4 ++-- docs/ui-parity.md | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b039998b7..59603f727 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| | 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 275 / 291 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -222,6 +222,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | | 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | @@ -239,7 +240,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) | | 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) | | 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries | -| 2026-10-21 | minor | Macro engine and `macro.*` commands; catalog 274 / 290 → 275 / 291 (View › Macros › Record Macro); progress log row | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 2c0b557de..8459bbcb1 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -981,7 +981,7 @@ fn macros_and_automate_scripts_live_side_by_side() { assert_eq!(ids, ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}", "cell.set {\"cell\":\"B1\",\"input\":\"from script\"}"]); // Both lists keep their own entries, under the same name if need be. let scripts = s.execute("automate.allScripts", json!({})).unwrap(); - assert_eq!(scripts, json!([{"name": "Empty", "steps": 0}, {"name": "Inner", "steps": 1}])); + assert_eq!(scripts, json!([{"name": "Empty", "steps": 0, "trusted": true}, {"name": "Inner", "steps": 1, "trusted": true}])); let macros: Vec = s.execute("macro.list", json!({})).unwrap().as_array().unwrap().iter().map(|m| m["name"].clone()).collect(); assert_eq!(macros, [json!("Inner"), json!("Recorded")]); s.execute("home.insertSheet", json!({})).unwrap(); @@ -991,7 +991,9 @@ fn macros_and_automate_scripts_live_side_by_side() { // Both survive an xlsx round trip unchanged. let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); s.execute("file.open", json!({"name": "both.xlsx", "base64": b64})).unwrap(); - assert_eq!(s.execute("automate.allScripts", json!({})).unwrap(), scripts); + // Opened from a file, the scripts are not trusted yet (#2178); their names and steps are the same. + let names_and_steps = |v: &Json| v.as_array().unwrap().iter().map(|s| (s["name"].clone(), s["steps"].clone())).collect::>(); + assert_eq!(names_and_steps(&s.execute("automate.allScripts", json!({})).unwrap()), names_and_steps(&scripts)); assert_eq!(s.execute("macro.list", json!({})).unwrap().as_array().unwrap().len(), 2); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1ef7c423e..71d75f659 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 275 / 291 commands implemented (94.5%).** +**Overall: 276 / 292 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -16,7 +16,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Formulas | 23 | 23 | 100% | | Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | -| View | 20 | 20 | 100% | +| View | 21 | 21 | 100% | | Automate | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0b020ef06..c577e0439 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (ribbon catalog count 275 / 291) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (275 / 291). +separately in [parity-checklist.md](parity-checklist.md) (276 / 292). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -175,6 +175,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | @@ -183,7 +184,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | | 2026-10-20 | minor | Esc and Enter close a message over the cell editor, such as a data validation alert, and the entry stays open to fix, with the keyboard back in it. Before, the editor took them (the first Esc cancelled the edit behind the alert and only the second closed it), and after the message typing went nowhere until the cell was clicked | -| 2026-10-21 | minor | Ribbon catalog count 274 / 290 → 275 / 291 (View › Macros › Record Macro) | | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-19 | trivial | While a mouse button is held (a click or a drag selecting cells) the selection outline is drawn on its cells at once instead of gliding after the pointer, which read as lag; keyboard moves still glide | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | From 2b33fcf881e40ef4ad1effaea270e2762f7b008d Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:49:43 -0400 Subject: [PATCH 087/166] Engine: a failed command rolls back the undo groups it opened A command that opened an undo group and then returned an error (a `?` between `group_begin` and `group_end`) left the group open for good: Undo and Redo refused with "finish or stop the running action first", later edits made no undo steps, and nothing could stop the group. `execute` now rolls back every group a failed command opened, as `group_rollback` does, restoring the workbook, selection, undo and redo history to where each group began. Only a command that succeeds keeps a group it opened, which is what a debugger's start needs. `Session::abort_groups(doc)` and the menu-less command `automate.stopAction` roll back the groups left open on a document, for a debugger the person abandons or a caller that dropped its token. The command is not undoable and works while a group is open, and the Undo refusal now names it. --- crates/engine/src/cmd/extra.rs | 16 ++++++ crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/lib.rs | 29 +++++++++++ crates/engine/src/tests/seams.rs | 89 ++++++++++++++++++++++++++++++++ docs/architecture.md | 12 +++-- 5 files changed, 143 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a566f7f12..6197dd5e2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -212,6 +212,15 @@ pub fn specs() -> Vec { trust_scripts ), cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), + cmd!( + noundo "automate.stopAction", + "Stop Action", + [], + None, + "{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began", + has_doc, + stop_action + ), // Cell controls, themes, borders, styles cmd!( "insert.checkbox", @@ -1088,6 +1097,13 @@ fn run_script(s: &mut Session, p: &Json) -> Result { Ok(json!({"ran": n})) } +/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and +/// Redo work there again. +fn stop_action(s: &mut Session, _: &Json) -> Result { + let uid = s.doc()?.uid; + Ok(json!({"stopped": s.abort_groups(uid)})) +} + fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 766a41005..8a0bb1052 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -108,7 +108,7 @@ pub fn can_redo(s: &Session) -> std::result::Result<(), String> { /// Undo and Redo wait while an undo group is open on the active document (an action that spans /// several calls is still running). fn not_grouping(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first".into()) } else { Ok(()) } + if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) } } pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a8d8bcfa..3b40d7401 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -594,6 +594,12 @@ impl Session { return Err(EngineError::Internal(id.to_string(), msg)); } }; + // A command that fails leaves none of its own undo groups open (a `?` between + // `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a + // command that succeeds keeps a group it opened (a debugger's start). + if result.is_err() { + self.rollback_groups_from(first_group); + } if spec.journal && result.is_ok() { // A command that started recording is not part of the recording; one that stopped it // finds no recorder. @@ -751,6 +757,29 @@ impl Session { Ok(changed) } + /// Stops the running action on the document with uid `doc`: every undo group open on it is + /// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo + /// and redo history as they were when the outermost one began. Their tokens are then spent + /// (ending or rolling one back reports that it has already ended). Returns whether a group + /// was open. `automate.stopAction` runs it on the active document. + pub fn abort_groups(&mut self, doc: u64) -> bool { + let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false }; + // Rolling back the outermost group ends the ones opened inside it. + self.group_rollback(GroupToken { id, doc }).is_ok() + } + + /// Rolls back every open undo group numbered `first` or later (the groups a failed command + /// opened), outermost first on each document. + fn rollback_groups_from(&mut self, first: u64) { + let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect(); + for (id, doc) in opened { + // A group inside one rolled back before it is already gone. + if self.groups.iter().any(|g| g.id == id) { + let _ = self.group_rollback(GroupToken { id, doc }); + } + } + } + /// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per /// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest. /// Only steps after the latest mark go: a running command's start, an open group's start and diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 0f1bc3072..9d32c66d1 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -643,6 +643,95 @@ fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() { } } +#[test] +fn a_command_that_fails_rolls_back_the_groups_it_opened() { + // A `?` between `group_begin` and `group_end` used to leave the group open for good: Undo + // and Redo refused, and later edits made no steps. + crate::cmd::register_undoable_test_command("test.groupThenFail", |s, _| { + let _token = s.group_begin("Half")?; + put(s, "A1", "half done")?; + Err(EngineError::Other("stopped halfway".into())) + }); + crate::cmd::register_undoable_test_command("test.editGroupThenFail", |s, _| { + put(s, "B1", "outside")?; + let _token = s.group_begin("Half")?; + put(s, "B2", "inside")?; + Err(EngineError::Other("stopped halfway".into())) + }); + let mut s = s(); + set(&mut s, "A1", "before"); + set(&mut s, "A2", "undone"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "C3"})).unwrap(); + let uid = s.doc().unwrap().uid; + let err = s.execute("test.groupThenFail", json!({})).unwrap_err().to_string(); + assert!(err.contains("stopped halfway"), "{err}"); + assert!(!s.in_group(uid), "no group is left open"); + assert_eq!(v(&s, "A1").display(), "before"); + assert_eq!(s.doc().unwrap().selection.a1(), "C3"); + assert_eq!(undo_labels(&s), ["Enter Cell"]); + assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history comes back"); + assert_eq!(s.execute("history.list", json!({})).unwrap()["grouping"], json!(false)); + set(&mut s, "A3", "next"); + assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell"], "later edits make steps again"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A3").is_empty()); + + // What the command changed before opening the group stays, as for any failed command, and is + // a step of its own; what it changed inside the group goes. + assert!(s.execute("test.editGroupThenFail", json!({})).is_err()); + assert!(!s.in_group(uid)); + assert_eq!((v(&s, "B1").display(), v(&s, "B2").display()), ("outside".into(), String::new())); + assert_eq!(undo_labels(&s), ["Enter Cell", "test.editGroupThenFail"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "B1").is_empty()); + + // A group open before the command stays open: only the command's own group goes. + let token = s.group_begin("Outer").unwrap(); + set(&mut s, "D1", "outer"); + assert!(s.execute("test.groupThenFail", json!({})).is_err()); + assert!(s.in_group(uid), "the outer group is still running"); + assert_eq!((v(&s, "A1").display(), v(&s, "D1").display()), ("before".into(), "outer".into())); + assert!(s.group_end(token).unwrap()); + assert_eq!(undo_labels(&s), ["Enter Cell", "Outer"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty()); +} + +#[test] +fn stop_action_rolls_back_a_group_left_open() { + // A host that dropped its token, or a debugger the person wants to abandon: the message on + // Undo names the command that stops it, and the command puts the start back. + let mut s = s(); + set(&mut s, "A1", "start"); + set(&mut s, "A2", "undone"); + s.execute("edit.undo", json!({})).unwrap(); + let uid = s.doc().unwrap().uid; + let token = s.group_begin("Debug").unwrap(); + set(&mut s, "A1", "step 1"); + drop(s.group_begin("Inner").unwrap()); + set(&mut s, "A3", "step 2"); + let err = s.execute("edit.undo", json!({})).unwrap_err().to_string(); + assert!(err.contains("automate.stopAction"), "{err}"); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); + assert!(!s.in_group(uid)); + assert_eq!(v(&s, "A1").display(), "start"); + assert!(v(&s, "A3").is_empty()); + assert_eq!(undo_labels(&s), ["Enter Cell"], "stopping is not a step of its own"); + assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history comes back"); + assert!(s.group_end(token).is_err(), "the token is spent"); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": false})); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A2").display(), "undone"); + // Other documents keep their groups. + let other = s.new_workbook(); + let other_uid = s.documents()[other].uid; + let _kept = s.group_begin("Theirs").unwrap(); + s.set_active(0); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": false})); + assert!(s.in_group(other_uid)); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { diff --git a/docs/architecture.md b/docs/architecture.md index f51f3a61b..63119fe16 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -59,8 +59,12 @@ Actions made of many commands (macros, a debugger that runs one step per call) u closes its own groups still makes one undo step for itself, covering its edits inside and outside the groups. A command that ends a group open before it leaves the group its step and makes one of its own for what it changes after the end; a group a command opens and leaves open starts - where the command did. The history limit is applied when the outermost command finishes; until - then the steps made inside it are bounded on their own. + where the command did, but only when the command succeeds: a command that fails rolls back + every group it opened, as `group_rollback` does. `Session::abort_groups(doc)`, and the command + `automate.stopAction` on the active workbook, roll back the groups left open on a document (a + debugger the person abandons, a caller that dropped its token), so Undo works there again. The + history limit is applied when the outermost command finishes; until then the steps made inside + it are bounded on their own. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, and the buffer is independent of the journal @@ -103,6 +107,6 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| -| 2026-10-23 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | +| 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | From 2e72407fc68eb12ba2072e70d342279086c93960 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:51:37 -0400 Subject: [PATCH 088/166] Engine: the recorder skips a call that only opened a dialog A menu-style call with no parameters, such as `file.open`, `data.fromTextCsv` or `home.formatCells` with `{}`, only queues its dialog and succeeds. The recorder kept it as a step with empty parameters, and then kept the dialog's confirming call as well, so playing the recording back ran the command twice, or failed on the empty step when played back headless. Only `execute_headless` took the step back, and only for the open and save-as dialogs. `execute` now compares the queued UI requests before and after a top-level command and does not record one that queued a dialog. The special case in `execute_headless` goes (it keeps turning a missing file into an error), and with it `Recorder::truncate`, which had no other caller. --- crates/engine/src/lib.rs | 17 ++++++++--------- crates/engine/src/record.rs | 9 --------- crates/engine/src/tests/seams.rs | 22 ++++++++++++++++++++++ docs/architecture.md | 3 ++- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3b40d7401..90de44385 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -549,6 +549,7 @@ impl Session { None => (0, 0, String::new(), Selection::default()), }); let doc_uid = before.as_ref().map(|(uid, ..)| *uid); + let requests_before = self.ui_requests.len(); let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len)); self.marks.extend(marked); self.depth = self.depth.saturating_add(1); @@ -602,8 +603,11 @@ impl Session { } if spec.journal && result.is_ok() { // A command that started recording is not part of the recording; one that stopped it - // finds no recorder. - if let Some((doc, sheet, sheet_name, selection)) = place + // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no + // file, `home.formatCells` with no style) is not recorded either: the dialog's own + // call is, so playing the recording back runs the command once, with its parameters. + let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..)))); + if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog) && let Some(rec) = self.recorder.as_mut() { rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); @@ -878,17 +882,12 @@ impl Session { /// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no /// file yet, `data.getData`) is an error instead of a silent success. pub fn execute_headless(&mut self, id: &str, params: Json) -> Result { - let recorded = self.recorder.as_ref().map(|r| (r.steps.len(), r.dropped)); let r = self.execute(id, params); let requests = self.take_ui_requests(); let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); + // A call that only opened a dialog is not recorded (see `execute`), so the recording + // has no step to take back here. if r.is_ok() && asked_for_file { - // The caller gets an error, so the recording keeps no step for the command. - if let Some((steps, dropped)) = recorded - && let Some(rec) = self.recorder.as_mut() - { - rec.truncate(steps, dropped); - } let what = match id { "file.open" => "`path` or `base64`", "data.getData" | "data.fromTextCsv" => "`path` or `text`", diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 7206c21a3..7804be6c9 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -63,13 +63,4 @@ impl Recorder { self.dropped = self.dropped.saturating_add(1); } } - - /// Forgets the steps after the first `steps` and puts the dropped count back to `dropped`, - /// for a command the caller received as failed after it was recorded. - pub fn truncate(&mut self, steps: usize, dropped: usize) { - for step in self.steps.drain(steps.min(self.steps.len())..) { - self.bytes = self.bytes.saturating_sub(serde_json::to_vec(&step).map_or(0, |v| v.len())); - } - self.dropped = dropped; - } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 9d32c66d1..86db0622f 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -751,6 +751,28 @@ fn a_headless_call_that_fails_for_want_of_a_file_is_not_recorded() { assert_eq!(rec.dropped, 0); } +#[test] +fn a_call_that_only_opens_a_dialog_is_not_recorded() { + // Menu-style calls with no parameters only queue their dialog; the dialog's confirming call + // is the step, so playing the recording back runs each command once, with its parameters. + let mut s = s(); + s.start_recording(); + for id in ["file.open", "data.fromTextCsv", "home.formatCells"] { + s.execute(id, json!({})).unwrap_or_else(|e| panic!("{id}: {e}")); + let dialogs = s.take_ui_requests(); + assert!(dialogs.iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))), "{id} opens a dialog: {dialogs:?}"); + } + assert!(recorded_ids(&s).is_empty(), "{:?}", recorded_ids(&s)); + set(&mut s, "A1", "1"); + s.execute("home.formatCells", json!({"range": "A1", "style": {"bold": true}})).unwrap(); + assert_eq!(recorded_ids(&s), ["cell.set", "home.formatCells"]); + // A dialog queued before the call, and not taken yet, does not hide the call. + s.execute("file.open", json!({})).unwrap(); + set(&mut s, "A2", "2"); + assert_eq!(recorded_ids(&s), ["cell.set", "home.formatCells", "cell.set"]); + assert_eq!(s.stop_recording().unwrap().dropped, 0); +} + #[test] fn the_recorder_keeps_top_level_calls_with_where_they_ran() { let mut s = s(); diff --git a/docs/architecture.md b/docs/architecture.md index 63119fe16..f820998f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u it are bounded on their own. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. - Commands run by other commands are not recorded, and the buffer is independent of the journal + Commands run by other commands are not recorded, nor is a menu-style call that only opened a + dialog (the dialog's confirming call is the step), and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and `FromFile` for workbooks opened from a file, and for workbooks that received a paste from one From 0fa6e78863595069425af606f541fdeabd3ac898 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:53:38 -0400 Subject: [PATCH 089/166] Engine: stash items keep characters XML can't carry The xlsx writer drops characters XML 1.0 can't carry: controls other than tab, line feed and carriage return, and U+FFFE and U+FFFF. A stash item holding one lost it on save without a warning, and so did a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a stored macro parameter changed silently. `chunk_formulas` now writes each such character, and the marker itself, as the noncharacter U+FDD0 followed by four hex digits, and `get` turns them back for `_gc_` items. A marker that isn't followed by four hex digits naming a character is read as it is. An escape counts as five units against the 255-unit literal limit and is never split across literals, and the guard on the stored size allows the longest item even when every character is escaped. Legacy `_sc_` items are read unchanged. --- crates/engine/src/cmd/stash.rs | 110 ++++++++++++++++++++++++++++++--- docs/architecture.md | 6 +- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index bbef7f3ec..38a6a39fc 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -7,8 +7,16 @@ //! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep //! one name each, `_sc_`; reading accepts that form as well, so those items can move to this //! format later, and a write of the same key replaces it. +//! +//! XML can't carry every character (controls other than tab, line feed and carriage return, and +//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the +//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits +//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode +//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on +//! ordinary text. Legacy `_sc_` items are read as they are. use std::collections::BTreeMap; +use std::fmt::Write as _; use gridcraft_model::{DefinedName, Workbook}; use serde_json::Value as Json; @@ -25,6 +33,11 @@ const FORMULA_UNITS: usize = 8_192; const MAX_KEY: usize = 200; /// The longest item: 4,194,304 characters. pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; +/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character +/// takes seven (the three-byte marker and four digits). +const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7; +/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter. +pub const ESCAPE: char = '\u{FDD0}'; /// The item text stored under `key`, or `None` when there is none (or it is over the size limit). pub fn get(wb: &Workbook, key: &str) -> Option { @@ -49,14 +62,47 @@ pub fn get(wb: &Workbook, key: &str) -> Option { break; } out.push_str(&string_literals(f)); - if out.len() > MAX_ITEM_CHARS.saturating_mul(4) { + if out.len() > MAX_STORED_BYTES { return None; } } + out = unescape(&out); } (out.chars().count() <= MAX_ITEM_CHARS).then_some(out) } +/// Whether `c` is stored escaped: XML can't carry it, or it is the marker. +fn needs_escape(c: char) -> bool { + c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') +} + +/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four +/// hex digits naming a character (a hand-made name) stays as it is. +fn unescape(s: &str) -> String { + if !s.contains(ESCAPE) { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find(ESCAPE) { + out.push_str(rest.get(..at).unwrap_or("")); + let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or(""); + let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok()); + match code.and_then(char::from_u32) { + Some(c) => { + out.push(c); + rest = after.get(4..).unwrap_or(""); + } + None => { + out.push(ESCAPE); + rest = after; + } + } + } + out.push_str(rest); + out +} + /// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it. pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { check_key(key)?; @@ -153,7 +199,8 @@ fn check_key(key: &str) -> Result<()> { } /// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula -/// at most 8,192 characters. Concatenating [`string_literals`] of each gives `text` back. +/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]). +/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back. pub fn chunk_formulas(text: &str) -> Vec { let mut formulas = Vec::new(); let (mut formula, mut units) = (String::new(), 0usize); @@ -175,14 +222,22 @@ pub fn chunk_formulas(text: &str) -> Vec { *lit_units = 0; }; for ch in text.chars() { - if lit_units + ch.len_utf16() > LITERAL_UNITS { + // An escaped character is the marker and four digits, kept in one literal. + let escaped = needs_escape(ch); + let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() }; + if lit_units + cost > LITERAL_UNITS { flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); } - lit_units += ch.len_utf16(); - if ch == '"' { - literal.push('"'); + lit_units += cost; + if escaped { + literal.push(ESCAPE); + let _ = write!(literal, "{:04X}", u32::from(ch)); + } else { + if ch == '"' { + literal.push('"'); + } + literal.push(ch); } - literal.push(ch); } if lit_units > 0 { flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); @@ -403,5 +458,46 @@ mod tests { assert!(warnings.is_empty(), "{warnings:?}"); assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str())); assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"]))); + + // Characters XML can't carry, and the escape marker itself, come back too, in text and + // in JSON (which leaves U+FFFE and U+FFFF unescaped). + let mut wb = Workbook::new(); + let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect(); + let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect(); + set(&mut wb, "odd", &text).unwrap(); + excel_limits_hold(&wb); + let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"}); + set_json(&mut wb, "json", &json).unwrap(); + // All escapes: each literal holds whole escapes, within the limits. + let controls = "\u{1}".repeat(9_000); + set(&mut wb, "controls", &controls).unwrap(); + excel_limits_hold(&wb); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str())); + assert_eq!(get_json(&back, "json"), Some(json)); + assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str())); + } + + #[test] + fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() { + // Excel's limits hold however many characters are escaped, and the longest item of + // characters that all need escaping still reads. + for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] { + let text = "\u{2}".repeat(len); + let fs = chunk_formulas(&text); + assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::()), text, "{len}"); + assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}"); + } + let mut wb = Workbook::new(); + let longest = "\u{0}".repeat(MAX_ITEM_CHARS); + set(&mut wb, "longest", &longest).unwrap(); + excel_limits_hold(&wb); + assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS)); + // A marker in a hand-made name that is not an escape stays as it is. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\"")); + assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}")); } } diff --git a/docs/architecture.md b/docs/architecture.md index f820998f1..d900be62d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,8 +80,10 @@ Actions made of many commands (macros, a debugger that runs one step per call) u per-session consent for stored `automate.*` scripts (`automate.trustScripts`). - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and - 8,192-character limits, so it survives an xlsx round trip. The older single-name `_sc_` - items are read as well. + 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls + other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter + U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. The older + single-name `_sc_` items are read as well. ## Agent control From b90710794a622fc25b4d848f02311d10deefbc48 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:55:07 -0400 Subject: [PATCH 090/166] Engine: list stash items in one pass over the names Each `stash::get` looks at every defined name, so listing the items with `keys` and then reading each with `get` took time quadratic in the number of names. A file with 20,000 hidden `_gc_macro__0` names took about ten seconds in a debug build, and the macro list reads its items this way from data that comes from the file. `stash::items(wb, prefix)` now groups the names by item in one pass and returns each item's text by key, and `keys` lists from the same pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys in sorted order, so a file with any number of hidden names can't make a listing unbounded. A test counts the names each listing looks at. --- crates/engine/src/cmd/stash.rs | 136 ++++++++++++++++++++++++++++----- docs/architecture.md | 5 +- 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 38a6a39fc..630e84ab6 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -38,23 +38,87 @@ pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7; /// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter. pub const ESCAPE: char = '\u{FDD0}'; +/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A +/// file can carry any number of hidden names; the rest are not listed. +pub const MAX_ITEMS: usize = 10_000; + +/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its +/// legacy `_sc_` name. +#[derive(Default)] +struct Parts<'a> { + chunks: BTreeMap, + legacy: Option<&'a str>, +} + +impl<'a> Parts<'a> { + fn add(&mut self, chunk: Option, formula: &'a str) { + match chunk { + Some(i) => { + self.chunks.entry(i).or_insert(formula); + } + None => self.legacy = self.legacy.or(Some(formula)), + } + } + + /// Whether the names make an item: a later chunk on its own does not. + fn is_item(&self) -> bool { + self.chunks.contains_key(&0) || self.legacy.is_some() + } +} + +#[cfg(test)] +thread_local! { + /// Names [`group`] has looked at on this thread, so a test can bound the work of a listing. + static NAME_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names +/// that hold them, in one pass over the names. +fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap> { + let prefix = prefix.to_ascii_lowercase(); + let mut items: BTreeMap> = BTreeMap::new(); + for n in wb.names.iter() { + #[cfg(test)] + NAME_VISITS.with(|v| v.set(v.get().saturating_add(1))); + let Some((k, chunk)) = item_of(n) else { continue }; + let k = k.to_ascii_lowercase(); + if k.starts_with(&prefix) { + items.entry(k).or_default().add(chunk, &n.formula); + } + } + items +} /// The item text stored under `key`, or `None` when there is none (or it is over the size limit). pub fn get(wb: &Workbook, key: &str) -> Option { - let mut chunks: BTreeMap = BTreeMap::new(); - let mut legacy = None; + let mut parts = Parts::default(); for n in wb.names.iter() { - match item_of(n) { - Some((k, Some(i))) if k.eq_ignore_ascii_case(key) => { - chunks.entry(i).or_insert(&n.formula); - } - Some((k, None)) if k.eq_ignore_ascii_case(key) => legacy = legacy.or(Some(&n.formula)), - _ => {} + if let Some((k, chunk)) = item_of(n) + && k.eq_ignore_ascii_case(key) + { + parts.add(chunk, &n.formula); } } + assemble(&parts) +} + +/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass +/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit. +pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap { + group(wb, prefix) + .into_iter() + .filter(|(_, parts)| parts.is_item()) + .take(MAX_ITEMS) + .filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text))) + .collect() +} + +/// The text of the item held by `parts`, as [`get`] reads it. +fn assemble(parts: &Parts) -> Option { + let chunks = &parts.chunks; let mut out = String::new(); if !chunks.contains_key(&0) { - out = string_literals(legacy?); + out = string_literals(parts.legacy?); } else { // Chunks run 0, 1, 2, …; anything after a gap isn't part of the item. for (want, (i, f)) in chunks.iter().enumerate() { @@ -144,18 +208,11 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> { set(wb, key, &v.to_string()) } -/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted. +/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted +/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass +/// where a [`get`] per key would take one each. pub fn keys(wb: &Workbook, prefix: &str) -> Vec { - let mut keys: Vec = wb - .names - .iter() - // A later chunk on its own is no item, as `get` reads it. - .filter_map(|n| item_of(n).filter(|(_, chunk)| chunk.is_none_or(|i| i == 0)).map(|(k, _)| k.to_ascii_lowercase())) - .filter(|k| k.starts_with(&prefix.to_ascii_lowercase())) - .collect(); - keys.sort(); - keys.dedup(); - keys + group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect() } /// The item a hidden name belongs to, lower-cased: `_gc__` and `_sc_` give ``. @@ -447,6 +504,45 @@ mod tests { assert_eq!(item_key("Total"), None); } + #[test] + fn listing_many_items_takes_one_pass_and_is_capped() { + // 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each + // key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS. + let mut wb = Workbook::new(); + let total = 20_000; + for i in 0..total { + wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\""))); + } + set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap(); + wb.names.push(name("_sc_macro_legacy", "\"old\"")); + wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\"")); + wb.names.push(name("_gc_other_0", "\"not a macro\"")); + NAME_VISITS.with(|v| v.set(0)); + let all = items(&wb, "MACRO_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names"); + assert_eq!(all.len(), MAX_ITEMS); + let first: Vec<&String> = all.keys().take(2).collect(); + assert_eq!(first, ["macro_00000", "macro_00001"]); + assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999")); + assert!(!all.contains_key("macro_10000"), "past the cap"); + NAME_VISITS.with(|v| v.set(0)); + let listed = keys(&wb, "macro_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len()); + assert_eq!(listed, all.keys().cloned().collect::>(), "keys and items agree"); + + // Under the cap every item is there, read as `get` reads it. + wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1")); + let few = items(&wb, "macro_"); + let want: BTreeMap = + [("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect(); + assert_eq!(few, want); + for (k, text) in &few { + assert_eq!(get(&wb, k).as_ref(), Some(text)); + } + assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]); + assert_eq!(items(&wb, "").len(), 3, "the other item too"); + } + #[test] fn items_round_trip_through_xlsx() { let mut wb = Workbook::new(); diff --git a/docs/architecture.md b/docs/architecture.md index d900be62d..2e80f5606 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,8 +82,9 @@ Actions made of many commands (macros, a debugger that runs one step per call) u workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter - U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. The older - single-name `_sc_` items are read as well. + U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` + and `stash::keys` list the items under a key prefix in one pass over the names, at most + 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. ## Agent control From 4668fc30ae985bdc028371a84f496ea863276498 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:57:14 -0400 Subject: [PATCH 091/166] Engine: the name commands leave GridCraft's data names alone `formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden chunk of a stored item with a visible name: the item vanished from `stash::get` and `stash::keys`, and its other chunks stayed behind as orphan hidden names. `formulas.deleteName` could remove chunks the same way, and `formulas.createFromSelection` with `replace` could replace one from a cell label. `stash::set` refused a clash with a visible name, but nothing guarded the other direction. Defining, renaming or deleting a name that `stash::item_key` recognises (`_gc__` and legacy `_sc_`) is now refused with an error that names `force: true`, which lets the call go ahead. Create from Selection lists such labels in `skipped` unless `force` is true. The params docs of the three commands say so. --- crates/engine/src/cmd/formulas.rs | 75 +++++++++++++++++++-- crates/engine/tests/it/stored_list_shape.rs | 3 +- docs/architecture.md | 2 + 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5334784fa..c5f8a8bf8 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -32,18 +32,26 @@ pub fn specs() -> Vec { "Define Name…", ["Formulas", "Defined Names"], None, - "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)", + "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", has_doc, define_name ), - cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name), + cmd!( + "formulas.deleteName", + "Delete Name", + [], + None, + "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", + has_doc, + delete_name + ), cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager), cmd!( "formulas.createFromSelection", "Create from Selection", ["Formulas", "Defined Names"], Some("Cmd+Shift+F3"), - "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}", + "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc__` and `_sc_`, are listed in `skipped` unless force is true)}", has_doc, create_from_selection ), @@ -268,6 +276,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result { if !valid_name(&name) { return Err(EngineError::Other("The name that you entered is not valid.".into())); } + for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() { + refuse_stash_name(p, n)?; + } let d = s.doc()?; let scope = match str_param(p, "scope") { None | Some("Workbook") => None, @@ -374,8 +385,21 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc } } +/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]): +/// defining, renaming or deleting one would replace or remove part of an item (a stored macro, +/// script or view) and leave the rest behind. `force: true` lets the call go ahead. +fn refuse_stash_name(p: &Json, name: &str) -> Result<()> { + if stash::item_key(name).is_some() && !bool_param(p, "force").unwrap_or(false) { + return Err(EngineError::Other(format!( + "'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway." + ))); + } + Ok(()) +} + fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); + refuse_stash_name(p, &name)?; let scope = match str_param(p, "scope") { None => None, Some("Workbook") => Some(None), @@ -461,7 +485,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and // reported. let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); - let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n)); + // A label of the shape GridCraft keeps its own data in would replace part of an item, or + // stand in the way of one (see `refuse_stash_name`). + let force = bool_param(p, "force").unwrap_or(false); + let ours = |n: &str| !force && stash::item_key(n).is_some(); + let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n))); let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); edit(s, |cx| { @@ -742,6 +770,45 @@ mod tests { assert_eq!(r["refersTo"], "=Sheet1!$A$1"); } + #[test] + fn the_name_commands_leave_gridcraft_data_alone() { + // Defining, renaming or deleting a name of a stored item's shape would replace or remove + // one of its chunks and leave the rest behind: refused unless `force` is true. + let mut s = Session::new(); + s.new_workbook(); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + let text = "m".repeat(9_000); + stash::set(wb, "macro_1", &text).unwrap(); + wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true }); + let names = |s: &Session| s.doc().unwrap().wb.names.clone(); + let before = names(&s); + assert!(before.len() >= 3, "the item takes two chunks"); + for params in [ + json!({"name": "_gc_macro_1_0", "refersTo": "=1"}), + json!({"name": "_SC_Watch", "refersTo": "=1"}), + json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}), + json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}), + ] { + let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string(); + assert!(err.contains("force"), "{params}: {err}"); + } + for name in ["_gc_macro_1_1", "_sc_watch"] { + let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string(); + assert!(err.contains("force"), "{name}: {err}"); + } + s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap(); + let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap(); + assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"]))); + let after = names(&s); + assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::>(), before); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str())); + // Ordinary names and forced calls go ahead. + s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap(); + assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1")); + } + #[test] fn arabic_defined_names_count_characters_not_bytes() { let mut s = Session::new(); diff --git a/crates/engine/tests/it/stored_list_shape.rs b/crates/engine/tests/it/stored_list_shape.rs index 0d38cd5f9..fd17a4806 100644 --- a/crates/engine/tests/it/stored_list_shape.rs +++ b/crates/engine/tests/it/stored_list_shape.rs @@ -6,7 +6,8 @@ const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\ fn with_name(name: &str, refers_to: &str) -> Session { let mut s = Session::new(); s.new_workbook(); - s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap(); + // `force`: the name commands refuse GridCraft's own data names otherwise. + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to, "force": true})).unwrap(); s } diff --git a/docs/architecture.md b/docs/architecture.md index 2e80f5606..3fc6cb0b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,6 +85,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` and `stash::keys` list the items under a key prefix in one pass over the names, at most 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. + `formulas.defineName` and `formulas.deleteName` refuse names of either shape, and + `formulas.createFromSelection` skips labels of either shape, unless `force: true` is passed. ## Agent control From 37bf09e94a1c45042958c0d0b121519f6cf13156 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:59:45 -0400 Subject: [PATCH 092/166] Engine: queries take no copy of the undo history `execute` copied the document's undo and redo history on every call, so a panic could put it back: up to 100 entries per stack, each with a label and a selection. Queries paid for it too, although no query runs a command or changes the history, and front ends and agents run queries all the time. The copy is now skipped for queries (commands that neither journal nor make undo steps). A test runs every query, with empty and with typical parameters, and checks the history is unchanged, so a query that ever changes it fails there. Commands that can change the history, nested ones included, still take the copy: a nested command that panics is restored at its own level, and the outer command can carry on, so relying on the outermost copy would weaken that restore. --- crates/engine/src/lib.rs | 11 ++++++-- crates/engine/src/tests.rs | 5 ++++ crates/engine/src/tests/seams.rs | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 90de44385..5c6e9f565 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -534,8 +534,15 @@ impl Session { let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); // A command that panics leaves the history as it found it: the steps commands nested in it // made or undid, and the redo history they cleared, come back. A length is not enough, - // because a nested `edit.undo` removes entries. - let history_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))); + // because a nested `edit.undo` removes entries. A query runs no commands and leaves the + // history alone (`every_query_leaves_the_history_alone` runs each one), so it needs no + // copy; front ends run queries all the time. + let query = !spec.journal && !spec.undoable; + let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { + #[cfg(test)] + tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1))); + self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone())) + }); // The groups open on the document before the command (usually none), for the same reason. let groups_before: Vec<(u64, String, Arc, Selection)> = match &before { Some((uid, ..)) => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4f2fc91bb..1be90d56c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3,6 +3,11 @@ use serde_json::json; use crate::Session; +thread_local! { + /// Copies of a document's undo and redo history `execute` has taken on this thread. + pub(crate) static HISTORY_COPIES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn s() -> Session { let mut s = Session::new(); s.new_workbook(); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 86db0622f..cab24426e 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -732,6 +732,53 @@ fn stop_action_rolls_back_a_group_left_open() { assert!(s.in_group(other_uid)); } +fn history_of(s: &Session) -> Vec<(String, *const gridcraft_model::Workbook)> { + let d = s.doc().unwrap(); + d.undo.iter().chain(d.redo.iter()).map(|e| (e.label.clone(), std::sync::Arc::as_ptr(&e.wb))).collect() +} + +#[test] +fn queries_take_no_copy_of_the_history() { + // `execute` copies the history so a panic can put it back; a query leaves it alone, so the + // copy is skipped for the queries front ends run all the time. + let mut s = s(); + for i in 0..50 { + set(&mut s, "A1", &i.to_string()); + } + s.execute("edit.undo", json!({})).unwrap(); + super::HISTORY_COPIES.with(|n| n.set(0)); + for _ in 0..100 { + s.execute("history.list", json!({})).unwrap(); + s.execute("cell.get", json!({"cell": "A1"})).unwrap(); + s.execute("document.inspect", json!({})).unwrap(); + } + assert_eq!(super::HISTORY_COPIES.with(|n| n.get()), 0); + set(&mut s, "A2", "x"); + assert_eq!(super::HISTORY_COPIES.with(|n| n.get()), 1, "a command that can change it still copies it"); +} + +#[test] +fn every_query_leaves_the_history_alone() { + // What lets a query skip the copy: none of them changes the undo or redo history. + let mut s = Session::new(); + s.execute("file.new", json!({"sample": "sales"})).unwrap(); + set(&mut s, "A1", "1"); + set(&mut s, "A1", "2"); + s.execute("edit.undo", json!({})).unwrap(); + let before = history_of(&s); + assert_eq!(before.len(), 2); + let mut queries = 0; + for spec in crate::command_specs().iter().filter(|c| !c.journal && !c.undoable) { + queries += 1; + for params in [json!({}), json!({"range": "A1:B3", "cell": "A1", "sheet": 0}), json!([1, 2])] { + let _ = s.execute(spec.id, params.clone()); + s.set_active(0); + assert_eq!(history_of(&s), before, "{} {params}", spec.id); + } + } + assert!(queries > 40, "{queries}"); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { From 9bbba198c6263adc1e448909bf8fa07e445c5ed5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:01:45 -0400 Subject: [PATCH 093/166] Engine: a document's maker says where its content came from `Trust` defaulted to `Local`, and `DocState::new` set it, so a document was trusted unless its maker remembered to mark it. Every current path that reads a file goes through `file.open`, which marks it `FromFile`, but a later way of building a document from bytes (recovery, templates, a new import) would have come out trusted by omission. `DocState::new` now takes the `Trust`, and `Trust` has no default. New workbooks and samples pass `Local`, `file.open` passes `FromFile`, a new window passes the trust of its workbook, and the tests that build documents from bytes pass `FromFile`. --- crates/engine/src/cmd/extra.rs | 3 +-- crates/engine/src/cmd/file.rs | 7 +++---- crates/engine/src/lib.rs | 16 ++++++++++------ crates/engine/src/sample.rs | 2 +- crates/engine/src/tests/seams.rs | 16 ++++++++++++++++ crates/engine/tests/it/dynamicmetadata.rs | 2 +- crates/engine/tests/it/long_operator_chains.rs | 2 +- crates/ui-egui/tests/it/interaction.rs | 2 +- crates/ui-egui/tests/it/scroll_position.rs | 2 +- docs/architecture.md | 15 ++++++++------- 10 files changed, 43 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 6197dd5e2..009d77f2a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -809,10 +809,9 @@ fn split(s: &mut Session, _: &Json) -> Result { fn new_window(s: &mut Session, _: &Json) -> Result { let d = s.doc()?.clone(); - let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title())); + let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust); copy.selection = d.selection.clone(); copy.scripts_trusted = d.scripts_trusted; - copy.trust = d.trust; let i = s.add_document(copy); Ok(json!({"index": i})) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7aee50921..b3e7309e2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result { Some(name) => { let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?; let title = crate::sample::title(name); - s.add_document(DocState::new(wb, None, title)) + s.add_document(DocState::new(wb, None, title, crate::Trust::Local)) } None => s.new_workbook(), }; @@ -83,12 +83,11 @@ fn open(s: &mut Session, p: &Json) -> Result { { s.close_document(0); } - let mut doc = DocState::new(wb, path, title); + // Someone else may have made the file: its active content asks before it runs. + let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile); if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { doc.content_kind = Some(crate::io::FileKind::Xlsx); } - // Someone else may have made the file: its active content asks before it runs. - doc.trust = crate::Trust::FromFile; let i = s.add_document(doc); // The decrypted-workbook notice is for the info bar, not a message box. let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5c6e9f565..1fd2f267b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -134,12 +134,13 @@ pub struct Clipboard { pub trust: Trust, } -/// Whether a document's active content (macros) may run without asking the person first. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +/// Whether a document's active content (macros) may run without asking the person first. There +/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new +/// way of building one from bytes can't come out trusted by omission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum Trust { /// Made in this session: a new workbook or a sample. - #[default] Local, /// Opened from a file, or holding content pasted from one: active content asks before it /// runs. @@ -259,7 +260,10 @@ fn trim_to_budget(history: &mut Vec, byte_budget: usize) { } impl DocState { - pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { + /// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything + /// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook + /// made in this session. + pub fn new(mut wb: Workbook, path: Option, title: String, trust: Trust) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); let scripts_trusted = !cmd::extra::has_scripts(&wb); @@ -280,7 +284,7 @@ impl DocState { unlocked_ranges: vec![], content_kind: None, scripts_trusted, - trust: Trust::Local, + trust, } } pub fn is_dirty(&self) -> bool { @@ -480,7 +484,7 @@ impl Session { let name = wb.next_sheet_name(); wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name))); } - self.add_document(DocState::new(wb, None, format!("Book{n}"))) + self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local)) } /// Journals a command, then trims the oldest entries past either cap. A recording in progress diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 129fe2171..90029bac3 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option { _ => return None, }; } - s.add_document(DocState::new(wb, None, title(name))); + s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local)); let ok = match name { "budget" => budget(&mut s), "sales" => sales(&mut s), diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index cab24426e..b445377b7 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -961,6 +961,22 @@ fn documents_know_where_they_came_from() { assert_eq!(trust(&s), Trust::FromFile); } +#[test] +fn whoever_makes_a_document_says_where_it_came_from() { + // No default trust: a document built from bytes outside `file.open` (a recovery copy, a + // template) is untrusted because its maker says so, not trusted because it forgot to. + let mut s = Session::new(); + let bytes = gridcraft_xlsx::write_xlsx(&gridcraft_model::Workbook::new()).unwrap(); + let (wb, _) = crate::io::open_bytes("recovered.xlsx", &bytes).unwrap(); + s.add_document(crate::DocState::new(wb, None, "Recovered".into(), Trust::FromFile)); + assert_eq!(trust(&s), Trust::FromFile); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["trust"], "fromFile"); + s.add_document(crate::DocState::new(gridcraft_model::Workbook::new(), None, "Made here".into(), Trust::Local)); + assert_eq!(trust(&s), Trust::Local); + s.new_workbook(); + assert_eq!(trust(&s), Trust::Local); +} + #[test] fn pasting_from_a_file_carries_its_trust() { let mut s = s(); // document 0: Local diff --git a/crates/engine/tests/it/dynamicmetadata.rs b/crates/engine/tests/it/dynamicmetadata.rs index e1edfc958..f88805476 100644 --- a/crates/engine/tests/it/dynamicmetadata.rs +++ b/crates/engine/tests/it/dynamicmetadata.rs @@ -26,7 +26,7 @@ fn opened() -> Session { let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap(); let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap(); let mut s = Session::new(); - s.add_document(DocState::new(back, None, "dynamic".into())); + s.add_document(DocState::new(back, None, "dynamic".into(), gridcraft_engine::Trust::FromFile)); s } diff --git a/crates/engine/tests/it/long_operator_chains.rs b/crates/engine/tests/it/long_operator_chains.rs index 97c95ce38..e5e5af4b7 100644 --- a/crates/engine/tests/it/long_operator_chains.rs +++ b/crates/engine/tests/it/long_operator_chains.rs @@ -78,7 +78,7 @@ fn longest_flat_chains_survive_editing_saving_and_reopening() { let bytes = io::save_bytes(wb, "chains.xlsx").expect("save"); let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open"); let mut session = Session::new(); - session.add_document(DocState::new(reopened, None, "chains".into())); + session.add_document(DocState::new(reopened, None, "chains".into(), gridcraft_engine::Trust::FromFile)); session.execute("formulas.calculateNow", json!({})).expect("recalc"); assert_eq!(value(&session, "C2"), Value::Number(4096.0)); assert_eq!(value(&session, "C3"), Value::Number(-4094.0)); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 46e981d0a..08317aad3 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -84,7 +84,7 @@ fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() { // Column A frozen: a page is the scrolling part only. sh.freeze = Some((0, 1)); let mut s = Session::new(); - s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into())); + s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local)); let mut h = harness(s); let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar"); assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}"); diff --git a/crates/ui-egui/tests/it/scroll_position.rs b/crates/ui-egui/tests/it/scroll_position.rs index 7945eb344..bcf62b1ac 100644 --- a/crates/ui-egui/tests/it/scroll_position.rs +++ b/crates/ui-egui/tests/it/scroll_position.rs @@ -18,7 +18,7 @@ fn harness(freeze: Option<(u32, u32)>) -> Harness { sh.view_active = CellRef::parse("A31").unwrap(); sh.freeze = freeze; let mut session = Session::new(); - session.add_document(DocState::new(wb, None, "Book1".into())); + session.add_document(DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local)); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); diff --git a/docs/architecture.md b/docs/architecture.md index 3fc6cb0b1..2a5bed44a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,13 +71,14 @@ Actions made of many commands (macros, a debugger that runs one step per call) u dialog (the dialog's confirming call is the step), and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and - `FromFile` for workbooks opened from a file, and for workbooks that received a paste from one - that writes formulas or validation: Paste, Paste Special › All, All Except Borders, Formulas, - Formulas and Number Formats or Validation, and any linked paste other than column widths or - notes. Values, formats, column widths and notes carry nothing active. Active content in a - `FromFile` workbook asks before it runs. This - is provenance, which no decision changes; `DocState::scripts_trusted` is the separate - per-session consent for stored `automate.*` scripts (`automate.trustScripts`). + `FromFile` for workbooks opened from a file. `Trust` has no default: `DocState::new` takes it, + so every way of making a document says where its content came from. It is also `FromFile` for + workbooks that received a paste from one that writes formulas or validation: Paste, Paste + Special › All, All Except Borders, Formulas, Formulas and Number Formats or Validation, and any + linked paste other than column widths or notes. Values, formats, column widths and notes carry + nothing active. Active content in a `FromFile` workbook asks before it runs. This is provenance, + which no decision changes; `DocState::scripts_trusted` is the separate per-session consent for + stored `automate.*` scripts (`automate.trustScripts`). - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls From b44a5139ab66bb8b92d0d790c629b29e456035a1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:19:07 -0400 Subject: [PATCH 094/166] Engine: the name commands refuse only names that hold GridCraft's data The guard refused any name of the stash's shape, so a visible name a person had defined, such as _gc_rate_0, could not be edited or deleted from Name Manager, which cannot pass force. It now refuses a name only when the workbook holds a hidden workbook-scope item under it; a visible name of the shape is the person's own, as the stash itself treats it. The test covers such a name. The query comment says what the copy skip relies on: a query runs nothing that changes the history. --- crates/engine/src/cmd/formulas.rs | 36 ++++++++++++++++++++++--------- crates/engine/src/lib.rs | 4 ++-- docs/architecture.md | 5 +++-- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index c5f8a8bf8..b3f79e81a 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,7 +1,7 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. use gridcraft_core::{CellError, CellRef, RangeRef, Value}; -use gridcraft_model::{CalcMode, DefinedName, Formula}; +use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook}; use serde_json::{Value as Json, json}; use super::*; @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Define Name…", ["Formulas", "Defined Names"], None, - "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", + "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", has_doc, define_name ), @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Delete Name", [], None, - "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", + "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", has_doc, delete_name ), @@ -277,7 +277,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("The name that you entered is not valid.".into())); } for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() { - refuse_stash_name(p, n)?; + refuse_stash_name(&s.doc()?.wb, p, n)?; } let d = s.doc()?; let scope = match str_param(p, "scope") { @@ -388,8 +388,15 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc /// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]): /// defining, renaming or deleting one would replace or remove part of an item (a stored macro, /// script or view) and leave the rest behind. `force: true` lets the call go ahead. -fn refuse_stash_name(p: &Json, name: &str) -> Result<()> { - if stash::item_key(name).is_some() && !bool_param(p, "force").unwrap_or(false) { +/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope +/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and +/// the name commands may change it. +fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool { + wb.names.iter().any(|n| n.hidden && n.scope.is_none() && n.name.eq_ignore_ascii_case(name) && stash::item_key(&n.name).is_some()) +} + +fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> { + if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) { return Err(EngineError::Other(format!( "'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway." ))); @@ -399,7 +406,7 @@ fn refuse_stash_name(p: &Json, name: &str) -> Result<()> { fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); - refuse_stash_name(p, &name)?; + refuse_stash_name(&s.doc()?.wb, p, &name)?; let scope = match str_param(p, "scope") { None => None, Some("Workbook") => Some(None), @@ -485,10 +492,10 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and // reported. let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); - // A label of the shape GridCraft keeps its own data in would replace part of an item, or - // stand in the way of one (see `refuse_stash_name`). + // A label that names one of GridCraft's own hidden data names would replace part of an item + // (see `refuse_stash_name`). let force = bool_param(p, "force").unwrap_or(false); - let ours = |n: &str| !force && stash::item_key(n).is_some(); + let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n); let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n))); let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); @@ -802,6 +809,15 @@ mod tests { let after = names(&s); assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::>(), before); assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str())); + // A visible name of the shape is the person's own (the stash never reads it): the name + // commands change and remove it without `force`, as Name Manager cannot pass one. + let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false }; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone()); + s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own); + s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap(); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched"); // Ordinary names and forced calls go ahead. s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap(); s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1fd2f267b..b4ab60f3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -538,8 +538,8 @@ impl Session { let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); // A command that panics leaves the history as it found it: the steps commands nested in it // made or undid, and the redo history they cleared, come back. A length is not enough, - // because a nested `edit.undo` removes entries. A query runs no commands and leaves the - // history alone (`every_query_leaves_the_history_alone` runs each one), so it needs no + // because a nested `edit.undo` removes entries. A query runs nothing that changes the + // history (`every_query_leaves_the_history_alone` runs each one), so it needs no // copy; front ends run queries all the time. let query = !spec.journal && !spec.undoable; let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { diff --git a/docs/architecture.md b/docs/architecture.md index 2a5bed44a..bfbf2b8c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,8 +86,9 @@ Actions made of many commands (macros, a debugger that runs one step per call) u U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` and `stash::keys` list the items under a key prefix in one pass over the names, at most 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. - `formulas.defineName` and `formulas.deleteName` refuse names of either shape, and - `formulas.createFromSelection` skips labels of either shape, unless `force: true` is passed. + `formulas.defineName` and `formulas.deleteName` refuse a name the workbook holds such an item + under (a hidden workbook-scope name of either shape; a visible one is the person's own), and + `formulas.createFromSelection` skips such labels, unless `force: true` is passed. ## Agent control From b09a5502e3a7a092561d13aaa262881b7f3d05c2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:30:15 -0400 Subject: [PATCH 095/166] UI: the toolbar's Undo and Redo follow the commands' own availability The ribbon enabled them from the history lengths alone, so while an undo group was open on the document (an action spanning several calls) a click ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and cmd::can_redo, the undo list is empty while a group is open, and the two buttons carry a name and their enabled state for assistive technology. A UI test opens a group, clicks both and checks the history and their state. --- crates/ui-egui/src/ribbon.rs | 22 +++++-- crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/undo_while_grouping.rs | 57 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/it/undo_while_grouping.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 45856565f..ab9cc311d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -74,15 +74,23 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { { app.run_or_alert("file.save", json!({})); } - let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); - let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty()); - if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)).clicked() && can_undo { + // The commands' own availability: there must be a step, and no undo group may be open on + // the document (an action that spans several calls is still running). + let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok(); + let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok(); + // Named and marked enabled or not for assistive technology (and the UI tests). + let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)); + undo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_undo, "Undo")); + if undo.clicked() && can_undo { app.run_or_alert("edit.undo", json!({})); } let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); egui::Popup::menu(&undo_list).show(|ui| { - let labels: Vec = - app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default(); + let labels: Vec = if can_undo { + app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default() + } else { + Vec::new() + }; if labels.is_empty() { ui.label("Can't Undo"); } @@ -92,7 +100,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)).clicked() && can_redo { + let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)); + redo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_redo, "Redo")); + if redo.clicked() && can_redo { app.run_or_alert("edit.redo", json!({})); } let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index fc00fb520..b9725aed0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -85,6 +85,7 @@ mod touch_pan; mod trace_navigation; mod translated_form_fields; mod ui_clock; +mod undo_while_grouping; mod validation_alert; mod validation_input_message; mod wheel_zoom; diff --git a/crates/ui-egui/tests/it/undo_while_grouping.rs b/crates/ui-egui/tests/it/undo_while_grouping.rs new file mode 100644 index 000000000..fdc98ebf9 --- /dev/null +++ b/crates/ui-egui/tests/it/undo_while_grouping.rs @@ -0,0 +1,57 @@ +//! Undo and Redo on the Quick Access Toolbar follow the commands' own availability: while an undo +//! group is open on the document (an action that spans several calls is still running), both +//! buttons are off, and a click neither runs the command nor raises its refusal. + +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn history(h: &Harness) -> (usize, usize) { + let d = h.state().session.active().unwrap(); + (d.undo.len(), d.redo.len()) +} + +#[test] +fn undo_and_redo_wait_while_an_undo_group_is_open() { + let mut h = harness(); + assert_eq!(history(&h), (1, 1)); + let token = h.state_mut().session.group_begin("Long action").unwrap(); + h.run_steps(2); + for button in ["Undo", "Redo"] { + assert!(h.get_by_label(button).accesskit_node().is_disabled(), "{button} is off while the group is open"); + h.get_by_label(button).click(); + h.run_steps(2); + assert!(h.state().message.is_none(), "{button}: no refusal is raised while the group is open"); + assert_eq!(history(&h), (1, 1), "{button}: the history is as it was"); + } + h.state_mut().session.group_end(token).unwrap(); + h.run_steps(2); + assert!(!h.get_by_label("Undo").accesskit_node().is_disabled() && !h.get_by_label("Redo").accesskit_node().is_disabled()); + // With the group ended, the buttons work again. + h.get_by_label("Undo").click(); + h.run_steps(2); + assert_eq!(history(&h), (0, 2)); + h.get_by_label("Redo").click(); + h.run_steps(2); + assert_eq!(history(&h), (1, 1)); +} From 4a294aefa9fb496ccd55ee6f93bd51f915044e88 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:41:03 -0400 Subject: [PATCH 096/166] Engine: the name commands use the stash's own rule for what holds its data The guard asked for a hidden name, but the stash reads a legacy _sc_ name whether it is hidden or not, so a visible one could be removed or replaced without force. stash::holds_item is the one rule (item_of), shared by the guard; the test covers a visible legacy name. --- crates/engine/src/cmd/formulas.rs | 8 ++++++-- crates/engine/src/cmd/stash.rs | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index b3f79e81a..7718279b2 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -392,7 +392,8 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc /// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and /// the name commands may change it. fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool { - wb.names.iter().any(|n| n.hidden && n.scope.is_none() && n.name.eq_ignore_ascii_case(name) && stash::item_key(&n.name).is_some()) + // The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not. + stash::holds_item(wb, name) } fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> { @@ -787,6 +788,8 @@ mod tests { let text = "m".repeat(9_000); stash::set(wb, "macro_1", &text).unwrap(); wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true }); + // A legacy item is read whether or not it is hidden, so a visible one is data too. + wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false }); let names = |s: &Session| s.doc().unwrap().wb.names.clone(); let before = names(&s); assert!(before.len() >= 3, "the item takes two chunks"); @@ -795,11 +798,12 @@ mod tests { json!({"name": "_SC_Watch", "refersTo": "=1"}), json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}), json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}), + json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}), ] { let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string(); assert!(err.contains("force"), "{params}: {err}"); } - for name in ["_gc_macro_1_1", "_sc_watch"] { + for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] { let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string(); assert!(err.contains("force"), "{name}: {err}"); } diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 630e84ab6..c0e8302ca 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -215,6 +215,13 @@ pub fn keys(wb: &Workbook, prefix: &str) -> Vec { group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect() } +/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope +/// `_gc__` name, or a workbook-scope legacy `_sc_` name, which is read whether or not +/// it is hidden. A visible `_gc_` name is the person's own. +pub fn holds_item(wb: &Workbook, name: &str) -> bool { + wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some()) +} + /// The item a hidden name belongs to, lower-cased: `_gc__` and `_sc_` give ``. pub fn item_key(name: &str) -> Option { parse_name(name).map(|(k, _)| k.to_ascii_lowercase()) From 3b25aa0d16071b460cc7c006d96b57441b958f22 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:41:03 -0400 Subject: [PATCH 097/166] Docs: the panic guard's note says what happens to a group the command ended It keeps a step from the group's start to the restored workbook, so Undo still returns to the state before the group; the note said the history came back exactly. --- docs/architecture.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index bfbf2b8c1..4794a6ec9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,7 +34,9 @@ including tests, 2026-10-10. enabled, run, journal, undoable }`; ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). `Session::execute` catches escaped panics and restores the workbook, its selection and its - undo and redo history as they were before the command (never-crash guard). + undo and redo history as they were before the command (never-crash guard); a group that was + open before the command and that the command ended or rolled back stays ended and keeps a + step from its start to the restored workbook, so Undo still returns to the state before it. 2. **Edits** change the model through copy-on-write bands; undo keeps the previous snapshot. Undo retains at most 100 steps and trims older steps when its estimated retained cell storage exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget. From f040b4ecaae3ab9c19ddd3145417e70d79b0b23c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:18:07 -0400 Subject: [PATCH 098/166] Engine: stash listings say how many items were past the cap stash::items and stash::keys listed the first 10,000 items and let the rest vanish without a word. They return a Listing { found, more } now, so a caller can say what it did not list. --- crates/engine/src/cmd/stash.rs | 52 +++++++++++++++++++++------------- docs/architecture.md | 3 +- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 786eca4f1..348f00157 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -102,15 +102,22 @@ pub fn get(wb: &Workbook, key: &str) -> Option { assemble(&parts) } +/// What a listing found: the first [`MAX_ITEMS`] items or keys, and how many more there were. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Listing { + pub found: T, + /// Items past the cap, not listed. A caller says so rather than letting them vanish. + pub more: usize, +} + /// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass -/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit. -pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap { - group(wb, prefix) - .into_iter() - .filter(|(_, parts)| parts.is_item()) - .take(MAX_ITEMS) - .filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text))) - .collect() +/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit, and +/// the count of the rest. +pub fn items(wb: &Workbook, prefix: &str) -> Listing> { + let all: Vec<(String, Parts)> = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).collect(); + let more = all.len().saturating_sub(MAX_ITEMS); + let found = all.into_iter().take(MAX_ITEMS).filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text))).collect(); + Listing { found, more } } /// The text of the item held by `parts`, as [`get`] reads it. @@ -227,8 +234,11 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> { /// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted /// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass /// where a [`get`] per key would take one each. -pub fn keys(wb: &Workbook, prefix: &str) -> Vec { - group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect() +pub fn keys(wb: &Workbook, prefix: &str) -> Listing> { + let mut found: Vec = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).collect(); + let more = found.len().saturating_sub(MAX_ITEMS); + found.truncate(MAX_ITEMS); + Listing { found, more } } /// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope @@ -433,10 +443,10 @@ mod tests { // Only a later chunk: no item, not listed, and the legacy name is still read. wb.names.push(name("_gc_orphan_2", "\"c\"")); assert_eq!(get(&wb, "orphan"), None); - assert_eq!(keys(&wb, "orphan"), Vec::::new()); + assert_eq!(keys(&wb, "orphan").found, Vec::::new()); wb.names.push(name("_sc_orphan", "\"old\"")); assert_eq!(get(&wb, "orphan").as_deref(), Some("old")); - assert_eq!(keys(&wb, "orphan"), ["orphan"]); + assert_eq!(keys(&wb, "orphan").found, ["orphan"]); // Chunks out of order, a duplicate, junk around the literals, an unterminated literal. wb.names.push(name("_gc_mix_1", "\"b\"&A1&")); wb.names.push(name("_gc_mix_0", "SUM(\"a\")")); @@ -484,7 +494,7 @@ mod tests { let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") }; wb.names.push(own.clone()); assert_eq!(get(&wb, "watch"), None); - assert!(keys(&wb, "").is_empty()); + assert!(keys(&wb, "").found.is_empty()); // Writing the item would put a hidden name of the same name beside it, and an xlsx file // keeps only one of them: refused, with the names as they were. let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string(); @@ -517,7 +527,7 @@ mod tests { let mut wb = Workbook::new(); wb.names.push(name("_gc_watch_00", "\"theirs\"")); assert_eq!(get(&wb, "watch"), None); - assert!(keys(&wb, "").is_empty()); + assert!(keys(&wb, "").found.is_empty()); let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") }; wb.names.push(own.clone()); set(&mut wb, "watch", "ours").unwrap(); @@ -533,7 +543,7 @@ mod tests { set(&mut wb, "macro_2", "b").unwrap(); set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap(); set(&mut wb, "scripts", "{}").unwrap(); - assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]); + assert_eq!(keys(&wb, "macro_").found, ["macro_1", "macro_2"]); assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1")); assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts")); assert_eq!(item_key("Total"), None); @@ -553,7 +563,8 @@ mod tests { wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\"")); wb.names.push(name("_gc_other_0", "\"not a macro\"")); NAME_VISITS.with(|v| v.set(0)); - let all = items(&wb, "MACRO_"); + let listing = items(&wb, "MACRO_"); + let all = &listing.found; assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names"); assert_eq!(all.len(), MAX_ITEMS); let first: Vec<&String> = all.keys().take(2).collect(); @@ -563,19 +574,20 @@ mod tests { NAME_VISITS.with(|v| v.set(0)); let listed = keys(&wb, "macro_"); assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len()); - assert_eq!(listed, all.keys().cloned().collect::>(), "keys and items agree"); + assert_eq!(listed.found, all.keys().cloned().collect::>(), "keys and items agree"); + assert_eq!((listing.more, listed.more), (10_002, 10_002), "the rest are counted, not lost"); // Under the cap every item is there, read as `get` reads it. wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1")); - let few = items(&wb, "macro_"); + let few = items(&wb, "macro_").found; let want: BTreeMap = [("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect(); assert_eq!(few, want); for (k, text) in &few { assert_eq!(get(&wb, k).as_ref(), Some(text)); } - assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]); - assert_eq!(items(&wb, "").len(), 3, "the other item too"); + assert_eq!(keys(&wb, "macro_").found, ["macro_big", "macro_legacy"]); + assert_eq!((items(&wb, "").found.len(), items(&wb, "").more), (3, 0), "the other item too, none past the cap"); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index 4794a6ec9..d243643a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,7 +87,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` and `stash::keys` list the items under a key prefix in one pass over the names, at most - 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. + 10,000 of them (`stash::MAX_ITEMS`) with a count of the rest, so a caller can say what it did + not list. The older single-name `_sc_` items are read as well. `formulas.defineName` and `formulas.deleteName` refuse a name the workbook holds such an item under (a hidden workbook-scope name of either shape; a visible one is the person's own), and `formulas.createFromSelection` skips such labels, unless `force: true` is passed. From 6cd4cebfb71d2a06e5f4079fccfd2a3b94ff2502 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:18:07 -0400 Subject: [PATCH 099/166] Engine: Stop Action runs on its own and is not recorded Run from inside another command, automate.stopAction rolled back that command's own group, pulling the workbook from under the rest of its run; and the recording buffer kept it, so a recording could replay the stop inside the run it was part of. Nested, the command is refused and the group stays; the recorder leaves it out (record::NOT_RECORDED). --- crates/engine/src/cmd/extra.rs | 5 +++++ crates/engine/src/lib.rs | 12 +++++++++++- crates/engine/src/record.rs | 4 ++++ crates/engine/src/tests/seams.rs | 26 ++++++++++++++++++++++++++ docs/architecture.md | 2 ++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 009d77f2a..02d45febb 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1099,6 +1099,11 @@ fn run_script(s: &mut Session, p: &Json) -> Result { /// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and /// Redo work there again. fn stop_action(s: &mut Session, _: &Json) -> Result { + // Inside another command the open groups are that command's own (an action in progress): + // a step of it that stopped them would pull the workbook from under the rest of its run. + if s.depth() > 1 { + return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into())); + } let uid = s.doc()?.uid; Ok(json!({"stopped": s.abort_groups(uid)})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1827da375..856f1777e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -623,7 +623,7 @@ impl Session { // file, `home.formatCells` with no style) is not recorded either: the dialog's own // call is, so playing the recording back runs the command once, with its parameters. let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..)))); - if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog) + if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog && !record::NOT_RECORDED.contains(&id)) && let Some(rec) = self.recorder.as_mut() { rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); @@ -870,6 +870,16 @@ impl Session { self.depth } + /// Runs a command as a command in progress would (one level deeper), for tests of what a + /// nested call may do. + #[cfg(test)] + pub(crate) fn execute_nested(&mut self, id: &str, params: Json) -> Result { + self.depth = self.depth.saturating_add(1); + let r = self.execute(id, params); + self.depth = self.depth.saturating_sub(1); + r + } + /// Whether an undo group is open on the document with this uid. pub fn in_group(&self, doc: u64) -> bool { self.groups.iter().any(|g| g.doc == doc) diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 7804be6c9..4a5d7c4c9 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -18,6 +18,10 @@ pub const RECORD_LIMIT: usize = 100_000; /// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive. pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024; +/// Commands never recorded: recovery actions, not steps of the person's work. A recording that +/// replayed Stop Action would stop the run it is a step of. +pub const NOT_RECORDED: [&str; 1] = ["automate.stopAction"]; + /// One recorded command and where it ran. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index b445377b7..45d57e6f6 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -820,6 +820,32 @@ fn a_call_that_only_opens_a_dialog_is_not_recorded() { assert_eq!(s.stop_recording().unwrap().dropped, 0); } +#[test] +fn stop_action_runs_only_on_its_own_and_is_not_recorded() { + // A step of an action in progress may not stop the action: nested, the command is refused + // and the action's group stays. Nor is Stop Action a step of the person's work: a recording + // leaves it out, so playing the recording back never stops the run it is part of. + let mut s = s(); + s.start_recording(); + let r: crate::Result<()> = s.grouped("Outer", |s| { + set(s, "A1", "one"); + let err = s.execute_nested("automate.stopAction", json!({})).unwrap_err().to_string(); + assert!(err.contains("on its own"), "{err}"); + set(s, "A2", "two"); + Ok(()) + }); + r.unwrap(); + assert_eq!(v(&s, "A2").display(), "two"); + assert_eq!(undo_labels(&s), ["Outer"], "the action kept its group"); + // On its own it stops a group left open; the recording keeps the edits and not the stop. + let _left = s.group_begin("Left open").unwrap(); + set(&mut s, "A3", "three"); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); + assert!(v(&s, "A3").is_empty()); + assert_eq!(recorded_ids(&s), ["cell.set", "cell.set", "cell.set"]); + assert_eq!(s.stop_recording().unwrap().dropped, 0); +} + #[test] fn the_recorder_keeps_top_level_calls_with_where_they_ran() { let mut s = s(); diff --git a/docs/architecture.md b/docs/architecture.md index d243643a4..60e095611 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u every group it opened, as `group_rollback` does. `Session::abort_groups(doc)`, and the command `automate.stopAction` on the active workbook, roll back the groups left open on a document (a debugger the person abandons, a caller that dropped its token), so Undo works there again. The + command runs on its own: a step of a script or macro may not stop the action it is part of, and + a recording leaves it out, so playing the recording back never stops the run. The history limit is applied when the outermost command finishes; until then the steps made inside it are bounded on their own. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful From 54bd4eca9d93122f75bcb7b107b202bc1d697393 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:42:45 -0400 Subject: [PATCH 100/166] Engine: a run that edited the workbook before opening a dialog is recorded The recorder left out every call that opened a dialog, so a script with an edit before a menu-style step lost that edit from the recording. A call is left out only when it opened a dialog and changed nothing (the dialog's confirming call is the step); one that changed the workbook is kept. --- crates/engine/src/lib.rs | 6 +++++- crates/engine/src/tests/seams.rs | 20 ++++++++++++++++++++ docs/architecture.md | 3 ++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 856f1777e..b2d4af09e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -622,8 +622,12 @@ impl Session { // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no // file, `home.formatCells` with no style) is not recorded either: the dialog's own // call is, so playing the recording back runs the command once, with its parameters. + // A call that opened a dialog and changed the workbook too (a script with an edit before + // a menu-style step) is recorded: the edit happened, and the dialog's confirming call + // is a step of its own. let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..)))); - if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog && !record::NOT_RECORDED.contains(&id)) + let changed = before.as_ref().is_some_and(|(uid, wb, _)| self.docs.iter().any(|d| d.uid == *uid && !Arc::ptr_eq(&d.wb, wb))); + if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| (!opened_dialog || changed) && !record::NOT_RECORDED.contains(&id)) && let Some(rec) = self.recorder.as_mut() { rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 45d57e6f6..e93116120 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -846,6 +846,26 @@ fn stop_action_runs_only_on_its_own_and_is_not_recorded() { assert_eq!(s.stop_recording().unwrap().dropped, 0); } +#[test] +fn a_run_that_edits_and_opens_a_dialog_is_recorded() { + // A script with an edit before a menu-style step: the edit happened, so the run is a step of + // the recording (the person's confirming call is another). Only a call that opened a dialog + // and changed nothing is left out. + let mut s = s(); + let edit_then_dialog = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}, {"command": "home.formatCells", "params": {}}]); + let dialog_only = json!([{"command": "home.formatCells", "params": {}}]); + s.execute("automate.newScript", json!({"name": "Edit", "commands": edit_then_dialog})).unwrap(); + s.execute("automate.newScript", json!({"name": "Ask", "commands": dialog_only})).unwrap(); + s.start_recording(); + s.execute("automate.runScript", json!({"name": "Ask"})).unwrap(); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + assert!(recorded_ids(&s).is_empty(), "{:?}", recorded_ids(&s)); + s.execute("automate.runScript", json!({"name": "Edit"})).unwrap(); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + assert_eq!(recorded_ids(&s), ["automate.runScript"], "the edit happened, so the run is a step"); + assert_eq!(s.stop_recording().unwrap().dropped, 0); +} + #[test] fn the_recorder_keeps_top_level_calls_with_where_they_ran() { let mut s = s(); diff --git a/docs/architecture.md b/docs/architecture.md index 60e095611..34c167c39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, nor is a menu-style call that only opened a - dialog (the dialog's confirming call is the step), and the buffer is independent of the journal + dialog (the dialog's confirming call is the step; a run that changed the workbook before a step + of it opened a dialog is recorded), and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and `FromFile` for workbooks opened from a file. `Trust` has no default: `DocState::new` takes it, From 3c722f4d86006801e27f5f6a500246a380c1c20f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:35:03 -0400 Subject: [PATCH 101/166] Engine: a visible name of the macro storage shape no longer blocks an xlsx save with_names numbered macros 0..N, and stash::set_many refuses to write a hidden name beside a visible one of the same name, so a visible _gc_macro_0_0 (from a file, or defined after the file opened, when the stored names are no longer in the workbook) made every xlsx save of a workbook with macros fail. The index is GridCraft's choice: macros now take the free indices from 0 up, skipping every key a visible workbook-scope name of the storage shape uses (compared as the stash reads names), in their order. No arithmetic depends on a number from the file. --- crates/engine/src/macros/mod.rs | 15 ++++++-- crates/engine/src/macros/tests.rs | 61 ++++++++++++++++++++++++++----- docs/macros.md | 4 +- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index f0c83e549..96f6ad988 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -48,7 +48,8 @@ pub fn check(m: &Macro) -> Result<(), String> { } /// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of -/// JSON), so saving a workbook never fails because of one of its macros. +/// JSON), so a macro's size never makes a save fail. (A clashing visible name doesn't either: +/// [`with_names`] stores the macro under another index.) fn check_size(m: &Macro) -> Result<(), String> { if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS { return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS)); @@ -94,7 +95,8 @@ pub fn find(wb: &Workbook, name: &str) -> Option { } /// 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. +/// 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. pub fn with_names(wb: &Workbook) -> crate::Result> { // Only the numbered items are this build's: a hidden `_gc_macro__` name with some // other key stays, as `load` leaves it. @@ -106,7 +108,14 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { // One pass drops the items of the last save, and one writes the macros (a file can hold a // thousand macros and many thousands of names). out.names.retain(|n| !stored(n)); - let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect(); + // A visible workbook-scope name of the storage shape is someone's own, and `set_many` won't + // write a hidden one of the same name beside it. Such a name (`_GC_MACRO_1_0`, or one a + // person defined after the file opened) blocks its item's key, in any chunk, so the macros + // take the free indices from 0 up. The keys compare as the stash reads them, so this skips + // exactly what `set_many` would refuse, and no arithmetic depends on a number from the file. + let blocked: HashSet = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect(); + let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key)); + let items: Vec<(String, String)> = wb.macros.iter().zip(free).map(|(m, key)| (key, macro_json(m).to_string())).collect(); stash::set_many(&mut out, &items)?; // An empty list of its own, not a copy of the shared one cleared. out.macros = gridcraft_model::Macros::default(); diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 99235ec68..d30ff021d 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -216,21 +216,62 @@ fn a_macro_built_in_code_goes_through_the_size_check_too() { assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save"); } +fn visible(name: &str) -> DefinedName { + DefinedName { name: name.into(), scope: None, formula: "Sheet1!$A$1".into(), comment: String::new(), hidden: false } +} + +/// Saves `wb` to xlsx and returns the names in the file and the workbook opened from it. +fn xlsx_round_trip(wb: &Workbook) -> (Vec, Workbook) { + let bytes = crate::io::save_bytes(wb, "book.xlsx").unwrap(); + let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0); + let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + (raw, back) +} + #[test] -fn a_save_refuses_a_clashing_visible_name_before_changing_anything() { +fn a_visible_name_of_the_storage_shape_does_not_block_a_save() { + // The index a macro is stored under is GridCraft's choice, so a person's own visible name + // of that shape (here in capitals: names compare without case) is stepped around, not + // refused. Stale hidden items still go in the same pass. let mut wb = Workbook::new(); wb.macros.push(sample_macro("A")); wb.macros.push(sample_macro("B")); - let own = DefinedName { hidden: false, ..hidden("_GC_MACRO_1_0", "mine") }; - wb.names.push(own.clone()); + wb.names.push(visible("_GC_MACRO_0_0")); wb.names.push(hidden("_gc_macro_7_0", "stale")); - let err = crate::io::save_bytes(&wb, "book.xlsx").unwrap_err().to_string(); - assert!(err.contains("_GC_MACRO_1_0"), "{err}"); - assert_eq!(wb.names.len(), 2, "nothing changed"); - wb.names.remove(0); - let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); - let names = names_of(&raw); - assert_eq!(names, ["_gc_macro_0_0", "_gc_macro_1_0"], "the stale item went in the same pass"); + let (raw, back) = xlsx_round_trip(&wb); + assert_eq!(raw, ["_GC_MACRO_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]); + assert_eq!(back.macros, wb.macros, "both macros, in their order"); + assert_eq!(back.names, vec![visible("_GC_MACRO_0_0")], "the person's name is kept as it was"); + assert_eq!(wb.names.len(), 2, "the open workbook is not changed by a save"); +} + +#[test] +fn a_huge_visible_index_takes_no_part_in_numbering() { + // A file can carry a visible name with any number in it. The macros take the free indices + // from 0 up, so no arithmetic depends on that number (one past the largest would overflow). + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + wb.names.push(visible(&format!("_gc_macro_{}_0", u64::MAX))); + wb.names.push(visible("_gc_macro_0_0")); + let (raw, back) = xlsx_round_trip(&wb); + assert_eq!(raw, [format!("_gc_macro_{}_0", u64::MAX), "_gc_macro_0_0".into(), "_gc_macro_1_0".into()]); + assert_eq!(back.macros, wb.macros); + assert_eq!(names_of(&back), names_of(&wb)); +} + +#[test] +fn a_name_defined_after_opening_does_not_block_the_next_save() { + // Once a file is open its macros are out of the names, so Define Name accepts a visible + // `_gc_macro_0_0`; the next save steps around it. + let mut s = crate::Session::new(); + s.execute("file.new", json!({})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).macros.push(sample_macro("A")); + s.execute("formulas.defineName", json!({"name": "_gc_macro_0_0", "refersTo": "=Sheet1!$A$1"})).unwrap(); + let (raw, back) = xlsx_round_trip(&s.doc().unwrap().wb); + assert_eq!(raw, ["_gc_macro_0_0", "_gc_macro_1_0"]); + assert_eq!(back.macros.len(), 1); + assert_eq!(names_of(&back), ["_gc_macro_0_0"]); } #[test] diff --git a/docs/macros.md b/docs/macros.md index f98435bb5..9fb3b7943 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -49,7 +49,9 @@ version. Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's -limits), which other spreadsheet applications keep but do not run. +limits), which other spreadsheet applications keep but do not run. A visible name of that shape +is someone's own name: opening a file leaves it alone, and saving stores the macros under indices +it does not use. Each macro carries its format version, `v` (1 in this version; a missing `v` reads as 1). When a file is opened, a macro that is damaged, over the limits or in a newer format (a `v` above 1) is From ac36eee23db3271ebfe0a69426e8207ff2fb91cb Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:35:32 -0400 Subject: [PATCH 102/166] Engine: a JSON workbook with no sheets keeps its macros The JSON reader replaces a workbook with an empty sheet list by a new one before macros::load reads the file's macro entries, so they were lost with no warning. The entries are carried across the reset. The reset itself, which also starts the names and styles afresh, is unchanged. --- crates/engine/src/io.rs | 3 +++ crates/engine/src/macros/tests.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b6a5d8473..b7a5ea709 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -166,7 +166,10 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?; wb.styles.rebuild_index(); if wb.sheets.is_empty() { + // The file's macros are read after this (`macros::load`), so they come along. + let macros = std::mem::take(&mut wb.macros_read); wb = Workbook::new(); + wb.macros_read = macros; } // The file's band placement and cell count are derived state: rebuild them from the // cells themselves, dropping what is outside the sheet grid (#1008). diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index d30ff021d..957b738f5 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -514,3 +514,16 @@ fn naming_clashing_macros_takes_linear_work() { taken.insert("a (2)"); assert_eq!(taken.unique("a"), "a (4)"); } + +#[test] +fn a_json_workbook_with_no_sheets_keeps_its_macros() { + // A JSON file with an empty sheet list opens as a new workbook; its macros come along. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap(); + v["sheets"] = json!([]); + let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.sheets.len(), 1); + assert_eq!(back.macros, wb.macros); +} From 471a40bd723a7b1fd6098523746b55107190a9d5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:39:14 -0400 Subject: [PATCH 103/166] Model: every field a macro file sets has a limit, and an input declaration is checked as a whole check_limits capped the name, description, notes, step count, depth and input names, and left the shortcut, permissions, output anchor, default range, column types, command ids, Repeat ranges, If cells and values and Stop messages bounded only by the item size; the input declaration was stored unchecked (rows [5, 2], a cell input with cols [2, 5]). Now each of them has a limit (docs/macros.md lists them), one-line fields refuse control characters, and a command id is letters, digits, . and _ (every registered id passes, tested). An If value may be null (Empty and NotEmpty ignore it); Repeat.max stays the runner's to bound. An input's least rows and columns can't pass its most, a cell input is one row and one column with no header and at most one column type, and no input lists more column types than columns. check_limits takes every struct apart field by field, so a new field doesn't compile there until its limit is decided. A macro from a file that breaks a rule is left out with the usual warning, never changed to fit. --- crates/engine/src/macros/tests.rs | 100 ++++++++++++++++++++++++++++ crates/model/src/macros.rs | 106 +++++++++++++++++++++++++----- docs/macros.md | 18 ++++- 3 files changed, 205 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 957b738f5..2d55cfe09 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -527,3 +527,103 @@ fn a_json_workbook_with_no_sheets_keeps_its_macros() { assert_eq!(back.sheets.len(), 1); assert_eq!(back.macros, wb.macros); } + +fn long(n: usize) -> String { + "x".repeat(n) +} + +/// `sample_macro` as JSON with `change` made to it. +fn changed(change: fn(&mut serde_json::Value)) -> serde_json::Value { + let mut v = macro_json(&sample_macro("T")); + change(&mut v); + v +} + +#[test] +fn every_field_a_file_sets_has_a_limit() { + // The sample's steps: 0 a command, 1 a Repeat whose body holds an If whose else holds a Stop. + let refused: [(&str, fn(&mut serde_json::Value)); 14] = [ + ("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT + 1))), + ("shortcut line break", |v| v["shortcut"] = json!("Ctrl+\nM")), + ("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS + 1])), + ("permission text", |v| v["permissions"] = json!(["data getData"])), + ("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF + 1))), + ("default range", |v| v["inputs"][0]["defaultRef"] = json!(long(MAX_MACRO_REF + 1))), + ("column types", |v| { + v["inputs"][0]["cols"] = json!([1, 0]); + v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS + 1]); + }), + ("command id", |v| v["steps"][0]["id"] = json!("cell set")), + ("empty command id", |v| v["steps"][0]["id"] = json!("")), + ("Repeat range", |v| v["steps"][1]["over"] = json!(long(MAX_MACRO_REF + 1))), + ("If cell", |v| v["steps"][1]["body"][0]["cell"] = json!(long(MAX_MACRO_REF + 1))), + // As JSON the string takes its two quotes as well. + ("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 1))), + ("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT + 1))), + ("Stop message, nested", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT * 2))), + ]; + for (what, change) in refused { + assert!(parse_macro(&changed(change)).is_err(), "{what} past its limit is refused"); + } + let accepted: [(&str, fn(&mut serde_json::Value)); 9] = [ + ("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT))), + ("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS])), + ("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF))), + ("column types", |v| { + v["inputs"][0]["cols"] = json!([1, 0]); + v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS]); + }), + ("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 2))), + // Empty and NotEmpty ignore the value, which is then left out (null). + ("If with no value", |v| { + v["steps"][1]["body"][0]["op"] = json!("empty"); + v["steps"][1]["body"][0].as_object_mut().unwrap().remove("value"); + }), + // How many times a Repeat may run is the runner's to bound, not the format's. + ("Repeat max", |v| v["steps"][1]["max"] = json!(u32::MAX)), + ("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT))), + ("no shortcut", |v| { + v.as_object_mut().unwrap().remove("shortcut"); + }), + ]; + for (what, change) in accepted { + assert!(parse_macro(&changed(change)).is_ok(), "{what} at its limit is accepted"); + } + // Every command there is passes as a step and as a permission. + for spec in crate::command_specs() { + let m = json!({"name": "T", "steps": [{"command": spec.id}], "permissions": [spec.id]}); + assert!(parse_macro(&m).is_ok(), "{}", spec.id); + } +} + +#[test] +fn an_input_declaration_is_checked_as_a_whole() { + let input = |i: serde_json::Value| json!({"name": "T", "inputs": [i]}); + for i in [ + json!({"name": "Input", "rows": [5, 2]}), + json!({"name": "Input", "cols": [3, 2]}), + json!({"name": "Input", "kind": "cell", "cols": [2, 5]}), + json!({"name": "Input", "kind": "cell", "rows": [0, 0]}), + json!({"name": "Input", "kind": "cell", "header": true}), + json!({"name": "Input", "kind": "cell", "colTypes": ["number", "text"]}), + json!({"name": "Input", "cols": [2, 2], "colTypes": ["number", "text", "any"]}), + ] { + assert!(parse_macro(&input(i.clone())).is_err(), "{i}"); + } + for i in [ + json!({"name": "Input", "rows": [5, 0]}), + json!({"name": "Input", "rows": [2, 2], "cols": [0, 3]}), + json!({"name": "Input", "kind": "cell"}), + json!({"name": "Input", "kind": "cell", "rows": [1, 1], "cols": [1, 1], "colTypes": ["number"]}), + json!({"name": "Input", "cols": [1, 2], "colTypes": ["number", "text"]}), + ] { + assert!(parse_macro(&input(i.clone())).is_ok(), "{i}"); + } + // From a file, a macro whose declaration fails is left out with the usual warning, and the + // workbook's other macros open. + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + file["macros"] = json!([input(json!({"name": "Input", "rows": [5, 2]})), {"name": "Fine"}]); + let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Fine"]); + assert_eq!(warnings.len(), 1, "{warnings:?}"); +} diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 54f975b04..8ec4b03e5 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -13,8 +13,17 @@ pub const MAX_MACRO_STEPS: usize = 10_000; pub const MAX_MACRO_DEPTH: usize = 4; /// The longest macro or input name, in characters. pub const MAX_MACRO_NAME: usize = 255; -/// The longest macro description or step note, in characters. +/// The longest macro description, step note, Stop message or If value (as JSON), in characters. pub const MAX_MACRO_TEXT: usize = 4_096; +/// The longest reference or token in a macro (an output anchor, an input's default range, a +/// Repeat's range, an If's cell), in characters. +pub const MAX_MACRO_REF: usize = 1_024; +/// The longest shortcut text (`Ctrl+Shift+M`), in characters. +pub const MAX_MACRO_SHORTCUT: usize = 64; +/// The most commands a macro's permissions list. +pub const MAX_MACRO_PERMISSIONS: usize = 256; +/// The most column types an input lists: one per column of a sheet. +pub const MAX_MACRO_COLUMNS: usize = 16_384; /// A recorded or hand-made macro: a list of typed steps that runs as one undo step. Most steps run /// a GridCraft command; Repeat, If and Stop add bounded control flow. References in step @@ -63,11 +72,17 @@ impl Macro { count(&self.steps) } - /// Checks the format version ([`check_version`]), the size limits ([`MAX_MACRO_STEPS`], - /// [`MAX_MACRO_DEPTH`], name and text lengths) and that the name has no control characters. + /// Checks every field a file can set: the format version ([`check_version`]); the lengths of + /// the name, description, shortcut, notes, messages, references and command ids; the counts of + /// steps ([`MAX_MACRO_STEPS`]), inputs, permissions and column types; the nesting depth + /// ([`MAX_MACRO_DEPTH`]); and each input's declaration. Command parameters are bounded only by + /// the size of the whole macro, which [`MAX_MACRO_STEPS`] and the workbook's item limit cap. + /// Every struct is taken apart field by field, so a field added later doesn't compile here + /// until someone decides its limit. pub fn check_limits(&self) -> Result<(), String> { - check_version(u64::from(self.v))?; - let name = self.name.trim(); + let Macro { v, name, description, shortcut, inputs, steps, output, permissions } = self; + check_version(u64::from(*v))?; + let name = name.trim(); if name.is_empty() { return Err("A macro needs a name.".into()); } @@ -77,8 +92,19 @@ impl Macro { if name.chars().any(char::is_control) { return Err("A macro name can't hold line breaks or other control characters.".into()); } - if self.description.chars().count() > MAX_MACRO_TEXT { - return Err(format!("A macro description can be at most {MAX_MACRO_TEXT} characters long.")); + check_text("A macro description", description, MAX_MACRO_TEXT, false)?; + if let Some(shortcut) = shortcut { + check_text("A shortcut", shortcut, MAX_MACRO_SHORTCUT, true)?; + } + let OutputRule { anchor, on_conflict: _ } = output; + if let Some(anchor) = anchor { + check_text("The output anchor", anchor, MAX_MACRO_REF, true)?; + } + if permissions.len() > MAX_MACRO_PERMISSIONS { + return Err(format!("A macro can list at most {MAX_MACRO_PERMISSIONS} permissions.")); + } + for p in permissions { + check_id("A permission", p)?; } // Count without recursing past the depth limit (a hostile file can nest deeper). // A Repeat or If at the top is nested 1 deep; one inside it, 2 deep. @@ -88,24 +114,36 @@ impl Macro { if *count > MAX_MACRO_STEPS { return Err(format!("A macro can have at most {MAX_MACRO_STEPS} steps.")); } - if s.note.chars().count() > MAX_MACRO_TEXT { - return Err(format!("A step note can be at most {MAX_MACRO_TEXT} characters long.")); + let Step { kind, enabled: _, breakpoint: _, note } = s; + check_text("A step note", note, MAX_MACRO_TEXT, false)?; + match kind { + StepKind::Command { id, params: _ } => check_id("A step's command", id)?, + StepKind::Repeat { over, of: _, max: _, body: _ } => check_text("A Repeat step's range", over, MAX_MACRO_REF, true)?, + StepKind::If { cell, op: _, value, then: _, otherwise: _ } => { + check_text("An If step's cell", cell, MAX_MACRO_REF, true)?; + // Empty and NotEmpty ignore the value, which is then null. + if serde_json::to_string(value).map_or(usize::MAX, |t| t.chars().count()) > MAX_MACRO_TEXT { + return Err(format!("An If step's value can be at most {MAX_MACRO_TEXT} characters long.")); + } + } + StepKind::Stop { message, rollback: _ } => check_text("A Stop message", message, MAX_MACRO_TEXT, false)?, } - if s.kind.children().next().is_some() && level > MAX_MACRO_DEPTH { + if kind.children().next().is_some() && level > MAX_MACRO_DEPTH { return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); } - for body in s.kind.children() { + for body in kind.children() { walk(body, level + 1, count)?; } } Ok(()) } - walk(&self.steps, 1, &mut 0)?; - if self.inputs.len() > 64 { + walk(steps, 1, &mut 0)?; + if inputs.len() > 64 { return Err("A macro can declare at most 64 inputs.".into()); } - for (k, i) in self.inputs.iter().enumerate() { - let n = i.name.as_str(); + for (k, i) in inputs.iter().enumerate() { + let NamedInput { name, kind, rows, cols, header, col_types, non_empty: _, default_ref } = i; + let n = name.as_str(); let ident = n.chars().next().is_some_and(char::is_alphabetic) && n.chars().all(|c| c.is_alphanumeric() || c == '_'); if !ident || n.chars().count() > MAX_MACRO_NAME { return Err(format!("'{n}' can't name an input: use letters, digits and _, starting with a letter.")); @@ -117,14 +155,50 @@ impl Macro { if gridcraft_core::RangeRef::parse(n).is_some() { return Err(format!("'{n}' reads as a cell reference and can't name an input.")); } - if self.inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) { + if inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) { return Err(format!("Two inputs are called '{n}'.")); } + for (what, [least, most]) in [("rows", rows), ("columns", cols)] { + if *most != 0 && least > most { + return Err(format!("Input '{n}' allows {least} to {most} {what}: the least can't be more than the most.")); + } + } + // A cell is one row and one column ([1, 1], or the default [1, 0]) with no header. + let one = |b: &[u32; 2]| *b == [1, 1] || *b == [1, 0]; + if *kind == InputKind::Cell && (!one(rows) || !one(cols) || *header || col_types.len() > 1) { + return Err(format!("Input '{n}' is a single cell: one row and one column, no header, at most one column type.")); + } + let most = if cols[1] == 0 { MAX_MACRO_COLUMNS } else { usize::try_from(cols[1]).unwrap_or(usize::MAX).min(MAX_MACRO_COLUMNS) }; + if col_types.len() > most { + return Err(format!("Input '{n}' lists the types of {} columns, but has at most {most}.", col_types.len())); + } + if let Some(r) = default_ref { + check_text(&format!("The default range of input '{n}'"), r, MAX_MACRO_REF, true)?; + } } Ok(()) } } +/// Refuses `text` longer than `max` characters, and, for a one-line field, one that holds line +/// breaks or other control characters. +fn check_text(what: &str, text: &str, max: usize, one_line: bool) -> Result<(), String> { + if text.chars().count() > max { + return Err(format!("{what} can be at most {max} characters long.")); + } + if one_line && text.chars().any(char::is_control) { + return Err(format!("{what} can't hold line breaks or other control characters.")); + } + Ok(()) +} + +/// Refuses a command id that isn't one: empty, longer than a name, or holding anything other than +/// letters, digits, `.` and `_` (every command id is of that form). +fn check_id(what: &str, id: &str) -> Result<(), String> { + let ok = !id.is_empty() && id.len() <= MAX_MACRO_NAME && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_'); + if ok { Ok(()) } else { Err(format!("{what} must be a command id (letters, digits, . and _): '{}'", id.chars().take(64).collect::())) } +} + /// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so /// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every /// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list diff --git a/docs/macros.md b/docs/macros.md index 9fb3b7943..3fbd61b2d 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -41,9 +41,21 @@ version. `nonEmpty` and `defaultRef`. These declare what a range must look like for the macro to work on it. An input's name is letters, digits and `_`, starting with a letter, and is neither a token of its own (`Output`, `Row`, `Column`, `Name`) nor something that reads as a cell - reference (`Q1`), so a step written with plain A1 text keeps meaning the cell. This version - stores and checks the declaration; it does not bind inputs or run macros. -- **Limits**: 1,000 macros a workbook, 10,000 steps a macro, and Repeat and If nested 4 deep. + 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. +- **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 + (`output.anchor`, `defaultRef`, a Repeat's `over`, an If's `cell`) 1,024; a shortcut 64. A + 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. ## Storage From 3905a4318e7bc8bb614a034934530d757f80fab8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:42:11 -0400 Subject: [PATCH 104/166] Engine: a macro in a newer format is kept unread and saved back unchanged A macro whose format version is above this build's was dropped on open, so the next save removed it from the file, and the warning said only that it couldn't be opened. It is now kept as the file held it (Workbook::macros_unread, shared between snapshots), never read, listed or run, and written back unchanged by JSON and xlsx saves, numbered after the readable macros and around visible names of the storage shape. Kept macros count toward the 1,000-macro limit after the readable ones; one too large for an xlsx item is left out. The warning says it is kept and saved back; damaged and over-limit macros keep their own warning. --- crates/engine/src/io.rs | 2 +- crates/engine/src/macros/mod.rs | 77 +++++++++++++++++++++++++++---- crates/engine/src/macros/tests.rs | 56 +++++++++++++++++++--- crates/model/src/workbook.rs | 6 +++ docs/macros.md | 10 ++-- 5 files changed, 132 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b7a5ea709..41c2420d2 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -262,7 +262,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), - FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), + FileKind::Json => crate::macros::json_bytes(wb), FileKind::Html => to_html(wb, sheet).map(String::into_bytes), } } diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 96f6ad988..5819e79fd 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -101,7 +101,7 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { // Only the numbered items are this build's: a hidden `_gc_macro__` name with some // other key stays, as `load` leaves it. let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some()); - if wb.macros.is_empty() && !wb.names.iter().any(stored) { + if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) { return Ok(None); } let mut out = wb.clone(); @@ -115,23 +115,63 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { // exactly what `set_many` would refuse, and no arithmetic depends on a number from the file. let blocked: HashSet = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect(); let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key)); - let items: Vec<(String, String)> = wb.macros.iter().zip(free).map(|(m, key)| (key, macro_json(m).to_string())).collect(); + // Macros in a newer format follow the readable ones, written back as the file held them. + let texts = wb.macros.iter().map(|m| macro_json(m).to_string()).chain(wb.macros_unread.iter().map(Json::to_string)); + let items: Vec<(String, String)> = texts.zip(free).map(|(text, key)| (key, text)).collect(); stash::set_many(&mut out, &items)?; // An empty list of its own, not a copy of the shared one cleared. out.macros = gridcraft_model::Macros::default(); + out.macros_unread = std::sync::Arc::default(); Ok(Some(out)) } -/// After a file is opened: moves macros out of hidden names into `Workbook::macros` and drops -/// macros that are damaged, over the limits or in a newer format. Returns a warning for what was dropped. +/// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into +/// the `macros` list as the file held them. +pub fn json_bytes(wb: &Workbook) -> crate::Result> { + let bytes = if wb.macros_unread.is_empty() { + serde_json::to_vec_pretty(wb) + } else { + serde_json::to_value(wb).and_then(|mut v| { + if let Some(list) = v.as_object_mut().map(|o| o.entry("macros").or_insert_with(|| Json::Array(vec![]))).and_then(Json::as_array_mut) { + list.extend(wb.macros_unread.iter().cloned()); + } + serde_json::to_vec_pretty(&v) + }) + }; + bytes.map_err(|e| crate::EngineError::Other(e.to_string())) +} + +/// Whether `v` is a macro in a newer format than this build reads. +fn is_newer(v: &Json) -> bool { + v.get("v").and_then(Json::as_u64).is_some_and(|found| found > u64::from(gridcraft_model::MACRO_VERSION)) +} + +/// Keeps a macro in a newer format unread, if it is small enough to save back +/// ([`stash::MAX_ITEM_CHARS`] characters of JSON, the size every xlsx save can write). +fn keep_unread(unread: &mut Vec, v: Json, bad: &mut usize) { + if v.to_string().chars().count() <= stash::MAX_ITEM_CHARS { + unread.push(v); + } else { + *bad = bad.saturating_add(1); + } +} + +/// After a file is opened: moves macros out of hidden names into `Workbook::macros`, keeps +/// macros in a newer format unread (`Workbook::macros_unread`), and drops macros that are damaged +/// or over the limits. Returns a warning for what was kept unread and one for what was dropped. pub fn load(wb: &mut Workbook) -> Vec { let mut warnings = Vec::new(); let mut bad = 0usize; + let mut unread = Vec::new(); // Entries of the JSON format's field, each read on its own so a damaged one drops alone. let read = std::mem::take(&mut wb.macros_read); bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS)); - for v in read.iter().take(gridcraft_model::MAX_MACROS) { - match parse_macro(v) { + for v in read.into_iter().take(gridcraft_model::MAX_MACROS) { + if is_newer(&v) { + keep_unread(&mut unread, v, &mut bad); + continue; + } + match parse_macro(&v) { Ok(m) => wb.macros.push(m), Err(_) => bad += 1, } @@ -155,7 +195,12 @@ pub fn load(wb: &mut Workbook) -> Vec { let mut item = Workbook::new(); for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) { item.names = names; - match stash::get_json(&item, &key).ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) { + let v = stash::get_json(&item, &key); + if let Some(v) = v.as_ref().filter(|v| is_newer(v)) { + keep_unread(&mut unread, v.clone(), &mut bad); + continue; + } + match v.ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) { Ok(m) => found.push(m), Err(_) => bad += 1, } @@ -183,9 +228,25 @@ pub fn load(wb: &mut Workbook) -> Vec { Err(_) => bad += 1, } } + // Readable and unread macros together stay within the limit, the readable ones first. + let room = gridcraft_model::MAX_MACROS.saturating_sub(wb.macros.len()); + bad = bad.saturating_add(unread.len().saturating_sub(room)); + unread.truncate(room); + if !unread.is_empty() { + let one = unread.len() == 1; + warnings.push(format!( + "{} macro{} in the workbook {} made by a newer version of GridCraft: this version can't show or run {}, and saving keeps {} unchanged.", + unread.len(), + if one { "" } else { "s" }, + if one { "was" } else { "were" }, + if one { "it" } else { "them" }, + if one { "it" } else { "them" }, + )); + } + wb.macros_unread = std::sync::Arc::new(unread); if bad > 0 { warnings.push(format!( - "{bad} macro{} in the workbook couldn't be opened: {} damaged, made by a newer version of GridCraft, or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).", + "{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).", if bad == 1 { "" } else { "s" }, if bad == 1 { "it is" } else { "they are" }, gridcraft_model::MAX_MACROS, diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 2d55cfe09..80c1b0fb6 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -427,10 +427,11 @@ fn a_damaged_json_macro_drops_alone() { } #[test] -fn a_macro_in_a_newer_format_is_dropped_not_rewritten() { +fn a_macro_in_a_newer_format_is_kept_unread_and_saved_back_unchanged() { // A newer build's macro (`v` 2, with a field this build doesn't know) would lose that field - // here and be saved back as if complete, so it is dropped with the usual warning instead. - // Version 1 and a missing version are read as before. + // if it were read here, so it is never read: it is kept as the file held it, saved back + // unchanged, and the newer build gets it back whole. Version 1 and a missing version are + // read as before. let future = json!({"name": "Future", "v": MACRO_VERSION + 1, "fromALaterBuild": {"x": 1}, "steps": [{"command": "cell.set"}]}); let current = json!({"name": "Current", "v": MACRO_VERSION, "steps": [{"command": "cell.set"}]}); let missing = json!({"name": "Missing"}); @@ -443,17 +444,24 @@ fn a_macro_in_a_newer_format_is_dropped_not_rewritten() { let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); assert_eq!(names, ["Current", "Missing"], "{file}"); assert!(back.macros.iter().all(|m| m.v == MACRO_VERSION), "{file}"); - assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version"), "{file}: {warnings:?}"); - // Saving and opening again leaves no reduced copy of it behind. + assert_eq!(*back.macros_unread, std::slice::from_ref(&future), "{file}"); + assert!( + warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version") && warnings[0].contains("unchanged"), + "{file}: {warnings:?}" + ); + // Saved and opened again, it is still there, whole, and the others are as they were. let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(back, file).unwrap()).unwrap(); - assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(warnings.len(), 1, "{file}: {warnings:?}"); assert_eq!(again.macros, back.macros, "{file}"); + assert_eq!(again.macros_unread, back.macros_unread, "{file}"); }; // The JSON format's field. let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); v["macros"] = json!([future, current, missing]); let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); check("book.json", &back, &warnings); + let saved: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&back, "book.json").unwrap()).unwrap(); + assert!(saved["macros"].as_array().unwrap().contains(&future), "the file holds it as it was"); // Hidden names in xlsx. let mut wb = Workbook::new(); for (i, m) in [&future, ¤t, &missing].into_iter().enumerate() { @@ -465,6 +473,42 @@ fn a_macro_in_a_newer_format_is_dropped_not_rewritten() { check("book.xlsx", &back, &warnings); } +#[test] +fn kept_macros_share_the_save_limits_and_the_numbering() { + // A newer macro too large for the file is left out (keeping it would make every xlsx save + // fail), and readable and unread macros together stay within the macro limit, readable ones + // first. Kept ones are numbered after the readable ones, around a person's visible name. + let newer = |name: &str| json!({"name": name, "v": MACRO_VERSION + 1}); + let mut big = newer("Big"); + big["pad"] = json!(long(crate::cmd::stash::MAX_ITEM_CHARS)); + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + let mut list = vec![big, newer("Kept"), json!({"name": "Read"})]; + file["macros"] = json!(list); + let (mut wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!(*wb.macros_unread, [newer("Kept")]); + assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}"); + wb.names.push(visible("_gc_macro_1_0")); + let (raw, back) = { + let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap(); + let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0); + (raw, crate::io::open_bytes("book.xlsx", &bytes).unwrap().0) + }; + assert_eq!(raw, ["_gc_macro_1_0", "_gc_macro_0_0", "_gc_macro_2_0"]); + assert_eq!((back.macros.len(), back.macros_unread.len()), (1, 1)); + // Past the limit the rest are left out; and when a JSON file's list is full, an unread item + // it also holds in hidden names is the one left out: readable macros come first. + list = (0..MAX_MACROS).map(|i| json!({"name": format!("M{i}")})).collect(); + let mut with_item = Workbook::new(); + with_item.names.push(hidden("_gc_macro_0_0", &newer("InNames").to_string())); + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&with_item, "book.json").unwrap()).unwrap(); + for extra in [None, Some(newer("Over"))] { + file["macros"] = json!(list.iter().cloned().chain(extra).collect::>()); + let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!((wb.macros.len(), wb.macros_unread.len()), (MAX_MACROS, 0)); + assert!(warnings.len() == 1 && warnings[0].contains("couldn't be opened"), "{warnings:?}"); + } +} + #[test] fn many_macros_with_one_name_all_survive_save_and_reopen() { // MAX_MACROS macros with the same name, at the length limit, so the suffixes run from " (2)" diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 5a47b75ed..bafca88f4 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -188,6 +188,11 @@ pub struct Workbook { /// Never written. #[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::macros::entries")] pub macros_read: Vec, + /// Macros in a newer format than this build reads, as the file held them. They are never read, + /// listed or run; a save writes them back unchanged, so the newer build gets them back whole. + /// Shared between snapshots, as nothing changes them. + #[serde(skip)] + pub macros_unread: Arc>, /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. #[serde(skip)] pub preserved: Option>, @@ -236,6 +241,7 @@ impl Workbook { csv_delimiter: None, macros: crate::macros::Macros::default(), macros_read: vec![], + macros_unread: Arc::default(), preserved: None, source_dir: None, source_path: None, diff --git a/docs/macros.md b/docs/macros.md index 3fbd61b2d..ea3156c28 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -66,10 +66,12 @@ is someone's own name: opening a file leaves it alone, and saving stores the mac it does not use. Each macro carries its format version, `v` (1 in this version; a missing `v` reads as 1). When a -file is opened, a macro that is damaged, over the limits or in a newer format (a `v` above 1) is -left out with one warning, and the rest of the workbook and its other macros open. A newer -macro is never read in part, so a save can't write it back with its newer fields missing; a -macro that was left out is not in the saved file. +file is opened, a macro in a newer format (a `v` above 1) is kept unread, with a warning: this +version can't show or run it, and saving writes it back exactly as the file held it, so the newer +version gets it back whole. It is never read in part. Kept macros count toward the 1,000-macro +limit after the readable ones, and one too large for an xlsx file is left out. A macro that is +damaged or over the limits is left out with one warning, and the rest of the workbook and its +other macros open; a macro that was left out is not in the saved file. ## Automate scripts and VBA From da439b71ed0dcf047f7957d6b1933c602f456ddc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:42:42 -0400 Subject: [PATCH 105/166] Tests: a visible clash, a newer macro and a broken input together, through both formats One file with a visible name of the storage shape, a macro in a newer format and one whose input declaration breaks the rules, saved and reopened xlsx then JSON and JSON then xlsx: the visible name stays, the newer macro comes back whole and numbered around the name, the broken one is left out once, and the readable one survives. --- crates/engine/src/macros/tests.rs | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 80c1b0fb6..c3fd230c4 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -671,3 +671,36 @@ fn an_input_declaration_is_checked_as_a_whole() { assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Fine"]); assert_eq!(warnings.len(), 1, "{warnings:?}"); } + +#[test] +fn the_storage_rules_hold_together_through_both_formats() { + // A visible name of the storage shape, a macro in a newer format and one whose input breaks + // the declaration rules, in one file, saved and opened again in each format and then the + // other: the visible name stays, the newer macro comes back whole, the broken one is left out + // once, and the readable one survives. + let newer = json!({"name": "Kept", "v": MACRO_VERSION + 1, "fromALaterBuild": true}); + let broken = json!({"name": "Broken", "inputs": [{"name": "Input", "rows": [5, 2]}]}); + let mut start = Workbook::new(); + start.names.push(visible("_gc_macro_0_0")); + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&start, "book.json").unwrap()).unwrap(); + file["macros"] = json!([newer, broken, {"name": "Good"}]); + let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}"); + let expect = |wb: &Workbook, how: &str| { + assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Good"], "{how}"); + assert_eq!(*wb.macros_unread, std::slice::from_ref(&newer), "{how}"); + assert_eq!(names_of(wb), ["_gc_macro_0_0"], "{how}"); + }; + expect(&wb, "opened"); + for formats in [["book.xlsx", "book.json"], ["book.json", "book.xlsx"]] { + let mut now = wb.clone(); + for file in formats { + let (back, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&now, file).unwrap()).unwrap(); + assert_eq!(warnings.len(), 1, "{file}: only the kept macro is reported again: {warnings:?}"); + expect(&back, file); + now = back; + } + } + let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap(); + assert_eq!(names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0), ["_gc_macro_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]); +} From 4c11929fe2f9ccf3da118d6a345d37be610c5cb1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:44:24 -0400 Subject: [PATCH 106/166] Tests: a visible legacy name of the macro storage shape survives a save The stash reads a legacy _sc_ name as data whether or not it is hidden, so writing key macro_0 removed a person's visible _sc_macro_0 name with no word. The index allocator steps around that key as well; this test fails without it. --- crates/engine/src/macros/tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index c3fd230c4..d87857177 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -260,6 +260,19 @@ fn a_huge_visible_index_takes_no_part_in_numbering() { assert_eq!(names_of(&back), names_of(&wb)); } +#[test] +fn a_visible_legacy_name_of_the_storage_shape_is_kept() { + // The stash reads a legacy `_sc_` name as data whether or not it is hidden, so writing + // key `macro_0` would remove a person's visible `_sc_macro_0`. That key is stepped around too. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + wb.names.push(visible("_sc_macro_0")); + let (raw, back) = xlsx_round_trip(&wb); + assert_eq!(raw, ["_sc_macro_0", "_gc_macro_1_0"]); + assert_eq!(back.macros, wb.macros); + assert_eq!(back.names, vec![visible("_sc_macro_0")]); +} + #[test] fn a_name_defined_after_opening_does_not_block_the_next_save() { // Once a file is open its macros are out of the names, so Define Name accepts a visible From e2851946436b07bbdf80a1cb9575af474901bb07 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:44:24 -0400 Subject: [PATCH 107/166] Docs: a kept macro is saved back unchanged, not byte for byte --- docs/macros.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/macros.md b/docs/macros.md index ea3156c28..486f81e34 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -67,7 +67,7 @@ it does not use. Each macro carries its format version, `v` (1 in this version; a missing `v` reads as 1). When a file is opened, a macro in a newer format (a `v` above 1) is kept unread, with a warning: this -version can't show or run it, and saving writes it back exactly as the file held it, so the newer +version can't show or run it, and saving writes it back unchanged, so the newer version gets it back whole. It is never read in part. Kept macros count toward the 1,000-macro limit after the readable ones, and one too large for an xlsx file is left out. A macro that is damaged or over the limits is left out with one warning, and the rest of the workbook and its From e88760b4802568960f70361dca480fe3ba1a3048 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:10:14 -0400 Subject: [PATCH 108/166] Engine: an item too large to read takes no place in a stash listing stash::items capped the items at MAX_ITEMS before reading them, so one over the size limit took a place (a readable item past the cap was then left out with fewer than MAX_ITEMS listed) and one past the cap was counted in `more` though it could never be listed. Items are now read first: the first MAX_ITEMS readable ones are listed and only readable ones past them are counted. --- crates/engine/src/cmd/stash.rs | 40 +++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 348f00157..9c4554056 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -111,13 +111,20 @@ pub struct Listing { } /// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass -/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit, and -/// the count of the rest. +/// over the names: the first [`MAX_ITEMS`] items that can be read, and the count of the readable +/// ones past them. An item over the size limit is neither listed nor counted, so it takes no +/// place another item could have. ([`keys`] lists keys without reading the items.) pub fn items(wb: &Workbook, prefix: &str) -> Listing> { - let all: Vec<(String, Parts)> = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).collect(); - let more = all.len().saturating_sub(MAX_ITEMS); - let found = all.into_iter().take(MAX_ITEMS).filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text))).collect(); - Listing { found, more } + let mut listing: Listing> = Listing::default(); + for (k, parts) in group(wb, prefix) { + let Some(text) = parts.is_item().then(|| assemble(&parts)).flatten() else { continue }; + if listing.found.len() < MAX_ITEMS { + listing.found.insert(k, text); + } else { + listing.more = listing.more.saturating_add(1); + } + } + listing } /// The text of the item held by `parts`, as [`get`] reads it. @@ -590,6 +597,27 @@ mod tests { assert_eq!((items(&wb, "").found.len(), items(&wb, "").more), (3, 0), "the other item too, none past the cap"); } + #[test] + fn an_item_over_the_size_limit_takes_no_place_in_a_listing() { + // One item too large to read sorts first and one sorts last, around MAX_ITEMS readable + // ones: every readable item is listed, and neither oversized one is counted as more. + let mut wb = Workbook::new(); + let oversized = |wb: &mut Workbook, key: &str| { + for (i, formula) in chunk_formulas(&"x".repeat(MAX_ITEM_CHARS + 1)).into_iter().enumerate() { + wb.names.push(name(&format!("_gc_{key}_{i}"), &formula)); + } + }; + oversized(&mut wb, "macro_0"); + for i in 0..MAX_ITEMS { + wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\""))); + } + oversized(&mut wb, "macro_z"); + assert_eq!(get(&wb, "macro_0"), None, "too large to read"); + let listing = items(&wb, "macro_"); + assert_eq!((listing.found.len(), listing.more), (MAX_ITEMS, 0)); + assert!(listing.found.contains_key("macro_09999")); + } + #[test] fn items_round_trip_through_xlsx() { let mut wb = Workbook::new(); From 3f8e6c288424a94df89b78e7656e740e20567682 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:27:42 -0400 Subject: [PATCH 109/166] Engine: a JSON save refuses a macro that skipped the check, as an xlsx save does Every path that adds a macro runs macros::check, and an xlsx save of a macro that skipped it fails (the stash refuses the oversized item). A JSON save wrote such a macro instead, and the next open dropped it with a warning. json_bytes now checks each macro first, so the save fails in either format with the check's message and the macro stays in the open workbook. Tests that build hostile JSON files write them directly. --- crates/engine/src/macros/mod.rs | 5 +++++ crates/engine/src/macros/tests.rs | 15 +++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 5819e79fd..d236a3f85 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -128,6 +128,11 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { /// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into /// the `macros` list as the file held them. pub fn json_bytes(wb: &Workbook) -> crate::Result> { + // Every path that adds a macro checks it, so this refuses only one that skipped the check, as + // the xlsx save does, instead of writing a macro the next open would drop. + for m in &wb.macros { + check(m).map_err(crate::EngineError::Other)?; + } let bytes = if wb.macros_unread.is_empty() { serde_json::to_vec_pretty(wb) } else { diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index d87857177..dc25de621 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -213,7 +213,11 @@ fn a_macro_built_in_code_goes_through_the_size_check_too() { assert!(check(&big).unwrap_err().contains("too large")); let mut wb = Workbook::new(); wb.macros.push(big); - assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save"); + // A macro that fails `check` fails the save in either format, rather than being written and + // dropped the next time the file opens. + for file in ["book.xlsx", "book.json"] { + assert!(crate::io::save_bytes(&wb, file).is_err(), "{file}"); + } } fn visible(name: &str) -> DefinedName { @@ -326,7 +330,8 @@ fn damaged_or_oversized_macros_are_dropped_with_a_warning() { let mut wb = Workbook::new(); wb.macros = (0..MAX_MACROS + 5).map(|i| Macro { name: format!("M{i}"), ..Macro::default() }).collect(); wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1]; - let bytes = crate::io::save_bytes(&wb, "many.json").unwrap(); + // Written as another program would: a GridCraft save refuses a macro that fails `check`. + let bytes = serde_json::to_vec(&wb).unwrap(); let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap(); // Only the first MAX_MACROS entries are read, as in xlsx. assert_eq!(back.macros.len(), MAX_MACROS - 1); @@ -339,7 +344,8 @@ fn damaged_or_oversized_macros_are_dropped_with_a_warning() { assert!(err.contains("too large"), "{err}"); let mut wb = Workbook::new(); wb.macros.push(Macro { name: "Huge".into(), steps: vec![Step::command("cell.set", json!({"input": huge}))], ..Macro::default() }); - let bytes = crate::io::save_bytes(&wb, "huge.json").unwrap(); + // Written as another program would: a GridCraft save refuses a macro that fails `check`. + let bytes = serde_json::to_vec(&wb).unwrap(); let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap(); assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}"); } @@ -368,7 +374,8 @@ fn json_field_macros_get_the_name_check_too() { let mut wb = Workbook::new(); wb.macros.push(Macro { name: "a\nb".into(), ..Macro::default() }); wb.macros.push(Macro { name: "Fine".into(), ..Macro::default() }); - let bytes = crate::io::save_bytes(&wb, "ctl.json").unwrap(); + // Written as another program would: a GridCraft save refuses a macro that fails `check`. + let bytes = serde_json::to_vec(&wb).unwrap(); let (back, warnings) = crate::io::open_bytes("ctl.json", &bytes).unwrap(); let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); assert_eq!(names, ["Fine"]); From b995c7c9a074effb3591ab94ca02b4d64c4d8309 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:10:29 -0400 Subject: [PATCH 110/166] Engine: a recorded script run keeps the steps that ran The recorder expanded an automate.runScript step from the workbook as it stood when the recording stopped, so a script edited or deleted during the recording changed the saved macro, or dropped the step. The run now leaves the steps it ran with the recorder (Recorder::ran, by step index, within the byte budget), and the conversion reads those, falling back to the workbook only when they could not be kept. --- crates/engine/src/cmd/extra.rs | 9 +++++++++ crates/engine/src/cmd/macros.rs | 2 +- crates/engine/src/lib.rs | 13 ++++++++++++- crates/engine/src/macros/recorder.rs | 13 +++++++++---- crates/engine/src/record.rs | 27 +++++++++++++++++++++++---- crates/engine/src/tests/macros.rs | 23 +++++++++++++++++++++++ docs/macros.md | 3 ++- 7 files changed, 79 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 87b9fe468..b3a548d75 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1074,6 +1074,9 @@ fn run_script(s: &mut Session, p: &Json) -> Result { } let _sandbox = crate::io::ScriptSandbox::enter(); let mut n = 0; + // What ran, for a recorder in progress: the recording keeps these steps with the run, so + // editing or deleting the script before the recording stops does not change what it saved. + let mut ran_steps: Vec<(String, Json)> = Vec::new(); for st in &steps { let id = st["command"].as_str().unwrap_or(""); if id.starts_with("automate.") { @@ -1092,6 +1095,12 @@ fn run_script(s: &mut Session, p: &Json) -> Result { } ran?; n += 1; + if s.recorder.is_some() { + ran_steps.push((id.to_string(), st["params"].clone())); + } + } + if s.recorder.is_some() { + s.script_ran = Some(ran_steps); } Ok(json!({"ran": n})) } diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 6cfd069f7..3dd8fea48 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -234,7 +234,7 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star Some(n) => crate::macros::unique_name(&wb, n), None => crate::macros::next_name(&wb), }; - let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &wb); + let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran, &wb); // Steps past the macro limit, and ones recorded in another workbook, are left out and counted // with the ones the recorder dropped. let cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 983764031..d39ef44d1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -426,6 +426,9 @@ pub struct Session { pub recorder: Option, /// How many `execute` calls are running (0 between top-level commands). depth: u32, + /// The steps the last successful `automate.runScript` ran while a recorder was on, for the + /// recorder to keep with that step (see [`record::Recorder::ran`]); taken by the recording hook. + pub(crate) script_ran: Option>, /// 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)>, @@ -691,7 +694,15 @@ impl Session { { p.insert("on".into(), on.clone()); } - rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params }); + let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params }); + // A script run is kept with the steps it ran, so editing or deleting the script + // before the recording stops does not change what was recorded. + if let Some(ran) = self.script_ran.take() + && kept + && id == "automate.runScript" + { + rec.attach_ran(ran); + } } self.push_journal(id, cmd::journal_params(¶ms)); } diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index 257278b02..54ff10811 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -17,6 +17,7 @@ use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook}; use serde_json::{Map, Value as Json, json}; +use std::collections::BTreeMap; use super::policy::{Policy, macro_policy}; use crate::Selection; @@ -47,7 +48,9 @@ pub struct Converted { } /// Builds a macro called `name` from the recorded steps. -pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: &Workbook) -> Converted { +/// `ran` is what a script step ran, by step index ([`crate::record::Recorder::ran`]); a script step +/// without an entry is read from `wb`. +pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap>, wb: &Workbook) -> Converted { let mut skipped = Vec::new(); // Commands as they'll be played back: (id, params, sheet before, selection before, whether // they came from another macro and stay as written). At most the macro's step limit are @@ -63,7 +66,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & cut = cut.saturating_add(1); } }; - for st in steps.iter().filter(|st| st.doc == start.doc) { + for (index, st) in steps.iter().enumerate().filter(|(_, st)| st.doc == start.doc) { let id = st.id.as_str(); if id == "macro.run" { // Another macro's steps, when they stand on their own: no inputs, no output and only @@ -85,8 +88,10 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], wb: & continue; } if id == "automate.runScript" { - // An Automate script's steps, as the script runs them. - match st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)) { + // An Automate script's steps as they ran, or as the script stands now when the recorder + // could not keep them. + let now = || st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)); + match ran.get(&index).cloned().or_else(now) { Some(steps) => { for (cid, params) in steps { if refused(&cid, ¶ms) { diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 4a5d7c4c9..d4b3599c6 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -8,6 +8,7 @@ use serde::Serialize; use serde_json::Value as Json; +use std::collections::BTreeMap; use crate::Selection; @@ -46,25 +47,43 @@ pub struct Recorder { /// Once a step is dropped, every later one is too, so `steps` is always the beginning of /// what ran, never a sequence with gaps. pub dropped: usize, - /// The size of `steps` serialized as JSON. + /// The size of `steps` and `ran` serialized as JSON. bytes: usize, + /// What a step ran, by its index in `steps`, for a command that runs steps stored in the + /// workbook (`automate.runScript`): the commands as they ran, so a recording keeps them even + /// when the script is edited or deleted before the recording stops. A step without an entry + /// is read from the workbook when the recording is used. + pub ran: BTreeMap>, } impl Recorder { - /// Adds a step, or counts it as dropped once the buffer is full. - pub fn push(&mut self, step: RecordedStep) { + /// Adds a step, or counts it as dropped once the buffer is full; `true` when it was kept. + pub fn push(&mut self, step: RecordedStep) -> bool { // Once a step has been dropped, or the step count is full, the rest are counted without // being serialized (a `file.open` step holds a whole file). if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT { self.dropped = self.dropped.saturating_add(1); - return; + return false; } let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { self.bytes = self.bytes.saturating_add(size); self.steps.push(step); + true } else { self.dropped = self.dropped.saturating_add(1); + false + } + } + + /// Attaches what the last kept step ran ([`Recorder::ran`]), within the byte budget: past it, + /// the step stays and its script is read from the workbook when the recording is used. + pub fn attach_ran(&mut self, steps: Vec<(String, Json)>) { + let Some(index) = self.steps.len().checked_sub(1) else { return }; + let size = serde_json::to_vec(&steps).map_or(0, |v| v.len()); + if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { + self.bytes = self.bytes.saturating_add(size); + self.ran.insert(index, steps); } } } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 8459bbcb1..fa4f4c7ed 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1372,6 +1372,29 @@ fn a_recorded_script_run_leaves_out_the_steps_a_macro_may_not_run() { s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); } +#[test] +fn a_recorded_script_run_keeps_the_steps_that_ran() { + // One script is edited and another deleted before the recording stops: the macro holds the + // steps as they ran, not the scripts as they stand when the recording is saved. + let mut s = s(); + let step = |input: &str| json!([{"command": "cell.set", "params": {"cell": "B1", "input": input}}]); + s.execute("automate.newScript", json!({"name": "Edited", "commands": step("first")})).unwrap(); + s.execute("automate.newScript", json!({"name": "Gone", "commands": step("kept")})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Edited"})).unwrap(); + s.execute("automate.runScript", json!({"name": "Gone"})).unwrap(); + s.execute("automate.newScript", json!({"name": "Edited", "commands": step("changed")})).unwrap(); + s.execute("automate.deleteScript", json!({"name": "Gone"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + let steps = recorded(&mut s, "Rec"); + assert_eq!( + steps.get(..2), + Some(&["cell.set {\"cell\":\"B1\",\"input\":\"first\"}".to_string(), "cell.set {\"cell\":\"B1\",\"input\":\"kept\"}".to_string()][..]), + "{steps:?}" + ); + assert!(!r["skipped"].as_array().unwrap().contains(&json!("automate.runScript")), "{r}"); +} + #[test] fn a_recording_is_saved_in_the_workbook_where_it_started() { let mut s = s(); diff --git a/docs/macros.md b/docs/macros.md index d14c92c00..35f624156 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -155,7 +155,8 @@ inferred. Toggles such as Bold are recorded with the state they set (`"on": true them back sets them rather than flipping them. Commands a macro may not run are left out and listed in the result, also from the steps of a macro or script run while recording; a macro run while recording is replaced by its steps when it has no inputs, no output and only plain commands -(otherwise it is left out and listed), and an Automate script run by the script's steps. +(otherwise it is left out and listed), and an Automate script run by the steps it ran, kept when +it ran: editing or deleting the script before the recording stops changes nothing. ## Other commands and storage From 58608579378798be2f574042dc9f92b30c884ec4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:32:37 -0400 Subject: [PATCH 111/166] Engine: a macro run checks each step for writes over data; the check before the run moves to its own pull request The review asked for the runner to be split. This pull request keeps the policy, the input checks, the step machine and `macro.run`; working out before a run where a macro writes (the footprint dry run, shifting data to make room, `macro.check`) moves to the pull request above it. - Unless the conflict rule is `overwrite`, every run now checks each step as it runs, which the run did before only after a partial footprint: 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 asks (`ask`) or fails, and the run rolls back. - `shiftDown` and `shiftRight` need the footprint, so here they stop the run as `fail` does. - Moved: `Footprint`, `Saved`, `footprint`, `diff`, `make_room`, `macro.check`, the dry-run branches of the machine, `policy::previewable`, `DocState::touched` and the touch log, `Session::journal_dropped` and `truncate_journal`, and the tests of those. - Test: `each_step_is_checked_for_writes_over_data` (the review's counterexample: a permitted file step or Solver, then a write over B1, under `fail` and `ask`). --- crates/engine/src/cmd/macros.rs | 34 +-- crates/engine/src/cmd/mod.rs | 12 - crates/engine/src/lib.rs | 23 -- crates/engine/src/macros/policy.rs | 7 - crates/engine/src/macros/run.rs | 472 ++--------------------------- crates/engine/src/tests/macros.rs | 375 +++-------------------- docs/macros.md | 37 +-- 7 files changed, 100 insertions(+), 860 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 53856e720..d81902b1b 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -6,26 +6,15 @@ 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, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", - has_doc, - run - ), - cmd!( - query "macro.check", - "Check Macro", - [], - None, - "{name, inputs?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}", - has_doc, - check - ), - ] + vec![cmd!( + "macro.run", + "Run Macro", + [], + None, + "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", + has_doc, + run + )] } fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> { @@ -36,8 +25,3 @@ fn run(s: &mut Session, p: &Json) -> Result { let name = name_param("macro.run", p)?; crate::macros::run::run(s, name, p) } - -fn check(s: &mut Session, p: &Json) -> Result { - let name = name_param("macro.check", p)?; - crate::macros::run::check(s, name, p) -} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 90fe4ccc8..6f58f18ee 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -366,13 +366,6 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) if !structural { check_arrays(&d.wb, &wb)?; } - if let Some(log) = d.touched.as_mut() { - for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) { - if let Some(sh) = wb.sheet(*si) { - log.push((sh.name.clone(), *c)); - } - } - } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } @@ -400,11 +393,6 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) Ok(r) } -/// The most written cells a macro's footprint check records. -/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes -/// that kept a cell's value, so a shift is refused then. -pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; - /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9d4d2f93b..151c2bc06 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -206,9 +206,6 @@ 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's footprint is measured: every cell a command wrote, by sheet name, even - /// where it wrote what was already there. - pub touched: 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, @@ -302,7 +299,6 @@ impl DocState { content_kind: None, scripts_trusted, trust, - touched: None, go_to_recent: vec![], } } @@ -438,9 +434,6 @@ pub struct Session { /// Serialized size of each journal entry, measured once when it is pushed, and their sum. journal_sizes: Vec, journal_bytes: usize, - /// Entries the journal has dropped from its front to stay within its limits, ever; a dry run - /// that puts the journal back needs to know how many went while it ran. - journal_dropped: u64, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). pub format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -565,26 +558,10 @@ impl Session { self.journal.drain(..n); self.journal_sizes.drain(..n); self.journal_bytes = kept; - self.journal_dropped = self.journal_dropped.saturating_add(n as u64); self.recording = self.recording.map(|r| r.saturating_sub(n)); } } - /// Entries dropped from the journal's front so far, for a caller that puts the journal back. - pub(crate) fn journal_dropped(&self) -> u64 { - self.journal_dropped - } - - /// Cuts the journal back to its first `keep` entries, with its size bookkeeping. - pub(crate) fn truncate_journal(&mut self, keep: usize) { - if self.journal_sizes.len() == self.journal.len() { - self.journal_sizes.truncate(keep); - self.journal_bytes = self.journal_sizes.iter().sum(); - } - self.journal.truncate(keep); - self.recording = self.recording.map(|r| r.min(self.journal.len())); - } - pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs index 8f23a5b40..57d974920 100644 --- a/crates/engine/src/macros/policy.rs +++ b/crates/engine/src/macros/policy.rs @@ -73,12 +73,6 @@ pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy { } } -/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops -/// before it and reports the footprint as partial. -pub fn previewable(id: &str) -> bool { - id != "data.solver" -} - #[cfg(test)] mod tests { use super::*; @@ -129,7 +123,6 @@ mod tests { assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}"); } - assert!(!previewable("data.solver") && previewable("cell.set")); } #[test] diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index f45bcfc25..8ae1e3496 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -6,12 +6,12 @@ //! 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. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run -//! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it -//! would write outside its inputs that already hold data are conflicts, handled by the output -//! rule: ask, fail, overwrite, or shift the data down or right (and check again). A dry run -//! that stops early (a file step, Solver) proves nothing about later steps, so the run then -//! checks each step as it goes. +//! 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. //! @@ -26,9 +26,9 @@ use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, S use serde::Serialize; use serde_json::{Value as Json, json}; -use super::policy::{Policy, macro_policy, previewable}; +use super::policy::{Policy, macro_policy}; use super::resolve::{self, Env, Place, ResolveError}; -use crate::{EngineError, Result, Selection, Session, Trust, UiRequest}; +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; @@ -37,7 +37,7 @@ pub const MAX_EXECUTED_STEPS: u64 = 100_000; 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 ranges a footprint lists per sheet (the counts stay exact). +/// 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. @@ -421,37 +421,28 @@ pub struct Machine { pub executed: u64, clock: Stopwatch, pub time_limit_ms: f64, - /// Dry run (the footprint check): steps that would touch files or can't be previewed stop - /// the run early instead of running. - pub dry_run: bool, /// A Stop step ended the run: its message, and whether it asked for a rollback. pub stopped: Option<(String, bool)>, - /// A dry run stopped before this step (path, reason): what follows isn't in the footprint. - pub partial: Option<(String, String)>, /// The last step's resolved parameters (for the debugger). pub last_params: Option, - /// Set when the footprint was partial: each step is checked for writes over data as it runs. + /// Set unless the macro may overwrite: each step is checked for writes over data as it runs. guard: Option, } -/// Checks each step of a real run for writes over data the footprint couldn't see. +/// 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>, - /// Where the footprint stopped: "step 2 (file.exportCsv writes a file)". - partial: String, /// The conflict rule is ask. ask: bool, } impl Guard { - fn new(start: Arc, partial: &Json, ask: bool) -> Guard { - let text = |k: &str| partial.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); - let partial = format!("step {} ({})", text("step"), text("reason")); - Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, partial, ask } + 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, @@ -512,7 +503,7 @@ fn deleted(start: &[T], prev: &[T], after: &[T], id: impl Fn(&T) -> u32) -> u } impl Machine { - pub fn new(p: &Prepared, dry_run: bool) -> Machine { + pub fn new(p: &Prepared) -> Machine { let mut m = Machine { name: p.mac.name.clone(), doc: p.doc, @@ -524,9 +515,7 @@ impl Machine { executed: 0, clock: Stopwatch::start(), time_limit_ms: DEFAULT_TIME_LIMIT_MS, - dry_run, stopped: None, - partial: None, last_params: None, guard: None, }; @@ -677,19 +666,9 @@ impl Machine { if !matches!(written, Policy::NeedsPermission(_)) { return Err(fail(format!("{id} {what} only once its parameters are filled in, so it wasn't approved."))); } - if self.dry_run { - self.partial = Some((path.to_string(), format!("{id} {what}"))); - self.frames.clear(); - return Ok(()); - } } (_, Policy::Allow | Policy::Ask(_)) => {} } - if self.dry_run && !previewable(id) { - self.partial = Some((path.to_string(), format!("{id} can't be previewed"))); - self.frames.clear(); - return Ok(()); - } self.last_params = Some(resolved.clone()); let prev = self.guard.as_ref().and_then(|_| s.docs.iter().find(|d| d.uid == self.doc)).map(|d| d.wb.clone()); let queued = s.ui_requests.len(); @@ -714,7 +693,7 @@ impl Machine { 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()))?; let what = g.check(&prev, &after, &self.inputs); if !what.is_empty() { - let msg = format!("It would write over data ({what}); the check before the run stopped at {} and couldn't see this.", g.partial); + let msg = format!("It would write over data ({what})."); return Err(StepError { confirm: g.ask, ..fail(msg) }); } } @@ -764,255 +743,7 @@ fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { } } -// ---------------------------------------------------------------- footprint - -/// What a macro would change, from a dry run on the real workbook. -#[derive(Clone, Debug, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Footprint { - /// Steps executed by the end of the dry run that made this footprint (the run's one budget). - #[serde(skip)] - pub executed: u64, - /// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value - /// may be missing from `cells`: a shift cannot trust the block. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub log_full: bool, - pub sheets: Vec, - pub sheets_added: Vec, - pub sheets_removed: Vec, - pub objects_added: usize, - pub objects_changed: usize, - pub objects_removed: usize, - /// The dry run stopped early before this step; later steps aren't in the footprint. - #[serde(skip_serializing_if = "Option::is_none")] - pub partial: Option, - /// The step the run would fail at. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - /// A Stop step's message. - #[serde(skip_serializing_if = "Option::is_none")] - pub stopped: Option, -} - -/// The cells a macro would write on one sheet, outside its inputs. -#[derive(Clone, Debug, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SheetFootprint { - pub sheet: String, - #[serde(skip)] - pub index: usize, - /// The written cells as ranges (at most 500 listed), and how many cells. - pub cells: Vec, - pub changed: usize, - /// Written cells that hold data now. - pub conflicts: Vec, - pub conflict_count: usize, - /// The block that holds every written cell. - #[serde(skip_serializing_if = "Option::is_none")] - pub bounds: Option, - #[serde(skip)] - pub bounds_range: Option, -} - -impl Footprint { - /// Cells that hold data and would be written, plus sheets and objects that would go. - pub fn conflict_count(&self) -> usize { - self.sheets.iter().map(|s| s.conflict_count).sum::() + self.sheets_removed.len() + self.objects_removed - } - - /// "C2:C9 on Sheet1, the sheet Old, 2 objects". - pub fn describe_conflicts(&self) -> String { - let mut parts: Vec = self - .sheets - .iter() - .filter(|s| s.conflict_count > 0) - .map(|s| { - let mut listed = s.conflicts.iter().take(3).cloned().collect::>().join(", "); - if s.conflicts.len() > 3 { - listed.push_str(", …"); - } - format!("{listed} on {}", s.sheet) - }) - .collect(); - parts.extend(self.sheets_removed.iter().map(|n| format!("the sheet {n}"))); - if self.objects_removed > 0 { - parts.push(format!("{} object{}", self.objects_removed, if self.objects_removed == 1 { "" } else { "s" })); - } - parts.join("; ") - } -} - -/// Session state a dry run may touch, put back afterwards. The workbook, its selection and its -/// history are put back by the dry run's undo group. -struct Saved { - active: usize, - docs: Vec, - /// The workbook as last saved: `sheet.activate` and the view commands move it to the - /// current workbook on a clean document, and the rollback puts the workbook back but not this. - saved: Option>, - sheet_selections: Vec, - unlocked_ranges: Vec<(String, String)>, - trust: Trust, - clipboard: Option, - prefs: crate::Prefs, - mode: crate::Mode, - /// The journal's length and how many entries it had dropped from its front; a dry run that - /// pushes past the journal's limits drops more, which a length alone cannot see. - journal: usize, - journal_dropped: u64, - recording: Option, - ui_requests: usize, - format_painter: Option<(Arc, usize, RangeRef, bool)>, - last_find: Option, - hide_ruler: bool, - hidden_windows: Vec, - draw: (String, String, f32), - comments_pane: bool, - show_all_notes: bool, -} - -impl Saved { - fn take(s: &mut Session, doc: u64) -> Saved { - let d = s.docs.iter().find(|d| d.uid == doc); - Saved { - active: s.active, - docs: s.docs.iter().map(|d| d.uid).collect(), - saved: d.map(|d| d.saved.clone()), - sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), - unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), - trust: d.map_or(Trust::Local, |d| d.trust), - clipboard: s.clipboard.clone(), - prefs: s.prefs.clone(), - mode: s.mode, - journal: s.journal.len(), - journal_dropped: s.journal_dropped(), - recording: s.recording, - ui_requests: s.ui_requests.len(), - format_painter: s.format_painter.clone(), - last_find: s.last_find.clone(), - hide_ruler: s.hide_ruler, - hidden_windows: s.hidden_windows.clone(), - draw: (s.draw_tool.clone(), s.draw_color.clone(), s.draw_width), - comments_pane: s.comments_pane, - show_all_notes: s.show_all_notes, - } - } - - fn restore(self, s: &mut Session, doc: u64) { - s.docs.retain(|d| self.docs.contains(&d.uid)); - if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { - if let Some(saved) = self.saved { - d.saved = saved; - } - d.sheet_selections = self.sheet_selections; - d.unlocked_ranges = self.unlocked_ranges; - d.trust = self.trust; - } - s.active = self.active.min(s.docs.len().saturating_sub(1)); - s.clipboard = self.clipboard; - s.prefs = self.prefs; - s.mode = self.mode; - // Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good, - // and the recording's start moves back with them. - let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX); - s.truncate_journal(self.journal.saturating_sub(dropped)); - s.recording = self.recording.map(|r| r.saturating_sub(dropped)); - s.ui_requests.truncate(self.ui_requests); - s.format_painter = self.format_painter; - s.last_find = self.last_find; - s.hide_ruler = self.hide_ruler; - s.hidden_windows = self.hidden_windows; - (s.draw_tool, s.draw_color, s.draw_width) = self.draw; - s.comments_pane = self.comments_pane; - s.show_all_notes = self.show_all_notes; - } -} - -/// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would -/// change. The dry run stops once `time_limit_ms` has passed on `clock`. -pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result { - let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; - s.set_active(i); - let saved = Saved::take(s, p.doc); - let before = s.doc()?.wb.clone(); - let token = s.group_begin("Check Macro")?; - if let Some(d) = s.docs.iter_mut().find(|d| d.uid == p.doc) { - d.touched = Some(Vec::new()); - } - let mut m = Machine::new(p, true); - (m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed); - let outcome = m.run_to_end(s); - let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { - Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), - None => (before.clone(), Vec::new()), - }; - let rolled = s.group_rollback(token); - saved.restore(s, p.doc); - rolled?; - let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; - let mut fp = diff(&before, &after, &p.inputs, &touched); - fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT; - fp.executed = m.executed; - fp.error = outcome.err(); - fp.stopped = m.stopped.map(|(msg, _)| msg); - fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); - Ok(fp) -} - -/// Compares the workbook before and after a dry run. `touched` adds the cells commands wrote -/// without changing them (a formula written again), so a shift moves everything the macro writes. -fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { - let mut fp = Footprint::default(); - let (pairs, added) = pair_sheets(before, after); - fp.sheets_added = added.into_iter().filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); - for (bi, ai) in pairs { - let Some(bs) = before.sheets.get(bi) else { continue }; - let Some(ai) = ai else { - fp.sheets_removed.push(bs.name.clone()); - continue; - }; - let Some(after_sheet) = after.sheets.get(ai) else { continue }; - let rewritten: BTreeSet = touched.iter().filter(|(n, _)| *n == after_sheet.name).map(|(_, c)| *c).collect(); - if Arc::ptr_eq(bs, after_sheet) && rewritten.is_empty() { - continue; - } - let (added, changed, removed) = objects(bs, after_sheet); - fp.objects_added += added; - fp.objects_changed += changed; - fp.objects_removed += removed; - let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == bi && p.range.contains(c)); - let mut written = Vec::new(); - let mut conflicts = Vec::new(); - let mut cells: BTreeSet = bs.cells.diff(&after_sheet.cells).into_iter().collect(); - cells.extend(rewritten.iter().copied()); - for c in cells { - if in_input(c) { - continue; - } - let (b, a) = (bs.cell(c), after_sheet.cell(c)); - if recalculated_only(b, a) && !rewritten.contains(&c) { - continue; - } - written.push(c); - if b.is_some_and(has_content) && content_differs(b, a) { - conflicts.push(c); - } - } - let Some(first) = written.first() else { continue }; - let bounds = written.iter().fold(RangeRef::cell(*first), |r, c| r.union(&RangeRef::cell(*c))); - fp.sheets.push(SheetFootprint { - sheet: bs.name.clone(), - index: bi, - changed: written.len(), - conflict_count: conflicts.len(), - cells: ranges(&mut written), - conflicts: ranges(&mut conflicts), - bounds: Some(bounds.a1()), - bounds_range: Some(bounds), - }); - } - fp -} +// ---------------------------------------------------------------- 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 @@ -1054,18 +785,6 @@ fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { } } -/// Objects added, changed and removed between two versions of a sheet. -fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { - fn count(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) { - let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count(); - let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count(); - let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); - (added, changed, removed) - } - let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)]; - parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2)) -} - /// 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)); @@ -1092,47 +811,6 @@ pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { merged.iter().take(MAX_LISTED_RANGES).map(RangeRef::a1).collect() } -/// Inserts blank cells where the footprint lands (moving what is there down or right), so the -/// macro writes into empty cells. The block is every written cell's, so a row the macro writes -/// stays together. A shift that would move one of the macro's inputs is refused: the inputs were -/// checked and bound where they are, and only the block's columns (or rows) would move. -fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> { - if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { - return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); - } - if fp.log_full { - return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into())); - } - let wb = s.doc()?.wb.clone(); - let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; - let mut moves = Vec::new(); - for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { - let Some(block) = sh.bounds_range else { continue }; - // What the insertion moves: the block's columns from its top row down, or its rows from - // its left column rightwards. - let moved = |r: &RangeRef| { - if down { - r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row - } else { - r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col - } - }; - // The output is where the macro writes, so moving what is there is the point; the second - // footprint catches data the shift moved into it. - if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) { - return Err(EngineError::Other(format!( - "Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.", - resolve::a1(pl, &env) - ))); - } - moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env)); - } - for range in moves { - s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; - } - Ok(()) -} - // ---------------------------------------------------------------- run /// A macro that passed its checks and is about to run. @@ -1162,62 +840,19 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { Ok(Begun { mac: m, prepared, rule, time_limit_ms, inputs }) } -/// The footprint check and the output rule, inside the run's undo group: an error when the macro -/// would fail or write over data it may not, after shifting the data if the rule says so. Returns -/// the machine for the run and whether it shifted. The dry runs count towards the run's time -/// limit. -/// -/// A partial footprint proves nothing about the steps it didn't reach, so then the machine -/// checks each step as it runs and stops the run at one that writes over data. -pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { - let (m, rule) = (&b.mac, b.rule); - let mut machine = Machine::new(&b.prepared, false); +/// 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 rule == OnConflict::Overwrite { - return Ok((machine, false)); - } - // The dry runs and the run share one step budget as they share one clock. - let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; - machine.executed = fp.executed; - if let Some(e) = fp.error.take() { - return Err(e.into_engine(&m.name)); - } - let mut shifted = false; - if fp.conflict_count() > 0 { - let what = fp.describe_conflicts(); - match rule { - OnConflict::Ask => { - return Err(EngineError::Confirm(format!( - "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", - m.name - ))); - } - OnConflict::Fail | OnConflict::Overwrite => { - return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))); - } - OnConflict::ShiftDown | OnConflict::ShiftRight => { - make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?; - fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; - machine.executed = fp.executed; - if let Some(e) = fp.error.take() { - return Err(e.into_engine(&m.name)); - } - if fp.conflict_count() > 0 { - return Err(EngineError::Other(format!( - "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", - m.name, - fp.describe_conflicts() - ))); - } - shifted = true; - } - } - } - if let Some(p) = &fp.partial { + 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, p, rule == OnConflict::Ask)); + machine.guard = Some(Guard::new(start, b.rule == OnConflict::Ask)); } - Ok((machine, shifted)) + Ok(machine) } /// Runs macro `name` on the active workbook: `{inputs?, onConflict?, overwrite?, approve?, @@ -1226,21 +861,16 @@ 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, shifted) = make_way(s, &b)?; + let mut machine = make_way(s, &b)?; let name = &b.mac.name; - // `ran` counts the run's own steps; the budget also holds the dry runs'. - let before_run = machine.executed; 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} Nothing was changed."))), stopped => { - let mut out = json!({"macro": name, "ran": machine.executed.saturating_sub(before_run), "inputs": b.inputs}); + let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs}); if let Some((msg, _)) = stopped { out["stopped"] = json!(msg); } - if shifted { - out["shifted"] = json!(b.rule.name()); - } Ok(out) } } @@ -1265,49 +895,3 @@ fn conflict_rule(p: &Json, m: &Macro) -> Result { } Ok(rule) } - -/// `macro.check`: what running the macro would do, without changing anything. -pub fn check(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}'.")))?; - let mut out = json!({"macro": m.name, "ok": false, "onConflict": m.output.on_conflict.name()}); - let asks = match gate(&m, trust) { - Ok(a) => a, - Err(e) => { - out["errors"] = json!([e.to_string()]); - return Ok(out); - } - }; - let approved = s.macro_approvals.contains(&(doc, super::fingerprint(&m))); - if !asks.is_empty() && !approved { - // Nothing from an unapproved macro runs, not even in a dry run. - out["needsApproval"] = json!(asks); - } - let prepared = match prepare(s, &m, p.get("inputs")) { - Ok(pr) => pr, - Err(e) => { - out["errors"] = json!([e.to_string()]); - return Ok(out); - } - }; - out["inputs"] = prepared.inputs_json(&s.doc()?.wb); - if let Some(o) = prepared.output { - let wb = &s.doc()?.wb; - out["output"] = json!(resolve::a1(o, &Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None })); - } - if !asks.is_empty() && !approved { - return Ok(out); - } - let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p), 0)?; - out["ok"] = json!(fp.error.is_none()); - out["conflicts"] = json!(fp.conflict_count()); - if let Some(e) = &fp.error { - out["errors"] = json!([e.describe()]); - } - out["footprint"] = serde_json::to_value(&fp).unwrap_or(Json::Null); - Ok(out) -} diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 672c28d24..262f50af9 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1,4 +1,5 @@ -//! Macros: running, input checks, the footprint dry run, the policy and the limits. +//! Macros: running, input checks, checking each step for writes over data, the policy and the +//! limits. use std::sync::Arc; @@ -156,23 +157,18 @@ fn conflicts_ask_fail_or_overwrite() { 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("would write over cells that hold data (E3 on Sheet1)"), "{q}"); + 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!(s.execute("macro.run", json!({"name": "Totals", "onConflict": "sideways"})).is_err()); - - // macro.check shows the footprint and the conflict, and changes nothing. - let journal = s.journal.len(); - let sel = s.doc().unwrap().selection.clone(); - let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap(); - assert_eq!(c["ok"], true); - assert_eq!(c["conflicts"], 1); - assert_eq!(c["output"], "E2"); - assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["E2:E3"])); - assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["E3"])); assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); - assert_eq!((s.journal.len(), &s.doc().unwrap().selection), (journal, &sel)); + // 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(); @@ -181,181 +177,45 @@ fn conflicts_ask_fail_or_overwrite() { } #[test] -fn shifting_makes_room_and_is_checked_again() { - for (rule, moved) in [("shiftDown", ["E4", "E5"]), ("shiftRight", ["F2", "F3"])] { - let mut s = s(); - data(&mut s); - set(&mut s, "E2", "old 1"); - set(&mut s, "E3", "old 2"); - put(&mut s, totals()); - let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": rule})).unwrap(); - assert_eq!(r["shifted"], rule); - assert_eq!((v(&s, "E2").display(), v(&s, "E3").display()), ("Total".into(), "400".into()), "{rule}"); - assert_eq!((v(&s, moved[0]).display(), v(&s, moved[1]).display()), ("old 1".into(), "old 2".into()), "{rule}"); - // The shift and the run are one undo step. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(v(&s, "E2").display(), "old 1", "{rule}"); - assert!(v(&s, moved[1]).is_empty(), "{rule}"); +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}"); + 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 cell written with what it already holds isn't a conflict, but it moves with the others, - // so rows stay together. + // A step that writes over what the macro itself wrote isn't a conflict. let mut s = super::s(); - for (cell, input) in [("F2", "x"), ("G2", "b"), ("F3", "f3"), ("G3", "g3")] { - set(&mut s, cell, input); - } + let again = |input: &str| json!({"command": "cell.set", "params": {"cell": "E1", "input": input}}); put( &mut s, - json!({"name": "Pair", "output": {"onConflict": "shiftDown"}, "steps": [ - {"command": "cell.set", "params": {"cell": "F2", "input": "a"}}, - {"command": "cell.set", "params": {"cell": "G2", "input": "b"}} - ]}), + json!({"name": "Twice", "permissions": ["file.exportCsv"], "output": {"onConflict": "fail"}, "steps": [again("a"), export, again("b")]}), ); - let c = s.execute("macro.check", json!({"name": "Pair"})).unwrap(); - assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["sheets"][0]["cells"]), (Some(1), &json!(["F2:G2"]))); - s.execute("macro.run", json!({"name": "Pair"})).unwrap(); - let row = |s: &Session, r: u32| (v(s, &format!("F{r}")).display(), v(s, &format!("G{r}")).display()); - assert_eq!(row(&s, 2), ("a".into(), "b".into())); - assert_eq!(row(&s, 3), ("x".into(), "b".into())); - assert_eq!(row(&s, 4), ("f3".into(), "g3".into())); - // An anchor that moves with the shifted data still lands on it: the second check refuses. - let mut s = super::s(); - data(&mut s); - set(&mut s, "E2", "old"); - s.execute("formulas.defineName", json!({"name": "Target", "refersTo": "=Sheet1!$E$2"})).unwrap(); - put( - &mut s, - json!({"name": "Named", "steps": [{"command": "cell.set", "params": {"cell": "Name:Target", "input": "new"}}], "output": {"onConflict": "shiftDown"}}), - ); - let start = s.doc().unwrap().wb.clone(); - let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string(); - assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}"); - assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); -} - -#[test] -fn a_shift_that_would_move_an_input_is_refused() { - // A shift inserts cells over the conflicting block, which moves the block's columns (or rows) - // from there on. When one of the macro's own inputs lies in the way, its data would move and - // shear while the run keeps the old coordinates, so the shift is refused and nothing changes. - let mut s = s(); - data(&mut s); - set(&mut s, "B1", "Old title"); - put( - &mut s, - json!({"name": "Title", "inputs": [{"name": "Input", "header": true}], "steps": [ - {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} - ]}), - ); - let start = s.doc().unwrap().wb.clone(); - let err = s.execute("macro.run", json!({"name": "Title", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); - assert!(err.contains("would move Input (") && err.contains("B2:C5"), "{err}"); - assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing changed"); - assert_eq!((v(&s, "B1").display(), v(&s, "B3").display()), ("Old title".into(), "Ann".into())); - // Writing left of the input and shifting right would move it too. - put( - &mut s, - json!({"name": "Left", "inputs": [{"name": "Input", "header": true}], "steps": [ - {"command": "cell.set", "params": {"cell": "Input.offset(1,-1).cell(1,1)", "input": "x"}} - ]}), - ); - set(&mut s, "A3", "in the way"); - let err = s.execute("macro.run", json!({"name": "Left", "inputs": "B2:C5", "onConflict": "shiftRight"})).unwrap_err().to_string(); - assert!(err.contains("would move Input"), "{err}"); - // The output counts as well. - put( - &mut s, - json!({"name": "Out", "inputs": [{"name": "Input", "header": true}], "output": {"anchor": "Input.below(1)"}, "steps": [ - {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} - ]}), - ); - let err = s.execute("macro.run", json!({"name": "Out", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); - assert!(err.contains("would move Input"), "{err}"); - // A shift that moves nothing of the macro's own still works, over the block of every written - // cell, so a row the macro writes stays together. - set(&mut s, "H1", "elsewhere"); - put( - &mut s, - json!({"name": "Wide", "inputs": [{"name": "Input", "header": true}], "steps": [ - {"command": "cell.set", "params": {"cell": "H1", "input": "over"}}, - {"command": "cell.set", "params": {"cell": "H9", "input": "far"}} - ]}), - ); - s.execute("macro.run", json!({"name": "Wide", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap(); - assert_eq!((v(&s, "H1").display(), v(&s, "H9").display(), v(&s, "H10").display()), ("over".into(), "far".into(), "elsewhere".into())); - assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move"); -} - -#[test] -fn a_shift_is_refused_when_the_footprint_cannot_follow_every_write() { - // The touch log keeps at most a million writes; past that, a write that kept a cell's value - // is missing from the footprint, so a shift could split a row the macro writes. The footprint - // says so and the shift is refused (overwrite, with no dry run, still runs). - let mut s = s(); - set(&mut s, "A1", "x"); - set(&mut s, "E2", "old"); - put( - &mut s, - json!({"name": "Flood", "steps": [ - {"command": "edit.fillDown", "params": {"range": "A1:A1000001"}}, - {"command": "cell.set", "params": {"cell": "E2", "input": "new"}} - ]}), - ); - let c = s.execute("macro.check", json!({"name": "Flood"})).unwrap(); - assert_eq!(c["footprint"]["logFull"], true, "{c}"); - let err = s.execute("macro.run", json!({"name": "Flood", "onConflict": "shiftDown"})).unwrap_err().to_string(); - assert!(err.contains("more cells than the footprint can follow"), "{err}"); - assert_eq!(v(&s, "E2").display(), "old", "nothing changed"); -} - -#[test] -fn a_dry_run_leaves_a_clean_workbook_clean() { - // `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean - // document; the dry run's rollback puts the workbook back, so the mark must come back too. - let mut s = s(); - data(&mut s); - let d = s.doc_mut().unwrap(); - d.saved = d.wb.clone(); - assert!(!s.doc().unwrap().is_dirty()); - put(&mut s, json!({"name": "Look", "steps": [{"command": "view.zoom", "params": {"percent": 120}}]})); - s.execute("macro.check", json!({"name": "Look"})).unwrap(); - assert!(!s.doc().unwrap().is_dirty(), "a dry run changed nothing"); - // A run refused by the conflict rule changed nothing either. - put(&mut s, totals()); - set(&mut s, "E3", "taken"); - let d = s.doc_mut().unwrap(); - d.saved = d.wb.clone(); - assert!(s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).is_err()); - assert!(!s.doc().unwrap().is_dirty()); -} - -#[test] -fn a_dry_run_puts_the_journal_back_even_after_it_trimmed_it() { - // Every nested command a dry run executes is journaled, so a long macro pushes the journal - // past its limit and the journal drops its oldest entries. Putting the journal back by its - // old length would then keep entries from the dry run and point a recording at the wrong - // place; the restore counts what was dropped. - let mut s = s(); - data(&mut s); - s.execute("selection.set", json!({"range": "A1"})).unwrap(); - let journal = s.journal.clone(); - s.recording = Some(journal.len().saturating_sub(1)); - put( - &mut s, - json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A200", "body": [ - {"type": "repeat", "over": "A1:A60", "body": [{"command": "cell.set", "params": {"cell": "Row.cell(1,1)", "input": "x"}}]} - ]}]}), - ); - s.execute("macro.check", json!({"name": "Long"})).unwrap(); - let sets = |j: &[(String, Json)]| j.iter().filter(|(id, _)| id == "cell.set").count(); - assert!(sets(&s.journal) <= sets(&journal), "no dry-run entry stays"); - assert!(s.journal.len() <= journal.len(), "{} entries, had {}", s.journal.len(), journal.len()); - assert!(journal.ends_with(&s.journal), "what stays is the journal's own tail"); - assert!(s.recording.is_some_and(|r| r <= s.journal.len()), "{:?}", s.recording); - // A short dry run leaves the journal exactly as it was. - put(&mut s, json!({"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "x"}}]})); - let (journal, recording) = (s.journal.clone(), s.recording); - s.execute("macro.check", json!({"name": "Short"})).unwrap(); - assert_eq!((&s.journal, s.recording), (&journal, recording)); + 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] @@ -372,66 +232,6 @@ fn the_answer_to_the_question_counts_however_ask_was_given() { assert_eq!(v(&s, "E3").display(), "400"); } -#[test] -fn the_step_limit_is_one_budget_across_the_dry_run_and_the_run() { - // 300 x 300 passes is under the limit for one phase; the dry run and the run together are not. - let mut s = s(); - put( - &mut s, - json!({"name": "Spin", "steps": [{"type": "repeat", "over": "A1:A300", "body": [ - {"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} - ]}]}), - ); - let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); - assert!(err.contains("more than 100000 steps"), "{err}"); - // With no dry run (overwrite), the same macro fits. - s.execute("macro.run", json!({"name": "Spin", "onConflict": "overwrite"})).unwrap(); -} - -#[test] -fn the_footprint_covers_other_sheets_objects_and_edges() { - let mut s = s(); - data(&mut s); - s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); - set(&mut s, "A1", "note"); - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - s.execute("insert.chart", json!({"range": "B2:C5", "type": "column", "at": "H2"})).unwrap(); - let chart = s.doc().unwrap().wb.sheet(0).unwrap().charts[0].id; - put( - &mut s, - json!({"name": "Wide", "output": {"onConflict": "fail"}, "steps": [ - {"command": "cell.set", "params": {"cell": "Notes!A1", "input": "x"}}, - {"command": "cell.set", "params": {"cell": "Notes!B2", "input": "y"}}, - {"command": "home.insertSheet", "params": {"name": "Extra"}}, - {"command": "sheet.activate", "params": {"sheet": "Sheet1"}}, - {"command": "insert.shape", "params": {"kind": "ellipse", "at": "J2"}}, - {"command": "object.delete", "params": {"kind": "chart", "id": chart}} - ]}), - ); - let c = s.execute("macro.check", json!({"name": "Wide", "inputs": {}})).unwrap(); - let fp = &c["footprint"]; - assert_eq!( - fp["sheets"], - json!([{"sheet": "Notes", "cells": ["A1", "B2"], "changed": 2, "conflicts": ["A1"], "conflictCount": 1, "bounds": "A1:B2"}]) - ); - assert_eq!(fp["sheetsAdded"], json!(["Extra"])); - assert_eq!((fp["objectsAdded"].as_u64(), fp["objectsRemoved"].as_u64()), (Some(1), Some(1))); - assert_eq!(c["conflicts"], 2, "the deleted chart and the note"); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2, "the dry run's new sheet went away"); - let err = s.execute("macro.run", json!({"name": "Wide"})).unwrap_err().to_string(); - assert!(err.contains("(A1 on Notes; 1 object)"), "{err}"); - - // Running past the edge of the sheet fails before anything changes. - put( - &mut s, - json!({"name": "Edge", "inputs": [{"name": "Input"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.offset(-3,0)", "input": "x"}}]}), - ); - let err = s.execute("macro.run", json!({"name": "Edge", "inputs": "B2"})).unwrap_err().to_string(); - assert!(err.contains("Step 1 (cell.set): 'Input.offset(-3,0)' runs past the top of the sheet."), "{err}"); - let c = s.execute("macro.check", json!({"name": "Edge", "inputs": "B2"})).unwrap(); - assert_eq!((c["ok"].as_bool(), c["footprint"]["error"]["edge"].as_bool()), (Some(false), Some(true))); -} - #[test] fn repeat_if_and_stop() { let mut s = s(); @@ -512,7 +312,7 @@ fn macros_are_bounded() { 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}"); - // The footprint's dry run keeps to the time limit too, and so does the check's. + // 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": [ @@ -521,8 +321,6 @@ fn macros_are_bounded() { ); let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string(); assert!(err.contains("ran longer than"), "{err}"); - let c = s.execute("macro.check", json!({"name": "Spin", "timeLimit": 0.001})).unwrap(); - assert!(c["errors"][0].as_str().unwrap().contains("ran longer than"), "{c}"); } #[test] @@ -535,33 +333,22 @@ fn a_token_in_a_path_needs_the_permission_and_approval() { 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, not even the check's dry run. + // 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}"); - let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); - assert!(c["errors"][0].as_str().unwrap().contains("writes a file"), "{path}: {c}"); - assert!(c.get("footprint").is_none()); - // With it, the person approves first; the check doesn't run it before then. + // 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}"); - let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); - assert_eq!(c["needsApproval"], json!(["runs file.exportCsv, which writes a file"]), "{path}"); - assert!(c.get("footprint").is_none()); assert_eq!(files(&dir), 0, "{path}"); assert!(!std::path::Path::new("A1.csv").exists() && !std::path::Path::new(" .csv").exists()); } - // Once approved, the check's dry run stops before the step, and only the run writes the file. + // 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); - std::fs::remove_dir_all(&dir).unwrap(); - std::fs::create_dir_all(&dir).unwrap(); - let c = s.execute("macro.check", json!({"name": "Export", "inputs": "B2"})).unwrap(); - assert_eq!(c["footprint"]["partial"]["step"], "1", "{c}"); - assert_eq!(files(&dir), 0, "the check writes nothing"); let _ = std::fs::remove_dir_all(&dir); } @@ -604,16 +391,10 @@ fn the_policy_keeps_macros_away_from_files_and_the_app() { 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); - let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); - assert_eq!(c["needsApproval"], json!(["runs data.fromTextCsv, which reads a file"])); - assert!(c.get("footprint").is_none(), "nothing unapproved runs, even dry"); 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(); - // The dry run skips the file step and says so. - let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); - assert_eq!(c["footprint"]["partial"]["step"], "1"); // 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(_)))); @@ -658,63 +439,3 @@ fn macros_from_a_file_ask_before_running() { 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)); } - -#[test] -fn a_partial_footprint_doesnt_clear_later_writes() { - let dir = std::env::temp_dir().join(format!("gc-macro-partial-{}", 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"}}); - // The dry run stops at an approved file step or at Solver; the step after it writes over B1. - 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(); - let c = s.execute("macro.check", json!({"name": "Late", "inputs": "A1"})).unwrap(); - if c.get("needsApproval").is_none() { - assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["partial"]["step"]), (Some(0), &json!("1")), "{c}"); - } - 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}"); - 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"); - // After a shift, the steps past the file step are checked too. - let mut s = super::s(); - set(&mut s, "C1", "old"); - set(&mut s, "D1", "keep"); - let step = |cell: &str| json!({"command": "cell.set", "params": {"cell": cell, "input": "new"}}); - put( - &mut s, - json!({"name": "Shift", "permissions": ["file.exportCsv"], "output": {"onConflict": "shiftDown"}, "steps": [step("C1"), export, step("D1")]}), - ); - let start = s.doc().unwrap().wb.clone(); - let err = s.execute("macro.run", json!({"name": "Shift", "approve": true})).unwrap_err().to_string(); - assert!(err.contains("Step 3 (cell.set): It would write over data (D1 on Sheet1)"), "{err}"); - assert_eq!((v(&s, "C1").display(), v(&s, "D1").display()), ("old".into(), "keep".into())); - assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "the shift is rolled back too"); - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/docs/macros.md b/docs/macros.md index 4b653ff7a..daf0a2c35 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,16 +1,17 @@ # Macros -> **Last updated:** 2026-10-24 · **Change:** minor (running and checking macros: `macro.run`, `macro.check`) +> **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, runs as one undo step, rolls all workbook changes back on an -error (a file it already wrote stays), checks where its results will land before it writes them, -and does not touch files without permission. +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, its tokens, and running and checking macros. The engine has -no macro debugger, recorder or management commands in this version, and no macro interface. +This page describes the macro format, its tokens, and running macros. The engine has no check of a +macro before it runs, no macro debugger, recorder or management commands in this version, and no +macro interface. ## A macro @@ -59,9 +60,8 @@ no macro debugger, recorder or management commands in this version, and no macro 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) and takes at most 60 seconds, both shared by the footprint dry - runs and the run itself (`timeLimit` to change the time; the web build has no clock, so there - only the step limit applies). + pass through a Repeat counts) and takes at most 60 seconds (`timeLimit` to change the time; + the web build has no clock, so there only the step limit applies). ## Tokens @@ -108,24 +108,17 @@ refused rather than read as the header. 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. -2. **Footprint.** Unless the conflict rule is `overwrite`, the macro first runs in dry-run mode on - the real workbook, and everything is put back. Cells it would write outside its - inputs that hold data, and sheets and objects it would - delete, are conflicts, handled by `onConflict` (or the macro's `output.onConflict`): `ask` (the - call fails with `confirm: …`; `overwrite: true` answers it), `fail`, `overwrite`, `shiftDown` or - `shiftRight` (Insert Cells makes room, then the footprint is checked again). Steps that touch - files, and Solver, end the dry run early; the footprint then says it is partial, and unless - the rule is `overwrite`, the run checks every step as it goes. A step that writes over cells, - sheets or objects that held data when the run started stops the run and rolls the workbook - back (a file already written stays): it asks (`confirm: …`) under `ask` and fails otherwise, - since shifting can't make room that late. +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). 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). -`macro.check {name, inputs?, timeLimit?}` reports the bound inputs, the output anchor, any -approval needed and the footprint, without changing anything. - ## Storage Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden From 0719458114b646e1d3d978e4e00a5ea8918f159f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:33:26 -0400 Subject: [PATCH 112/166] Engine: macro.check and the footprint: where a macro writes, worked out before it runs Split from the pull request below at the review's request; the code is the runner's footprint as reviewed there, unchanged. - `macro.run` first runs the macro in dry-run mode on the real workbook (unless the rule is `overwrite`) and puts everything back: the workbook, its selection and history through the dry run's undo group, and the session state a dry run may touch (`Saved`: the active workbook, open documents, the saved mark, selections, unlocked ranges, trust, clipboard, preferences, mode, journal and recording, queued requests, format painter, find, ruler, hidden windows, drawing tool and the comment panes). - Cells it would write outside its inputs that hold data, and sheets and objects it would delete, are conflicts: `ask` asks before anything runs, `fail` fails, and `shiftDown` and `shiftRight` insert cells over the block of every written cell and check again. A shift is refused when it would move an input, when the macro deletes sheets or objects, or when the touch log is full. - A dry run stops before a file step and before Solver; the footprint is then partial, and the run checks each step as it goes. - The dry runs and the run share one clock, one time limit and one step budget. - `macro.check` reports the inputs, the output anchor, approval needed and the footprint. - Tests: the footprint tests that moved with this code. --- crates/engine/src/cmd/macros.rs | 34 ++- crates/engine/src/cmd/mod.rs | 12 + crates/engine/src/lib.rs | 23 ++ crates/engine/src/macros/policy.rs | 7 + crates/engine/src/macros/run.rs | 474 +++++++++++++++++++++++++++-- crates/engine/src/tests/macros.rs | 349 ++++++++++++++++++++- docs/macros.md | 37 ++- 7 files changed, 869 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index d81902b1b..53856e720 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -6,15 +6,26 @@ 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, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", - has_doc, - run - )] + vec![ + cmd!( + "macro.run", + "Run Macro", + [], + None, + "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", + has_doc, + run + ), + cmd!( + query "macro.check", + "Check Macro", + [], + None, + "{name, inputs?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}", + has_doc, + check + ), + ] } fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> { @@ -25,3 +36,8 @@ fn run(s: &mut Session, p: &Json) -> Result { let name = name_param("macro.run", p)?; crate::macros::run::run(s, name, p) } + +fn check(s: &mut Session, p: &Json) -> Result { + let name = name_param("macro.check", p)?; + crate::macros::run::check(s, name, p) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6f58f18ee..90fe4ccc8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -366,6 +366,13 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) if !structural { check_arrays(&d.wb, &wb)?; } + if let Some(log) = d.touched.as_mut() { + for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) { + if let Some(sh) = wb.sheet(*si) { + log.push((sh.name.clone(), *c)); + } + } + } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } @@ -393,6 +400,11 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) Ok(r) } +/// The most written cells a macro's footprint check records. +/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes +/// that kept a cell's value, so a shift is refused then. +pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; + /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 151c2bc06..9d4d2f93b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -206,6 +206,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's footprint is measured: every cell a command wrote, by sheet name, even + /// where it wrote what was already there. + pub touched: 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, @@ -299,6 +302,7 @@ impl DocState { content_kind: None, scripts_trusted, trust, + touched: None, go_to_recent: vec![], } } @@ -434,6 +438,9 @@ pub struct Session { /// Serialized size of each journal entry, measured once when it is pushed, and their sum. journal_sizes: Vec, journal_bytes: usize, + /// Entries the journal has dropped from its front to stay within its limits, ever; a dry run + /// that puts the journal back needs to know how many went while it ran. + journal_dropped: u64, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). pub format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -558,10 +565,26 @@ impl Session { self.journal.drain(..n); self.journal_sizes.drain(..n); self.journal_bytes = kept; + self.journal_dropped = self.journal_dropped.saturating_add(n as u64); self.recording = self.recording.map(|r| r.saturating_sub(n)); } } + /// Entries dropped from the journal's front so far, for a caller that puts the journal back. + pub(crate) fn journal_dropped(&self) -> u64 { + self.journal_dropped + } + + /// Cuts the journal back to its first `keep` entries, with its size bookkeeping. + pub(crate) fn truncate_journal(&mut self, keep: usize) { + if self.journal_sizes.len() == self.journal.len() { + self.journal_sizes.truncate(keep); + self.journal_bytes = self.journal_sizes.iter().sum(); + } + self.journal.truncate(keep); + self.recording = self.recording.map(|r| r.min(self.journal.len())); + } + pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs index 57d974920..8f23a5b40 100644 --- a/crates/engine/src/macros/policy.rs +++ b/crates/engine/src/macros/policy.rs @@ -73,6 +73,12 @@ pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy { } } +/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops +/// before it and reports the footprint as partial. +pub fn previewable(id: &str) -> bool { + id != "data.solver" +} + #[cfg(test)] mod tests { use super::*; @@ -123,6 +129,7 @@ mod tests { assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}"); } + assert!(!previewable("data.solver") && previewable("cell.set")); } #[test] diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 8ae1e3496..f45bcfc25 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -6,12 +6,12 @@ //! 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. +//! 3. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run +//! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it +//! would write outside its inputs that already hold data are conflicts, handled by the output +//! rule: ask, fail, overwrite, or shift the data down or right (and check again). A dry run +//! that stops early (a file step, Solver) proves nothing about later steps, so the run then +//! checks each step as it goes. //! 4. **Run**: one undo step (`Session::grouped`); an error at any step rolls everything back and //! names the step. //! @@ -26,9 +26,9 @@ use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, S use serde::Serialize; use serde_json::{Value as Json, json}; -use super::policy::{Policy, macro_policy}; +use super::policy::{Policy, macro_policy, previewable}; use super::resolve::{self, Env, Place, ResolveError}; -use crate::{EngineError, Result, Session, Trust, UiRequest}; +use crate::{EngineError, Result, Selection, Session, Trust, UiRequest}; /// The most steps one run executes (counting each pass through a Repeat). pub const MAX_EXECUTED_STEPS: u64 = 100_000; @@ -37,7 +37,7 @@ pub const MAX_EXECUTED_STEPS: u64 = 100_000; 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 ranges a list of cells gives per sheet (the counts stay exact). +/// The most ranges a footprint lists 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. @@ -421,28 +421,37 @@ pub struct Machine { pub executed: u64, clock: Stopwatch, pub time_limit_ms: f64, + /// Dry run (the footprint check): steps that would touch files or can't be previewed stop + /// the run early instead of running. + pub dry_run: bool, /// A Stop step ended the run: its message, and whether it asked for a rollback. pub stopped: Option<(String, bool)>, + /// A dry run stopped before this step (path, reason): what follows isn't in the footprint. + pub partial: Option<(String, String)>, /// The last step's resolved parameters (for the debugger). pub last_params: Option, - /// Set unless the macro may overwrite: each step is checked for writes over data as it runs. + /// Set when the footprint was partial: 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. +/// Checks each step of a real run for writes over data the footprint couldn't see. #[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>, + /// Where the footprint stopped: "step 2 (file.exportCsv writes a file)". + partial: String, /// 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 } + fn new(start: Arc, partial: &Json, ask: bool) -> Guard { + let text = |k: &str| partial.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); + let partial = format!("step {} ({})", text("step"), text("reason")); + Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, partial, ask } } /// What one step wrote over that held data when the run started: cells outside the inputs, @@ -503,7 +512,7 @@ fn deleted(start: &[T], prev: &[T], after: &[T], id: impl Fn(&T) -> u32) -> u } impl Machine { - pub fn new(p: &Prepared) -> Machine { + pub fn new(p: &Prepared, dry_run: bool) -> Machine { let mut m = Machine { name: p.mac.name.clone(), doc: p.doc, @@ -515,7 +524,9 @@ impl Machine { executed: 0, clock: Stopwatch::start(), time_limit_ms: DEFAULT_TIME_LIMIT_MS, + dry_run, stopped: None, + partial: None, last_params: None, guard: None, }; @@ -666,9 +677,19 @@ impl Machine { if !matches!(written, Policy::NeedsPermission(_)) { return Err(fail(format!("{id} {what} only once its parameters are filled in, so it wasn't approved."))); } + if self.dry_run { + self.partial = Some((path.to_string(), format!("{id} {what}"))); + self.frames.clear(); + return Ok(()); + } } (_, Policy::Allow | Policy::Ask(_)) => {} } + if self.dry_run && !previewable(id) { + self.partial = Some((path.to_string(), format!("{id} can't be previewed"))); + self.frames.clear(); + return Ok(()); + } self.last_params = Some(resolved.clone()); let prev = self.guard.as_ref().and_then(|_| s.docs.iter().find(|d| d.uid == self.doc)).map(|d| d.wb.clone()); let queued = s.ui_requests.len(); @@ -693,7 +714,7 @@ impl Machine { 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()))?; let what = g.check(&prev, &after, &self.inputs); if !what.is_empty() { - let msg = format!("It would write over data ({what})."); + let msg = format!("It would write over data ({what}); the check before the run stopped at {} and couldn't see this.", g.partial); return Err(StepError { confirm: g.ask, ..fail(msg) }); } } @@ -743,7 +764,255 @@ fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { } } -// ---------------------------------------------------------------- comparing workbooks +// ---------------------------------------------------------------- footprint + +/// What a macro would change, from a dry run on the real workbook. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Footprint { + /// Steps executed by the end of the dry run that made this footprint (the run's one budget). + #[serde(skip)] + pub executed: u64, + /// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value + /// may be missing from `cells`: a shift cannot trust the block. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub log_full: bool, + pub sheets: Vec, + pub sheets_added: Vec, + pub sheets_removed: Vec, + pub objects_added: usize, + pub objects_changed: usize, + pub objects_removed: usize, + /// The dry run stopped early before this step; later steps aren't in the footprint. + #[serde(skip_serializing_if = "Option::is_none")] + pub partial: Option, + /// The step the run would fail at. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// A Stop step's message. + #[serde(skip_serializing_if = "Option::is_none")] + pub stopped: Option, +} + +/// The cells a macro would write on one sheet, outside its inputs. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetFootprint { + pub sheet: String, + #[serde(skip)] + pub index: usize, + /// The written cells as ranges (at most 500 listed), and how many cells. + pub cells: Vec, + pub changed: usize, + /// Written cells that hold data now. + pub conflicts: Vec, + pub conflict_count: usize, + /// The block that holds every written cell. + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option, + #[serde(skip)] + pub bounds_range: Option, +} + +impl Footprint { + /// Cells that hold data and would be written, plus sheets and objects that would go. + pub fn conflict_count(&self) -> usize { + self.sheets.iter().map(|s| s.conflict_count).sum::() + self.sheets_removed.len() + self.objects_removed + } + + /// "C2:C9 on Sheet1, the sheet Old, 2 objects". + pub fn describe_conflicts(&self) -> String { + let mut parts: Vec = self + .sheets + .iter() + .filter(|s| s.conflict_count > 0) + .map(|s| { + let mut listed = s.conflicts.iter().take(3).cloned().collect::>().join(", "); + if s.conflicts.len() > 3 { + listed.push_str(", …"); + } + format!("{listed} on {}", s.sheet) + }) + .collect(); + parts.extend(self.sheets_removed.iter().map(|n| format!("the sheet {n}"))); + if self.objects_removed > 0 { + parts.push(format!("{} object{}", self.objects_removed, if self.objects_removed == 1 { "" } else { "s" })); + } + parts.join("; ") + } +} + +/// Session state a dry run may touch, put back afterwards. The workbook, its selection and its +/// history are put back by the dry run's undo group. +struct Saved { + active: usize, + docs: Vec, + /// The workbook as last saved: `sheet.activate` and the view commands move it to the + /// current workbook on a clean document, and the rollback puts the workbook back but not this. + saved: Option>, + sheet_selections: Vec, + unlocked_ranges: Vec<(String, String)>, + trust: Trust, + clipboard: Option, + prefs: crate::Prefs, + mode: crate::Mode, + /// The journal's length and how many entries it had dropped from its front; a dry run that + /// pushes past the journal's limits drops more, which a length alone cannot see. + journal: usize, + journal_dropped: u64, + recording: Option, + ui_requests: usize, + format_painter: Option<(Arc, usize, RangeRef, bool)>, + last_find: Option, + hide_ruler: bool, + hidden_windows: Vec, + draw: (String, String, f32), + comments_pane: bool, + show_all_notes: bool, +} + +impl Saved { + fn take(s: &mut Session, doc: u64) -> Saved { + let d = s.docs.iter().find(|d| d.uid == doc); + Saved { + active: s.active, + docs: s.docs.iter().map(|d| d.uid).collect(), + saved: d.map(|d| d.saved.clone()), + sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), + unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), + trust: d.map_or(Trust::Local, |d| d.trust), + clipboard: s.clipboard.clone(), + prefs: s.prefs.clone(), + mode: s.mode, + journal: s.journal.len(), + journal_dropped: s.journal_dropped(), + recording: s.recording, + ui_requests: s.ui_requests.len(), + format_painter: s.format_painter.clone(), + last_find: s.last_find.clone(), + hide_ruler: s.hide_ruler, + hidden_windows: s.hidden_windows.clone(), + draw: (s.draw_tool.clone(), s.draw_color.clone(), s.draw_width), + comments_pane: s.comments_pane, + show_all_notes: s.show_all_notes, + } + } + + fn restore(self, s: &mut Session, doc: u64) { + s.docs.retain(|d| self.docs.contains(&d.uid)); + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { + if let Some(saved) = self.saved { + d.saved = saved; + } + d.sheet_selections = self.sheet_selections; + d.unlocked_ranges = self.unlocked_ranges; + d.trust = self.trust; + } + s.active = self.active.min(s.docs.len().saturating_sub(1)); + s.clipboard = self.clipboard; + s.prefs = self.prefs; + s.mode = self.mode; + // Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good, + // and the recording's start moves back with them. + let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX); + s.truncate_journal(self.journal.saturating_sub(dropped)); + s.recording = self.recording.map(|r| r.saturating_sub(dropped)); + s.ui_requests.truncate(self.ui_requests); + s.format_painter = self.format_painter; + s.last_find = self.last_find; + s.hide_ruler = self.hide_ruler; + s.hidden_windows = self.hidden_windows; + (s.draw_tool, s.draw_color, s.draw_width) = self.draw; + s.comments_pane = self.comments_pane; + s.show_all_notes = self.show_all_notes; + } +} + +/// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would +/// change. The dry run stops once `time_limit_ms` has passed on `clock`. +pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result { + let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; + s.set_active(i); + let saved = Saved::take(s, p.doc); + let before = s.doc()?.wb.clone(); + let token = s.group_begin("Check Macro")?; + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == p.doc) { + d.touched = Some(Vec::new()); + } + let mut m = Machine::new(p, true); + (m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed); + let outcome = m.run_to_end(s); + let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { + Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), + None => (before.clone(), Vec::new()), + }; + let rolled = s.group_rollback(token); + saved.restore(s, p.doc); + rolled?; + let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; + let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT; + fp.executed = m.executed; + fp.error = outcome.err(); + fp.stopped = m.stopped.map(|(msg, _)| msg); + fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); + Ok(fp) +} + +/// Compares the workbook before and after a dry run. `touched` adds the cells commands wrote +/// without changing them (a formula written again), so a shift moves everything the macro writes. +fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { + let mut fp = Footprint::default(); + let (pairs, added) = pair_sheets(before, after); + fp.sheets_added = added.into_iter().filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); + for (bi, ai) in pairs { + let Some(bs) = before.sheets.get(bi) else { continue }; + let Some(ai) = ai else { + fp.sheets_removed.push(bs.name.clone()); + continue; + }; + let Some(after_sheet) = after.sheets.get(ai) else { continue }; + let rewritten: BTreeSet = touched.iter().filter(|(n, _)| *n == after_sheet.name).map(|(_, c)| *c).collect(); + if Arc::ptr_eq(bs, after_sheet) && rewritten.is_empty() { + continue; + } + let (added, changed, removed) = objects(bs, after_sheet); + fp.objects_added += added; + fp.objects_changed += changed; + fp.objects_removed += removed; + let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == bi && p.range.contains(c)); + let mut written = Vec::new(); + let mut conflicts = Vec::new(); + let mut cells: BTreeSet = bs.cells.diff(&after_sheet.cells).into_iter().collect(); + cells.extend(rewritten.iter().copied()); + for c in cells { + if in_input(c) { + continue; + } + let (b, a) = (bs.cell(c), after_sheet.cell(c)); + if recalculated_only(b, a) && !rewritten.contains(&c) { + continue; + } + written.push(c); + if b.is_some_and(has_content) && content_differs(b, a) { + conflicts.push(c); + } + } + let Some(first) = written.first() else { continue }; + let bounds = written.iter().fold(RangeRef::cell(*first), |r, c| r.union(&RangeRef::cell(*c))); + fp.sheets.push(SheetFootprint { + sheet: bs.name.clone(), + index: bi, + changed: written.len(), + conflict_count: conflicts.len(), + cells: ranges(&mut written), + conflicts: ranges(&mut conflicts), + bounds: Some(bounds.a1()), + bounds_range: Some(bounds), + }); + } + fp +} /// 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 @@ -785,6 +1054,18 @@ fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { } } +/// Objects added, changed and removed between two versions of a sheet. +fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { + fn count(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) { + let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count(); + let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count(); + let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); + (added, changed, removed) + } + let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)]; + parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2)) +} + /// 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)); @@ -811,6 +1092,47 @@ pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { merged.iter().take(MAX_LISTED_RANGES).map(RangeRef::a1).collect() } +/// Inserts blank cells where the footprint lands (moving what is there down or right), so the +/// macro writes into empty cells. The block is every written cell's, so a row the macro writes +/// stays together. A shift that would move one of the macro's inputs is refused: the inputs were +/// checked and bound where they are, and only the block's columns (or rows) would move. +fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> { + if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { + return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); + } + if fp.log_full { + return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into())); + } + let wb = s.doc()?.wb.clone(); + let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + let mut moves = Vec::new(); + for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { + let Some(block) = sh.bounds_range else { continue }; + // What the insertion moves: the block's columns from its top row down, or its rows from + // its left column rightwards. + let moved = |r: &RangeRef| { + if down { + r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row + } else { + r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col + } + }; + // The output is where the macro writes, so moving what is there is the point; the second + // footprint catches data the shift moved into it. + if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) { + return Err(EngineError::Other(format!( + "Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.", + resolve::a1(pl, &env) + ))); + } + moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env)); + } + for range in moves { + s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; + } + Ok(()) +} + // ---------------------------------------------------------------- run /// A macro that passed its checks and is about to run. @@ -840,19 +1162,62 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { 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); +/// The footprint check and the output rule, inside the run's undo group: an error when the macro +/// would fail or write over data it may not, after shifting the data if the rule says so. Returns +/// the machine for the run and whether it shifted. The dry runs count towards the run's time +/// limit. +/// +/// A partial footprint proves nothing about the steps it didn't reach, so then the machine +/// checks each step as it runs and stops the run at one that writes over data. +pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { + let (m, rule) = (&b.mac, b.rule); + let mut machine = Machine::new(&b.prepared, false); 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)); + if rule == OnConflict::Overwrite { + return Ok((machine, false)); } - Ok(machine) + // The dry runs and the run share one step budget as they share one clock. + let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; + machine.executed = fp.executed; + if let Some(e) = fp.error.take() { + return Err(e.into_engine(&m.name)); + } + let mut shifted = false; + if fp.conflict_count() > 0 { + let what = fp.describe_conflicts(); + match rule { + OnConflict::Ask => { + return Err(EngineError::Confirm(format!( + "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", + m.name + ))); + } + OnConflict::Fail | OnConflict::Overwrite => { + return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))); + } + OnConflict::ShiftDown | OnConflict::ShiftRight => { + make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?; + fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; + machine.executed = fp.executed; + if let Some(e) = fp.error.take() { + return Err(e.into_engine(&m.name)); + } + if fp.conflict_count() > 0 { + return Err(EngineError::Other(format!( + "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", + m.name, + fp.describe_conflicts() + ))); + } + shifted = true; + } + } + } + if let Some(p) = &fp.partial { + let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone(); + machine.guard = Some(Guard::new(start, p, rule == OnConflict::Ask)); + } + Ok((machine, shifted)) } /// Runs macro `name` on the active workbook: `{inputs?, onConflict?, overwrite?, approve?, @@ -861,16 +1226,21 @@ 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 (mut machine, shifted) = make_way(s, &b)?; let name = &b.mac.name; + // `ran` counts the run's own steps; the budget also holds the dry runs'. + let before_run = machine.executed; 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} Nothing was changed."))), stopped => { - let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs}); + let mut out = json!({"macro": name, "ran": machine.executed.saturating_sub(before_run), "inputs": b.inputs}); if let Some((msg, _)) = stopped { out["stopped"] = json!(msg); } + if shifted { + out["shifted"] = json!(b.rule.name()); + } Ok(out) } } @@ -895,3 +1265,49 @@ fn conflict_rule(p: &Json, m: &Macro) -> Result { } Ok(rule) } + +/// `macro.check`: what running the macro would do, without changing anything. +pub fn check(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}'.")))?; + let mut out = json!({"macro": m.name, "ok": false, "onConflict": m.output.on_conflict.name()}); + let asks = match gate(&m, trust) { + Ok(a) => a, + Err(e) => { + out["errors"] = json!([e.to_string()]); + return Ok(out); + } + }; + let approved = s.macro_approvals.contains(&(doc, super::fingerprint(&m))); + if !asks.is_empty() && !approved { + // Nothing from an unapproved macro runs, not even in a dry run. + out["needsApproval"] = json!(asks); + } + let prepared = match prepare(s, &m, p.get("inputs")) { + Ok(pr) => pr, + Err(e) => { + out["errors"] = json!([e.to_string()]); + return Ok(out); + } + }; + out["inputs"] = prepared.inputs_json(&s.doc()?.wb); + if let Some(o) = prepared.output { + let wb = &s.doc()?.wb; + out["output"] = json!(resolve::a1(o, &Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None })); + } + if !asks.is_empty() && !approved { + return Ok(out); + } + let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p), 0)?; + out["ok"] = json!(fp.error.is_none()); + out["conflicts"] = json!(fp.conflict_count()); + if let Some(e) = &fp.error { + out["errors"] = json!([e.describe()]); + } + out["footprint"] = serde_json::to_value(&fp).unwrap_or(Json::Null); + Ok(out) +} diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 262f50af9..2f5b71a61 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1,5 +1,4 @@ -//! Macros: running, input checks, checking each step for writes over data, the policy and the -//! limits. +//! Macros: running, input checks, the footprint dry run, the policy and the limits. use std::sync::Arc; @@ -157,25 +156,208 @@ fn conflicts_ask_fail_or_overwrite() { 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!(q.contains("would write over cells that hold 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()); + // macro.check shows the footprint and the conflict, and changes nothing. + let journal = s.journal.len(); + let sel = s.doc().unwrap().selection.clone(); + let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap(); + assert_eq!(c["ok"], true); + assert_eq!(c["conflicts"], 1); + assert_eq!(c["output"], "E2"); + assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["E2:E3"])); + assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["E3"])); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!((s.journal.len(), &s.doc().unwrap().selection), (journal, &sel)); + // 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"); } +#[test] +fn shifting_makes_room_and_is_checked_again() { + for (rule, moved) in [("shiftDown", ["E4", "E5"]), ("shiftRight", ["F2", "F3"])] { + let mut s = s(); + data(&mut s); + set(&mut s, "E2", "old 1"); + set(&mut s, "E3", "old 2"); + put(&mut s, totals()); + let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": rule})).unwrap(); + assert_eq!(r["shifted"], rule); + assert_eq!((v(&s, "E2").display(), v(&s, "E3").display()), ("Total".into(), "400".into()), "{rule}"); + assert_eq!((v(&s, moved[0]).display(), v(&s, moved[1]).display()), ("old 1".into(), "old 2".into()), "{rule}"); + // The shift and the run are one undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "E2").display(), "old 1", "{rule}"); + assert!(v(&s, moved[1]).is_empty(), "{rule}"); + } + // A cell written with what it already holds isn't a conflict, but it moves with the others, + // so rows stay together. + let mut s = super::s(); + for (cell, input) in [("F2", "x"), ("G2", "b"), ("F3", "f3"), ("G3", "g3")] { + set(&mut s, cell, input); + } + put( + &mut s, + json!({"name": "Pair", "output": {"onConflict": "shiftDown"}, "steps": [ + {"command": "cell.set", "params": {"cell": "F2", "input": "a"}}, + {"command": "cell.set", "params": {"cell": "G2", "input": "b"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Pair"})).unwrap(); + assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["sheets"][0]["cells"]), (Some(1), &json!(["F2:G2"]))); + s.execute("macro.run", json!({"name": "Pair"})).unwrap(); + let row = |s: &Session, r: u32| (v(s, &format!("F{r}")).display(), v(s, &format!("G{r}")).display()); + assert_eq!(row(&s, 2), ("a".into(), "b".into())); + assert_eq!(row(&s, 3), ("x".into(), "b".into())); + assert_eq!(row(&s, 4), ("f3".into(), "g3".into())); + // An anchor that moves with the shifted data still lands on it: the second check refuses. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old"); + s.execute("formulas.defineName", json!({"name": "Target", "refersTo": "=Sheet1!$E$2"})).unwrap(); + put( + &mut s, + json!({"name": "Named", "steps": [{"command": "cell.set", "params": {"cell": "Name:Target", "input": "new"}}], "output": {"onConflict": "shiftDown"}}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string(); + assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); +} + +#[test] +fn a_shift_that_would_move_an_input_is_refused() { + // A shift inserts cells over the conflicting block, which moves the block's columns (or rows) + // from there on. When one of the macro's own inputs lies in the way, its data would move and + // shear while the run keeps the old coordinates, so the shift is refused and nothing changes. + let mut s = s(); + data(&mut s); + set(&mut s, "B1", "Old title"); + put( + &mut s, + json!({"name": "Title", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Title", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input (") && err.contains("B2:C5"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing changed"); + assert_eq!((v(&s, "B1").display(), v(&s, "B3").display()), ("Old title".into(), "Ann".into())); + // Writing left of the input and shifting right would move it too. + put( + &mut s, + json!({"name": "Left", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.offset(1,-1).cell(1,1)", "input": "x"}} + ]}), + ); + set(&mut s, "A3", "in the way"); + let err = s.execute("macro.run", json!({"name": "Left", "inputs": "B2:C5", "onConflict": "shiftRight"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // The output counts as well. + put( + &mut s, + json!({"name": "Out", "inputs": [{"name": "Input", "header": true}], "output": {"anchor": "Input.below(1)"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let err = s.execute("macro.run", json!({"name": "Out", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // A shift that moves nothing of the macro's own still works, over the block of every written + // cell, so a row the macro writes stays together. + set(&mut s, "H1", "elsewhere"); + put( + &mut s, + json!({"name": "Wide", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "H1", "input": "over"}}, + {"command": "cell.set", "params": {"cell": "H9", "input": "far"}} + ]}), + ); + s.execute("macro.run", json!({"name": "Wide", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap(); + assert_eq!((v(&s, "H1").display(), v(&s, "H9").display(), v(&s, "H10").display()), ("over".into(), "far".into(), "elsewhere".into())); + assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move"); +} + +#[test] +fn a_shift_is_refused_when_the_footprint_cannot_follow_every_write() { + // The touch log keeps at most a million writes; past that, a write that kept a cell's value + // is missing from the footprint, so a shift could split a row the macro writes. The footprint + // says so and the shift is refused (overwrite, with no dry run, still runs). + let mut s = s(); + set(&mut s, "A1", "x"); + set(&mut s, "E2", "old"); + put( + &mut s, + json!({"name": "Flood", "steps": [ + {"command": "edit.fillDown", "params": {"range": "A1:A1000001"}}, + {"command": "cell.set", "params": {"cell": "E2", "input": "new"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Flood"})).unwrap(); + assert_eq!(c["footprint"]["logFull"], true, "{c}"); + let err = s.execute("macro.run", json!({"name": "Flood", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("more cells than the footprint can follow"), "{err}"); + assert_eq!(v(&s, "E2").display(), "old", "nothing changed"); +} + +#[test] +fn a_dry_run_leaves_a_clean_workbook_clean() { + // `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean + // document; the dry run's rollback puts the workbook back, so the mark must come back too. + let mut s = s(); + data(&mut s); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(!s.doc().unwrap().is_dirty()); + put(&mut s, json!({"name": "Look", "steps": [{"command": "view.zoom", "params": {"percent": 120}}]})); + s.execute("macro.check", json!({"name": "Look"})).unwrap(); + assert!(!s.doc().unwrap().is_dirty(), "a dry run changed nothing"); + // A run refused by the conflict rule changed nothing either. + put(&mut s, totals()); + set(&mut s, "E3", "taken"); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).is_err()); + assert!(!s.doc().unwrap().is_dirty()); +} + +#[test] +fn a_dry_run_puts_the_journal_back_even_after_it_trimmed_it() { + // Every nested command a dry run executes is journaled, so a long macro pushes the journal + // past its limit and the journal drops its oldest entries. Putting the journal back by its + // old length would then keep entries from the dry run and point a recording at the wrong + // place; the restore counts what was dropped. + let mut s = s(); + data(&mut s); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let journal = s.journal.clone(); + s.recording = Some(journal.len().saturating_sub(1)); + put( + &mut s, + json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A200", "body": [ + {"type": "repeat", "over": "A1:A60", "body": [{"command": "cell.set", "params": {"cell": "Row.cell(1,1)", "input": "x"}}]} + ]}]}), + ); + s.execute("macro.check", json!({"name": "Long"})).unwrap(); + let sets = |j: &[(String, Json)]| j.iter().filter(|(id, _)| id == "cell.set").count(); + assert!(sets(&s.journal) <= sets(&journal), "no dry-run entry stays"); + assert!(s.journal.len() <= journal.len(), "{} entries, had {}", s.journal.len(), journal.len()); + assert!(journal.ends_with(&s.journal), "what stays is the journal's own tail"); + assert!(s.recording.is_some_and(|r| r <= s.journal.len()), "{:?}", s.recording); + // A short dry run leaves the journal exactly as it was. + put(&mut s, json!({"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "x"}}]})); + let (journal, recording) = (s.journal.clone(), s.recording); + s.execute("macro.check", json!({"name": "Short"})).unwrap(); + assert_eq!((&s.journal, s.recording), (&journal, recording)); +} + #[test] fn each_step_is_checked_for_writes_over_data() { let dir = std::env::temp_dir().join(format!("gc-macro-guard-{}", std::process::id())); @@ -232,6 +414,66 @@ fn the_answer_to_the_question_counts_however_ask_was_given() { assert_eq!(v(&s, "E3").display(), "400"); } +#[test] +fn the_step_limit_is_one_budget_across_the_dry_run_and_the_run() { + // 300 x 300 passes is under the limit for one phase; the dry run and the run together are not. + let mut s = s(); + put( + &mut s, + json!({"name": "Spin", "steps": [{"type": "repeat", "over": "A1:A300", "body": [ + {"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); + assert!(err.contains("more than 100000 steps"), "{err}"); + // With no dry run (overwrite), the same macro fits. + s.execute("macro.run", json!({"name": "Spin", "onConflict": "overwrite"})).unwrap(); +} + +#[test] +fn the_footprint_covers_other_sheets_objects_and_edges() { + let mut s = s(); + data(&mut s); + s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); + set(&mut s, "A1", "note"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("insert.chart", json!({"range": "B2:C5", "type": "column", "at": "H2"})).unwrap(); + let chart = s.doc().unwrap().wb.sheet(0).unwrap().charts[0].id; + put( + &mut s, + json!({"name": "Wide", "output": {"onConflict": "fail"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Notes!A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "Notes!B2", "input": "y"}}, + {"command": "home.insertSheet", "params": {"name": "Extra"}}, + {"command": "sheet.activate", "params": {"sheet": "Sheet1"}}, + {"command": "insert.shape", "params": {"kind": "ellipse", "at": "J2"}}, + {"command": "object.delete", "params": {"kind": "chart", "id": chart}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Wide", "inputs": {}})).unwrap(); + let fp = &c["footprint"]; + assert_eq!( + fp["sheets"], + json!([{"sheet": "Notes", "cells": ["A1", "B2"], "changed": 2, "conflicts": ["A1"], "conflictCount": 1, "bounds": "A1:B2"}]) + ); + assert_eq!(fp["sheetsAdded"], json!(["Extra"])); + assert_eq!((fp["objectsAdded"].as_u64(), fp["objectsRemoved"].as_u64()), (Some(1), Some(1))); + assert_eq!(c["conflicts"], 2, "the deleted chart and the note"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2, "the dry run's new sheet went away"); + let err = s.execute("macro.run", json!({"name": "Wide"})).unwrap_err().to_string(); + assert!(err.contains("(A1 on Notes; 1 object)"), "{err}"); + + // Running past the edge of the sheet fails before anything changes. + put( + &mut s, + json!({"name": "Edge", "inputs": [{"name": "Input"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.offset(-3,0)", "input": "x"}}]}), + ); + let err = s.execute("macro.run", json!({"name": "Edge", "inputs": "B2"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (cell.set): 'Input.offset(-3,0)' runs past the top of the sheet."), "{err}"); + let c = s.execute("macro.check", json!({"name": "Edge", "inputs": "B2"})).unwrap(); + assert_eq!((c["ok"].as_bool(), c["footprint"]["error"]["edge"].as_bool()), (Some(false), Some(true))); +} + #[test] fn repeat_if_and_stop() { let mut s = s(); @@ -312,7 +554,7 @@ fn macros_are_bounded() { 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. + // The footprint's dry run keeps to the time limit too, and so does the check's. put( &mut s, json!({"name": "Spin", "output": {"onConflict": "fail"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [ @@ -321,6 +563,8 @@ fn macros_are_bounded() { ); let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string(); assert!(err.contains("ran longer than"), "{err}"); + let c = s.execute("macro.check", json!({"name": "Spin", "timeLimit": 0.001})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("ran longer than"), "{c}"); } #[test] @@ -333,22 +577,33 @@ fn a_token_in_a_path_needs_the_permission_and_approval() { 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. + // Without the permission nothing runs, not even the check's dry run. 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. + let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("writes a file"), "{path}: {c}"); + assert!(c.get("footprint").is_none()); + // With it, the person approves first; the check doesn't run it before then. 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}"); + let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); + assert_eq!(c["needsApproval"], json!(["runs file.exportCsv, which writes a file"]), "{path}"); + assert!(c.get("footprint").is_none()); 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. + // Once approved, the check's dry run stops before the step, and only 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); + std::fs::remove_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let c = s.execute("macro.check", json!({"name": "Export", "inputs": "B2"})).unwrap(); + assert_eq!(c["footprint"]["partial"]["step"], "1", "{c}"); + assert_eq!(files(&dir), 0, "the check writes nothing"); let _ = std::fs::remove_dir_all(&dir); } @@ -391,10 +646,16 @@ fn the_policy_keeps_macros_away_from_files_and_the_app() { 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); + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["needsApproval"], json!(["runs data.fromTextCsv, which reads a file"])); + assert!(c.get("footprint").is_none(), "nothing unapproved runs, even dry"); 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(); + // The dry run skips the file step and says so. + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["footprint"]["partial"]["step"], "1"); // 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(_)))); @@ -439,3 +700,63 @@ fn macros_from_a_file_ask_before_running() { 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)); } + +#[test] +fn a_partial_footprint_doesnt_clear_later_writes() { + let dir = std::env::temp_dir().join(format!("gc-macro-partial-{}", 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"}}); + // The dry run stops at an approved file step or at Solver; the step after it writes over B1. + 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(); + let c = s.execute("macro.check", json!({"name": "Late", "inputs": "A1"})).unwrap(); + if c.get("needsApproval").is_none() { + assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["partial"]["step"]), (Some(0), &json!("1")), "{c}"); + } + 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}"); + 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"); + // After a shift, the steps past the file step are checked too. + let mut s = super::s(); + set(&mut s, "C1", "old"); + set(&mut s, "D1", "keep"); + let step = |cell: &str| json!({"command": "cell.set", "params": {"cell": cell, "input": "new"}}); + put( + &mut s, + json!({"name": "Shift", "permissions": ["file.exportCsv"], "output": {"onConflict": "shiftDown"}, "steps": [step("C1"), export, step("D1")]}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Shift", "approve": true})).unwrap_err().to_string(); + assert!(err.contains("Step 3 (cell.set): It would write over data (D1 on Sheet1)"), "{err}"); + assert_eq!((v(&s, "C1").display(), v(&s, "D1").display()), ("old".into(), "keep".into())); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "the shift is rolled back too"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/macros.md b/docs/macros.md index daf0a2c35..a56bd77a9 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,17 +1,16 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (running macros: `macro.run`) +> **Last updated:** 2026-10-25 · **Change:** minor (checking macros before they run: the footprint, shifting, `macro.check`) 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, 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. +error (a file it already wrote stays), checks where its results will land before it writes them, +and does not touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the macro format, its tokens, and running macros. The engine has no check of a -macro before it runs, no macro debugger, recorder or management commands in this version, and no -macro interface. +This page describes the macro format, its tokens, and running and checking macros. The engine has +no macro debugger, recorder or management commands in this version, and no macro interface. ## A macro @@ -60,8 +59,9 @@ macro interface. 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) and takes at most 60 seconds (`timeLimit` to change the time; - the web build has no clock, so there only the step limit applies). + pass through a Repeat counts) and takes at most 60 seconds, both shared by the footprint dry + runs and the run itself (`timeLimit` to change the time; the web build has no clock, so there + only the step limit applies). ## Tokens @@ -108,17 +108,24 @@ refused rather than read as the header. 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. -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). 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. +2. **Footprint.** Unless the conflict rule is `overwrite`, the macro first runs in dry-run mode on + the real workbook, and everything is put back. Cells it would write outside its + inputs that hold data, and sheets and objects it would + delete, are conflicts, handled by `onConflict` (or the macro's `output.onConflict`): `ask` (the + call fails with `confirm: …`; `overwrite: true` answers it), `fail`, `overwrite`, `shiftDown` or + `shiftRight` (Insert Cells makes room, then the footprint is checked again). Steps that touch + files, and Solver, end the dry run early; the footprint then says it is partial, and unless + the rule is `overwrite`, the run checks every step as it goes. A step that writes over cells, + sheets or objects that held data when the run started stops the run and rolls the workbook + back (a file already written stays): it asks (`confirm: …`) under `ask` and fails otherwise, + since shifting can't make room that late. 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). +`macro.check {name, inputs?, timeLimit?}` reports the bound inputs, the output anchor, any +approval needed and the footprint, without changing anything. + ## Storage Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden From d053b1f54820781ebb5a2bd6f6195c600dd3fed7 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:35:02 -0400 Subject: [PATCH 113/166] Engine: a run is checked step by step even when its footprint was complete A footprint is not proof of what the run will do. RAND gives the run other values than the dry run before it (the recalculation's random sequence goes on), and NOW moves on, so an If can take another branch in the run and write over data the dry run never reached; under `fail` the run went ahead and wrote over it. - Unless the rule is `overwrite`, every run checks each step as it goes, as a run after a partial footprint already did; a write the footprint didn't see stops the run, which asks under `ask` and fails otherwise, and rolls back. - Test: `a_run_that_takes_another_path_than_its_check_is_still_checked` (an If on `=RAND()` over 40 runs under `fail`; before, one of them wrote over B1). --- crates/engine/src/macros/run.rs | 40 ++++++++++++++++++------------- crates/engine/src/tests/macros.rs | 25 +++++++++++++++++++ docs/macros.md | 6 +++-- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index f45bcfc25..9719e1e09 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -9,9 +9,9 @@ //! 3. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run //! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it //! would write outside its inputs that already hold data are conflicts, handled by the output -//! rule: ask, fail, overwrite, or shift the data down or right (and check again). A dry run -//! that stops early (a file step, Solver) proves nothing about later steps, so the run then -//! checks each step as it goes. +//! rule: ask, fail, overwrite, or shift the data down or right (and check again). The +//! footprint can miss a write (a dry run stops early at a file step or Solver, and RAND or NOW +//! can send the run another way than the dry run), so the run also checks each step as it goes. //! 4. **Run**: one undo step (`Session::grouped`); an error at any step rolls everything back and //! names the step. //! @@ -430,27 +430,30 @@ pub struct Machine { pub partial: Option<(String, String)>, /// The last step's resolved parameters (for the debugger). pub last_params: Option, - /// Set when the footprint was partial: each step is checked for writes over data as it runs. + /// Set unless the macro may overwrite: each step is checked for writes over data as it runs, + /// since the footprint can miss a write (it stopped early, or the run took another path). guard: Option, } -/// Checks each step of a real run for writes over data the footprint couldn't see. +/// Checks each step of a real run for writes over data the footprint didn't see. #[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>, - /// Where the footprint stopped: "step 2 (file.exportCsv writes a file)". - partial: String, + /// Where the footprint stopped, if it did: "step 2 (file.exportCsv writes a file)". + partial: Option, /// The conflict rule is ask. ask: bool, } impl Guard { - fn new(start: Arc, partial: &Json, ask: bool) -> Guard { - let text = |k: &str| partial.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); - let partial = format!("step {} ({})", text("step"), text("reason")); + fn new(start: Arc, partial: Option<&Json>, ask: bool) -> Guard { + let partial = partial.map(|p| { + let text = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); + format!("step {} ({})", text("step"), text("reason")) + }); Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, partial, ask } } @@ -714,7 +717,11 @@ impl Machine { 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()))?; let what = g.check(&prev, &after, &self.inputs); if !what.is_empty() { - let msg = format!("It would write over data ({what}); the check before the run stopped at {} and couldn't see this.", g.partial); + let msg = match &g.partial { + Some(at) => format!("It would write over data ({what}); the check before the run stopped at {at} and couldn't see this."), + // A value such as RAND or NOW differed from the check's, and the run went another way. + None => format!("It would write over data ({what}); the check before the run didn't see this write."), + }; return Err(StepError { confirm: g.ask, ..fail(msg) }); } } @@ -1167,8 +1174,9 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { /// the machine for the run and whether it shifted. The dry runs count towards the run's time /// limit. /// -/// A partial footprint proves nothing about the steps it didn't reach, so then the machine -/// checks each step as it runs and stops the run at one that writes over data. +/// The footprint proves nothing about steps it didn't reach (it stopped early) or a path the run +/// takes and the dry run didn't (RAND, NOW), so the machine also checks each step as it runs and +/// stops the run at one that writes over data. pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { let (m, rule) = (&b.mac, b.rule); let mut machine = Machine::new(&b.prepared, false); @@ -1213,10 +1221,8 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { } } } - if let Some(p) = &fp.partial { - let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone(); - machine.guard = Some(Guard::new(start, p, rule == OnConflict::Ask)); - } + let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone(); + machine.guard = Some(Guard::new(start, fp.partial.as_ref(), rule == OnConflict::Ask)); Ok((machine, shifted)) } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 2f5b71a61..94902ee7b 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1,5 +1,6 @@ //! Macros: running, input checks, the footprint dry run, the policy and the limits. +use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value as Json, json}; @@ -400,6 +401,30 @@ fn each_step_is_checked_for_writes_over_data() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn a_run_that_takes_another_path_than_its_check_is_still_checked() { + // RAND gives the run other values than the dry run before it, so an If can go the other way: + // the write the footprint didn't see must still not go over data. + let mut s = s(); + let steps = json!([ + {"command": "cell.set", "params": {"cell": "A1", "input": "=RAND()"}}, + {"type": "if", "cell": "A1", "op": "gt", "value": 0.5, "then": [{"command": "cell.set", "params": {"cell": "B1", "input": "over"}}]} + ]); + put(&mut s, json!({"name": "Coin", "output": {"onConflict": "fail"}, "steps": steps})); + let mut outcomes = BTreeSet::new(); + for _ in 0..40 { + set(&mut s, "B1", "keep"); + let r = s.execute("macro.run", json!({"name": "Coin"})); + assert_eq!(v(&s, "B1").display(), "keep", "{r:?}"); + outcomes.insert(match r { + Ok(_) => "ran", + Err(e) if e.to_string().contains("would write over") => "refused", + Err(e) => panic!("{e}"), + }); + } + assert_eq!(outcomes.len(), 2, "both paths were taken: {outcomes:?}"); +} + #[test] fn the_answer_to_the_question_counts_however_ask_was_given() { let mut s = s(); diff --git a/docs/macros.md b/docs/macros.md index a56bd77a9..fbda2f69a 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -114,8 +114,10 @@ refused rather than read as the header. delete, are conflicts, handled by `onConflict` (or the macro's `output.onConflict`): `ask` (the call fails with `confirm: …`; `overwrite: true` answers it), `fail`, `overwrite`, `shiftDown` or `shiftRight` (Insert Cells makes room, then the footprint is checked again). Steps that touch - files, and Solver, end the dry run early; the footprint then says it is partial, and unless - the rule is `overwrite`, the run checks every step as it goes. A step that writes over cells, + files, and Solver, end the dry run early; the footprint then says it is partial. Unless the + rule is `overwrite`, the run also checks every step as it goes, since the footprint can miss a + write: it is partial, or a value such as RAND or NOW sends the run another way than the dry + run. A step that writes over cells, sheets or objects that held data when the run started stops the run and rolls the workbook back (a file already written stays): it asks (`confirm: …`) under `ask` and fails otherwise, since shifting can't make room that late. From ad79b071ce8dfa22647cb4910530735c7cbb988b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:40:21 -0400 Subject: [PATCH 114/166] Engine: a macro's inputs, output and Repeat ranges follow their cells when a step moves them Inputs, the output and a Repeat's range were bound once, by sheet index and coordinates. A step that inserted a sheet in front of an input's sheet sent every later token to the new sheet, and rows inserted above an input left the tokens on the cells that moved into its place; the run wrote into the wrong cells and reported success. - Commands record the structural edits they make as they rewrite references (`Ctx::edits`; while a macro step runs, `DocState::edits` collects them). - After each step the machine matches the sheets as the run's guard does and moves the inputs, the output and the ranges of the Repeats in progress by those edits, with the formula engine's own adjustment. - A step that deletes an input's or the output's cells, or their sheet, or changes the size of a range a Repeat is going over, stops the run, which rolls back. - Test: `inputs_and_the_output_follow_their_cells_when_a_step_moves_them` (a sheet inserted in front, rows inserted above, an input deleted, a Repeat's range grown). --- crates/engine/src/cmd/edit.rs | 1 + crates/engine/src/cmd/insert.rs | 2 +- crates/engine/src/cmd/mod.rs | 18 +++++++- crates/engine/src/cmd/sheet.rs | 18 ++++---- crates/engine/src/lib.rs | 4 ++ crates/engine/src/macros/run.rs | 68 ++++++++++++++++++++++++++++++- crates/engine/src/tests/macros.rs | 53 ++++++++++++++++++++++++ docs/macros.md | 5 ++- 8 files changed, 155 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 102edc2d3..556d41759 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 3c45031af..0078cb4fe 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/mod.rs b/crates/engine/src/cmd/mod.rs index 6f58f18ee..55da0377e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -340,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 { @@ -357,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 e6caa1dc7..dfab93736 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/lib.rs b/crates/engine/src/lib.rs index 151c2bc06..23a09c9ee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -206,6 +206,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, @@ -299,6 +302,7 @@ impl DocState { content_kind: None, scripts_trusted, trust, + edits: None, go_to_recent: vec![], } } diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 8ae1e3496..36c4cae00 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -22,6 +22,7 @@ use std::collections::BTreeSet; 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}; @@ -645,6 +646,34 @@ impl Machine { } } + /// 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(()) + } + 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() } } @@ -670,9 +699,13 @@ impl Machine { (_, Policy::Allow | Policy::Ask(_)) => {} } self.last_params = Some(resolved.clone()); - let prev = self.guard.as_ref().and_then(|_| s.docs.iter().find(|d| d.uid == self.doc)).map(|d| d.wb.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(); let r = s.execute(id, resolved); + 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; @@ -689,8 +722,11 @@ impl Machine { 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 after = s.docs.iter().find(|d| d.uid == self.doc).map(|d| d.wb.clone()).ok_or_else(|| fail("The workbook was closed.".into()))?; let what = g.check(&prev, &after, &self.inputs); if !what.is_empty() { let msg = format!("It would write over data ({what})."); @@ -701,6 +737,34 @@ impl Machine { } } +/// 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(""); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 262f50af9..68d0b428a 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -3,6 +3,7 @@ use std::sync::Arc; +use gridcraft_core::CellRef; use serde_json::{Value as Json, json}; use super::{s, v}; @@ -176,6 +177,58 @@ fn conflicts_ask_fail_or_overwrite() { 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})); + s.execute("macro.run", json!({"name": "Front", "inputs": "Sheet1!B2"})).unwrap(); + assert_eq!((on(&s, "Sheet1", "B2"), on(&s, "Summary", "B2")), ("x".into(), "".into())); + + // 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())); diff --git a/docs/macros.md b/docs/macros.md index daf0a2c35..184cc16ad 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -117,7 +117,10 @@ refused rather than read as the header. 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). + an If). 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 From 65cbcdd6bd96c415700c861510e66a4b6b6a284c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:44:15 -0400 Subject: [PATCH 115/166] Engine: a macro run names the files it kept, refuses parameters it would ignore, and bounds its work - A run that rolls back after writing a file no longer says "Nothing was changed.": the message says the workbook was put back and names the step whose file stays (`StepError::wrote_files`, also after a Stop with rollback). - `macro.run` refuses an input the macro doesn't have (a misspelt input bound the selection), an input that isn't reference text, and `onConflict`, `overwrite`, `approve` or `timeLimit` of the wrong kind, which it used to ignore; `null` still counts as not given. - A step no longer copies itself, and Repeat and If no longer copy their bodies, on every pass: a body is copied once per run. A run passes its commands at most 256 MB of parameters, all steps together, which bounds a run's work on the web build, where there is no clock. - Every step except an approved file step runs inside the scripts' file sandbox, so a command that touches a file under a parameter other than `path` can't do so from a macro. - Docs: moving cells that hold data counts as writing over them. - Tests: `run_parameters_that_would_be_ignored_are_refused`, `a_run_passes_a_bounded_amount_of_parameters`, and the rollback message in `each_step_is_checked_for_writes_over_data`. --- crates/engine/src/io.rs | 2 +- crates/engine/src/macros/policy.rs | 13 ++- crates/engine/src/macros/run.rs | 132 ++++++++++++++++++++++++++--- crates/engine/src/tests/macros.rs | 44 ++++++++++ docs/macros.md | 17 ++-- 5 files changed, 189 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b8ef1abe9..d47b78520 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/macros/policy.rs b/crates/engine/src/macros/policy.rs index 57d974920..24e7724ad 100644 --- a/crates/engine/src/macros/policy.rs +++ b/crates/engine/src/macros/policy.rs @@ -1,6 +1,8 @@ -//! What a macro step may run. Macros run commands, so this is the one place that keeps a macro -//! from saving, opening or closing files, driving the app, running other macros, or reading and -//! writing files without the person knowing. +//! 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; @@ -47,6 +49,11 @@ const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.pictu /// 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"}` diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 36c4cae00..3d293e609 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -18,7 +18,7 @@ //! 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; +use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value}; @@ -27,7 +27,7 @@ use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, S use serde::Serialize; use serde_json::{Value as Json, json}; -use super::policy::{Policy, macro_policy}; +use super::policy::{Policy, macro_policy, writes_file}; use super::resolve::{self, Env, Place, ResolveError}; use crate::{EngineError, Result, Session, Trust, UiRequest}; @@ -38,6 +38,9 @@ pub const MAX_EXECUTED_STEPS: u64 = 100_000; 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; @@ -58,11 +61,22 @@ pub struct StepError { /// 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 } + 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 { @@ -79,11 +93,30 @@ impl StepError { } pub fn into_engine(self, name: &str) -> EngineError { - let text = format!("Macro '{name}' stopped. {} Nothing was changed.", self.describe()); + 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 @@ -426,6 +459,13 @@ pub struct Machine { 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, } @@ -518,6 +558,9 @@ impl Machine { 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(); @@ -574,12 +617,15 @@ impl Machine { /// Runs the current step and moves to the next one. pub fn step(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { - let Some((path, st)) = self.current().map(|(p, st)| (p, st.clone())) else { return Ok(()) }; + // 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 r = self.exec(s, &path, &st); - if r.is_err() { + 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(); @@ -618,7 +664,8 @@ impl Machine { } if items > 0 && body.iter().any(|b| b.enabled) { let repeat = Some(Loop { of: *of, over: place, at: 0, count: items }); - self.frames.push(Frame { steps: Arc::new(body.clone()), next: 0, prefix: path.to_string(), repeat }); + let steps = self.body(path.to_string(), body); + self.frames.push(Frame { steps, next: 0, prefix: path.to_string(), repeat }); } Ok(()) } @@ -634,7 +681,9 @@ impl Machine { }; let (branch, name) = if compare(&v, *op, value) { (then, "then") } else { (otherwise, "else") }; if branch.iter().any(|b| b.enabled) { - self.frames.push(Frame { steps: Arc::new(branch.clone()), next: 0, prefix: format!("{path}.{name}"), repeat: None }); + let prefix = format!("{path}.{name}"); + let steps = self.body(prefix.clone(), branch); + self.frames.push(Frame { steps, next: 0, prefix, repeat: None }); } Ok(()) } @@ -674,6 +723,11 @@ impl Machine { 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() } } @@ -698,13 +752,26 @@ impl Machine { } (_, 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. @@ -719,6 +786,9 @@ impl Machine { } } 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)); } @@ -895,6 +965,7 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { .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"))?; @@ -929,7 +1000,7 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { 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} Nothing was changed."))), + 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 { @@ -941,6 +1012,47 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { }) } +/// 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) diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 68d0b428a..0df51122c 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -252,6 +252,10 @@ fn each_step_is_checked_for_writes_over_data() { (_, 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"); } @@ -271,6 +275,46 @@ fn each_step_is_checked_for_writes_over_data() { 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(); diff --git a/docs/macros.md b/docs/macros.md index 184cc16ad..a3f18ebb6 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -60,8 +60,9 @@ macro interface. 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) and takes at most 60 seconds (`timeLimit` to change the time; - the web build has no clock, so there only the step limit applies). + 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 @@ -107,17 +108,23 @@ refused rather than read as the header. 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. + `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). The rule is `onConflict` (or the macro's `output.onConflict`): `ask` (the call + written stays). Moving such cells, as inserting or deleting rows or cells above them does, + counts as writing over them: the check compares cells by place. 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). When a step inserts, deletes or moves sheets, rows, columns or cells, the inputs, the + 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. From 3708c80168ac8023cc551ed0a7efc98c4d2f213b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:49:35 -0400 Subject: [PATCH 116/166] Engine: macro.check says whether the run would go ahead; the question names what the answer covers - `ok` in `macro.check` meant only that the dry run hit no error. It now says whether `macro.run` with the same parameters would go ahead without a question, and `errors` says why not: a failing step, a Stop step that rolls back, data written over under `ask` or `fail`, a shift that can't make room (the refusals `make_room` makes, now in `shift_refusal`), or a run past the step or parameter limit once the dry runs before it count. `macro.check` takes `onConflict` and `overwrite` as `macro.run` does, reports the rule it used, and refuses the parameters `macro.run` refuses. - After a partial footprint, the question under `ask` says where the check stopped and that overwriting also lets the later steps write over data unchecked. - The dry runs and the run share the parameter budget as they share the step budget. - A dry run puts back the Go To list (`edit.goTo` with `record: true`). - The footprint groups the touch log by sheet once, instead of filtering a million entries once per sheet. - Docs: what `ok` means; RAND's sequence moves on after a check, as after any recalculation. - Tests: `the_check_says_whether_the_run_would_go_ahead`, `the_question_after_a_partial_footprint_says_what_the_answer_covers`, `a_dry_run_puts_back_the_go_to_list`, the shared parameter budget, and `ok` under `ask` in `conflicts_ask_fail_or_overwrite`. --- crates/engine/src/cmd/macros.rs | 2 +- crates/engine/src/cmd/mod.rs | 1 - crates/engine/src/macros/run.rs | 153 +++++++++++++++++++++++------- crates/engine/src/tests/macros.rs | 79 ++++++++++++++- docs/macros.md | 9 +- 5 files changed, 203 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 53856e720..b33107d16 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Check Macro", [], None, - "{name, inputs?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}", + "{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}", has_doc, check ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5e2afcddb..bcf77d4d0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -414,7 +414,6 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) Ok(r) } -/// The most written cells a macro's footprint check records. /// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes /// that kept a cell's value, so a shift is refused then. pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index a6caaaafb..d9c714352 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -911,9 +911,15 @@ fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct Footprint { - /// Steps executed by the end of the dry run that made this footprint (the run's one budget). + /// Steps executed and bytes of parameters passed by the end of the dry run that made this + /// footprint (the run's one budget). #[serde(skip)] pub executed: u64, + #[serde(skip)] + pub param_bytes: u64, + /// The Stop step that ended the dry run asks for the run to be rolled back. + #[serde(skip)] + pub rolls_back: bool, /// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value /// may be missing from `cells`: a shift cannot trust the block. #[serde(skip_serializing_if = "std::ops::Not::not")] @@ -994,6 +1000,7 @@ struct Saved { sheet_selections: Vec, unlocked_ranges: Vec<(String, String)>, trust: Trust, + go_to_recent: Vec, clipboard: Option, prefs: crate::Prefs, mode: crate::Mode, @@ -1022,6 +1029,7 @@ impl Saved { sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), trust: d.map_or(Trust::Local, |d| d.trust), + go_to_recent: d.map(|d| d.go_to_recent.clone()).unwrap_or_default(), clipboard: s.clipboard.clone(), prefs: s.prefs.clone(), mode: s.mode, @@ -1048,6 +1056,7 @@ impl Saved { d.sheet_selections = self.sheet_selections; d.unlocked_ranges = self.unlocked_ranges; d.trust = self.trust; + d.go_to_recent = self.go_to_recent; } s.active = self.active.min(s.docs.len().saturating_sub(1)); s.clipboard = self.clipboard; @@ -1070,8 +1079,9 @@ impl Saved { } /// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would -/// change. The dry run stops once `time_limit_ms` has passed on `clock`. -pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result { +/// change. The dry run spends from `run`'s budget: its clock and time limit, its steps and the +/// bytes of parameters it passed. +pub fn footprint(s: &mut Session, p: &Prepared, run: &Machine) -> Result { let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; s.set_active(i); let saved = Saved::take(s, p.doc); @@ -1081,7 +1091,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: d.touched = Some(Vec::new()); } let mut m = Machine::new(p, true); - (m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed); + (m.clock, m.time_limit_ms, m.executed, m.param_bytes) = (run.clock, run.time_limit_ms, run.executed, run.param_bytes); let outcome = m.run_to_end(s); let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), @@ -1093,7 +1103,8 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; let mut fp = diff(&before, &after, &p.inputs, &touched); fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT; - fp.executed = m.executed; + (fp.executed, fp.param_bytes) = (m.executed, m.param_bytes); + fp.rolls_back = matches!(m.stopped, Some((_, true))); fp.error = outcome.err(); fp.stopped = m.stopped.map(|(msg, _)| msg); fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); @@ -1104,6 +1115,11 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: /// without changing them (a formula written again), so a shift moves everything the macro writes. fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { let mut fp = Footprint::default(); + // One pass over the touch log (up to a million writes), not one per sheet. + let mut by_sheet: HashMap<&str, BTreeSet> = HashMap::new(); + for (n, c) in touched { + by_sheet.entry(n.as_str()).or_default().insert(*c); + } let (pairs, added) = pair_sheets(before, after); fp.sheets_added = added.into_iter().filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); for (bi, ai) in pairs { @@ -1113,7 +1129,7 @@ fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(Strin continue; }; let Some(after_sheet) = after.sheets.get(ai) else { continue }; - let rewritten: BTreeSet = touched.iter().filter(|(n, _)| *n == after_sheet.name).map(|(_, c)| *c).collect(); + let rewritten = by_sheet.remove(after_sheet.name.as_str()).unwrap_or_default(); if Arc::ptr_eq(bs, after_sheet) && rewritten.is_empty() { continue; } @@ -1238,15 +1254,33 @@ pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { /// stays together. A shift that would move one of the macro's inputs is refused: the inputs were /// checked and bound where they are, and only the block's columns (or rows) would move. fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> { + let wb = s.doc()?.wb.clone(); + if let Some(why) = shift_refusal(&wb, fp, p, down) { + return Err(EngineError::Other(why)); + } + let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + let moves: Vec = fp + .sheets + .iter() + .filter(|s| s.conflict_count > 0) + .filter_map(|sh| sh.bounds_range.map(|block| resolve::a1(Place { sheet: sh.index, range: block }, &env))) + .collect(); + for range in moves { + s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; + } + Ok(()) +} + +/// Why shifting the data can't make room for `fp`, or `None`: the macro deletes sheets or +/// objects, the footprint couldn't follow every write, or the shift would move an input. +fn shift_refusal(wb: &Workbook, fp: &Footprint, p: &Prepared, down: bool) -> Option { if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { - return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); + return Some("Shifting can't make room: the macro deletes sheets or objects.".into()); } if fp.log_full { - return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into())); + return Some("Shifting can't make room: the macro writes more cells than the footprint can follow.".into()); } - let wb = s.doc()?.wb.clone(); - let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; - let mut moves = Vec::new(); + let env = Env { wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { let Some(block) = sh.bounds_range else { continue }; // What the insertion moves: the block's columns from its top row down, or its rows from @@ -1261,17 +1295,13 @@ fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Resul // The output is where the macro writes, so moving what is there is the point; the second // footprint catches data the shift moved into it. if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) { - return Err(EngineError::Other(format!( + return Some(format!( "Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.", resolve::a1(pl, &env) - ))); + )); } - moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env)); } - for range in moves { - s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; - } - Ok(()) + None } // ---------------------------------------------------------------- run @@ -1319,9 +1349,9 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { if rule == OnConflict::Overwrite { return Ok((machine, false)); } - // The dry runs and the run share one step budget as they share one clock. - let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; - machine.executed = fp.executed; + // The dry runs and the run share one budget (steps, parameters) as they share one clock. + let mut fp = footprint(s, &b.prepared, &machine)?; + (machine.executed, machine.param_bytes) = (fp.executed, fp.param_bytes); if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1330,8 +1360,17 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { let what = fp.describe_conflicts(); match rule { OnConflict::Ask => { + // Overwriting answers for every step, also the ones a partial footprint didn't reach. + let unseen = fp.partial.as_ref().map_or(String::new(), |p| { + let text = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); + format!( + " The check stopped at step {} ({}), so overwriting also lets the steps after it write over data unchecked.", + text("step"), + text("reason") + ) + }); return Err(EngineError::Confirm(format!( - "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", + "Macro '{}' would write over cells that hold data ({what}).{unseen} Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", m.name ))); } @@ -1340,8 +1379,8 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { } OnConflict::ShiftDown | OnConflict::ShiftRight => { make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?; - fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; - machine.executed = fp.executed; + fp = footprint(s, &b.prepared, &machine)?; + (machine.executed, machine.param_bytes) = (fp.executed, fp.param_bytes); if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1457,12 +1496,21 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { .cloned() .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; let mut out = json!({"macro": m.name, "ok": false, "onConflict": m.output.on_conflict.name()}); + let refused = |mut out: Json, e: String| { + out["errors"] = json!([e]); + Ok(out) + }; + if let Err(e) = check_params(p, &m) { + return refused(out, e.to_string()); + } + let rule = match conflict_rule(p, &m) { + Ok(r) => r, + Err(e) => return refused(out, e.to_string()), + }; + out["onConflict"] = json!(rule.name()); let asks = match gate(&m, trust) { Ok(a) => a, - Err(e) => { - out["errors"] = json!([e.to_string()]); - return Ok(out); - } + Err(e) => return refused(out, e.to_string()), }; let approved = s.macro_approvals.contains(&(doc, super::fingerprint(&m))); if !asks.is_empty() && !approved { @@ -1471,10 +1519,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { } let prepared = match prepare(s, &m, p.get("inputs")) { Ok(pr) => pr, - Err(e) => { - out["errors"] = json!([e.to_string()]); - return Ok(out); - } + Err(e) => return refused(out, e.to_string()), }; out["inputs"] = prepared.inputs_json(&s.doc()?.wb); if let Some(o) = prepared.output { @@ -1484,11 +1529,47 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { if !asks.is_empty() && !approved { return Ok(out); } - let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p), 0)?; - out["ok"] = json!(fp.error.is_none()); - out["conflicts"] = json!(fp.conflict_count()); + let mut budget = Machine::new(&prepared, false); + budget.time_limit_ms = time_limit(p); + let fp = footprint(s, &prepared, &budget)?; + // `ok`: the run would go ahead with this rule, without a question. + let mut problems = Vec::new(); if let Some(e) = &fp.error { - out["errors"] = json!([e.describe()]); + problems.push(e.describe()); + } + if let (true, Some(msg)) = (fp.rolls_back, &fp.stopped) { + problems.push(format!("A Stop step ends the run and rolls it back: {msg}")); + } + let shifts = matches!(rule, OnConflict::ShiftDown | OnConflict::ShiftRight); + if fp.conflict_count() > 0 { + let what = fp.describe_conflicts(); + match rule { + OnConflict::Ask => problems.push(format!("It would write over cells that hold data ({what}); the run asks first.")), + OnConflict::Fail => problems.push(format!("It would write over cells that hold data ({what}).")), + OnConflict::Overwrite => {} + OnConflict::ShiftDown | OnConflict::ShiftRight => { + if let Some(why) = shift_refusal(&s.doc()?.wb, &fp, &prepared, rule == OnConflict::ShiftDown) { + problems.push(why); + } + } + } + } + // The run spends its budget on the dry runs before it too: one, or two when it shifts. + let passes: u64 = match rule { + OnConflict::Overwrite => 1, + _ if shifts && fp.conflict_count() > 0 => 3, + _ => 2, + }; + if fp.executed.saturating_mul(passes) > MAX_EXECUTED_STEPS { + problems.push(format!("The run would execute more than {MAX_EXECUTED_STEPS} steps, counting the check before it ({} here).", fp.executed)); + } + if fp.param_bytes.saturating_mul(passes) > MAX_PARAM_BYTES { + problems.push(format!("The run would pass more than {} MB of parameters, counting the check before it.", MAX_PARAM_BYTES / (1024 * 1024))); + } + out["ok"] = json!(problems.is_empty()); + out["conflicts"] = json!(fp.conflict_count()); + if !problems.is_empty() { + out["errors"] = json!(problems); } out["footprint"] = serde_json::to_value(&fp).unwrap_or(Json::Null); Ok(out) diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index a9d6c6179..eaa4f527b 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -168,13 +168,22 @@ fn conflicts_ask_fail_or_overwrite() { let journal = s.journal.len(); let sel = s.doc().unwrap().selection.clone(); let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap(); - assert_eq!(c["ok"], true); + // Under ask the run would ask first, so it wouldn't go ahead as it is. + assert_eq!((&c["ok"], &c["onConflict"]), (&json!(false), &json!("ask"))); + assert_eq!(c["errors"], json!(["It would write over cells that hold data (E3 on Sheet1); the run asks first."])); assert_eq!(c["conflicts"], 1); assert_eq!(c["output"], "E2"); assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["E2:E3"])); assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["E3"])); assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); assert_eq!((s.journal.len(), &s.doc().unwrap().selection), (journal, &sel)); + // With the answer given, or with the rule overwrite, it would. + for p in [json!({"overwrite": true}), json!({"onConflict": "overwrite"})] { + let mut p = p; + (p["name"], p["inputs"]) = (json!("Totals"), json!("B2:C5")); + let c = s.execute("macro.check", p.clone()).unwrap(); + assert_eq!((&c["ok"], &c["onConflict"], c.get("errors")), (&json!(true), &json!("overwrite"), None), "{p}"); + } // The answer to the question overwrites. s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "overwrite": true})).unwrap(); @@ -520,6 +529,74 @@ fn a_run_passes_a_bounded_amount_of_parameters() { 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}"); + // 50 rows are 150 MB: the run alone stays under the limit, the dry run and the run together + // go past it, as they share the step budget. + let body = json!([{"command": "home.bold", "params": {"range": "A1", "note": "x".repeat(3_000_000)}}]); + for (rule, ok) in [("overwrite", true), ("fail", false)] { + let repeat = json!({"type": "repeat", "over": "A1:A50", "body": body}); + put(&mut s, json!({"name": "Half", "output": {"onConflict": rule}, "steps": [repeat]})); + assert_eq!(s.execute("macro.run", json!({"name": "Half"})).is_ok(), ok, "{rule}"); + } +} + +#[test] +fn the_check_says_whether_the_run_would_go_ahead() { + let mut s = s(); + // A Stop step that rolls the run back. + put(&mut s, json!({"name": "Halt", "output": {"onConflict": "fail"}, "steps": [{"type": "stop", "message": "Not today.", "rollback": true}]})); + let c = s.execute("macro.check", json!({"name": "Halt"})).unwrap(); + assert_eq!((&c["ok"], &c["errors"]), (&json!(false), &json!(["A Stop step ends the run and rolls it back: Not today."]))); + // A shift that would move an input. + set(&mut s, "B1", "taken"); + set(&mut s, "B3", "in"); + let step = json!({"command": "cell.set", "params": {"cell": "B1", "input": "new"}}); + put(&mut s, json!({"name": "Move", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "shiftDown"}, "steps": [step]})); + let c = s.execute("macro.check", json!({"name": "Move", "inputs": "B3"})).unwrap(); + assert_eq!(c["ok"], false); + assert!(c["errors"][0].as_str().unwrap().starts_with("Shifting the data would move Input (Sheet1!B3)."), "{c}"); + // A macro whose dry run takes 90,000 steps passes its own dry run, but the run spends the + // budget twice under fail, so the check says it wouldn't finish. + let spin = json!([{"type": "repeat", "over": "A1:A300", "body": [{"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]}]}]); + for (rule, ok) in [("fail", false), ("overwrite", true)] { + put(&mut s, json!({"name": "Spin", "output": {"onConflict": rule}, "steps": spin})); + let c = s.execute("macro.check", json!({"name": "Spin"})).unwrap(); + assert_eq!(c["ok"], ok, "{rule}: {c}"); + assert_eq!(s.execute("macro.run", json!({"name": "Spin"})).is_ok(), ok, "{rule}: the run agrees"); + } + let c = s.execute("macro.check", json!({"name": "Spin", "onConflict": "fail"})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().starts_with("The run would execute more than 100000 steps, counting the check before it"), "{c}"); + // Parameters the run would refuse, the check reports. + let c = s.execute("macro.check", json!({"name": "Spin", "onConflict": 5})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("`onConflict` must be"), "{c}"); +} + +#[test] +fn the_question_after_a_partial_footprint_says_what_the_answer_covers() { + let dir = std::env::temp_dir().join(format!("gc-macro-unseen-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("out.csv").to_string_lossy().to_string(); + let mut s = s(); + set(&mut s, "B1", "keep"); + set(&mut s, "C1", "also"); + let steps = json!([ + {"command": "cell.set", "params": {"cell": "B1", "input": "over"}}, + {"command": "file.exportCsv", "params": {"path": csv}}, + {"command": "cell.set", "params": {"cell": "C1", "input": "over"}} + ]); + put(&mut s, json!({"name": "Two", "permissions": ["file.exportCsv"], "steps": steps})); + let r = s.execute("macro.run", json!({"name": "Two", "approve": true})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert!(q.contains("(B1 on Sheet1). The check stopped at step 2 (file.exportCsv writes a file), so overwriting also lets the steps after it write over data unchecked."), "{q}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_dry_run_puts_back_the_go_to_list() { + let mut s = s(); + let step = json!({"command": "edit.goTo", "params": {"reference": "D4", "record": true}}); + put(&mut s, json!({"name": "Go", "steps": [step]})); + s.execute("macro.check", json!({"name": "Go"})).unwrap(); + assert_eq!(s.doc().unwrap().go_to_recent, Vec::::new()); } #[test] diff --git a/docs/macros.md b/docs/macros.md index 994006c85..c3a9c2198 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -135,8 +135,13 @@ refused rather than read as the header. do; a step that deletes one of them, or changes the size of a range a Repeat is going over, stops the run. -`macro.check {name, inputs?, timeLimit?}` reports the bound inputs, the output anchor, any -approval needed and the footprint, without changing anything. +`macro.check {name, inputs?, onConflict?, overwrite?, timeLimit?}` reports the bound inputs, the +output anchor, any approval needed and the footprint, and puts back everything the dry run +changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; +when it wouldn't, `errors` says why: the run would fail at a step, a Stop step rolls it back, it +would write over data under `ask` or `fail`, shifting can't make room, or the run would go past +the step or parameter limit counting the dry runs before it. The random numbers RAND gives move +on, as after any recalculation. ## Storage From 2cf5b808b3caec40dc4e5694092326d742ea8e5b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:49:42 -0400 Subject: [PATCH 117/166] Docs: the macro page's conflict rules wrap at the page's width --- docs/macros.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index a3f18ebb6..c0a09890f 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -115,10 +115,11 @@ refused rather than read as the header. 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: the check compares cells by place. 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. + counts as writing over them: the check compares cells by place. 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, From d309c85c3193b1f199bcdeb977997cc4a660c64b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:52:43 -0400 Subject: [PATCH 118/166] Engine: Stop Action waits while a macro is being debugged `automate.stopAction` rolls back the undo groups open on the workbook. While a macro was being debugged it rolled back the debugger's own group and left the debugger set, pointing at a group that was gone. It is now refused under the debugger's lock with "Finish or stop the macro first", as edits are; `macro.debug {action: "stop"}` ends the run. - Test: `stop_action_waits_while_a_macro_is_being_debugged`. --- crates/engine/src/lib.rs | 5 ++++- crates/engine/src/tests/macros.rs | 16 ++++++++++++++++ docs/macros.md | 5 +++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 844494ec5..19e76227a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -615,7 +615,10 @@ impl Session { // recording, so stopping again after the run keeps what was recorded. let stops_record_actions = id == "automate.recordActions" && (params.get("stop").and_then(Json::as_bool) == Some(true) || self.recording.is_some()); - if (spec.undoable || stops_record_actions) && id != "macro.debug" && locked { + // Stop Action rolls back the open groups, the debugger's among them, and would leave the + // debugger pointing at a group that is gone: stopping the macro is the debugger's own. + let stops_groups = id == "automate.stopAction"; + if (spec.undoable || stops_record_actions || stops_groups) && id != "macro.debug" && locked { return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into())); } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 0082f783d..7f5adf0f9 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -295,6 +295,22 @@ fn shifting_makes_room_and_is_checked_again() { assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); } +#[test] +fn stop_action_waits_while_a_macro_is_being_debugged() { + // Stop Action rolls back the open undo groups; the debugger's is one, so it waits for the + // debugger, which stops the macro itself. + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + let r = s.execute("automate.stopAction", json!({})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why == crate::macros::debug::BUSY), "{r:?}"); + assert!(s.macro_debug.is_some(), "the debugger is still there"); + s.execute("macro.debug", json!({"action": "stop"})).unwrap(); + assert!(s.macro_debug.is_none()); + s.execute("automate.stopAction", json!({})).unwrap(); +} + #[test] fn closing_the_debugged_workbook_ends_the_debugger() { // `file.close` is not an edit, so the debugger's lock lets it through; the group went with diff --git a/docs/macros.md b/docs/macros.md index a126c80d2..4006096a7 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -150,8 +150,9 @@ on, as after any recalculation. be given with any action; `breakpointsOnly: true` makes the list replace the steps' own flags. The run stays one undo step; while it is open, edits of the workbooks it touched (its own, and any other its steps changed) are refused with "Finish or stop the macro first", also when another -command makes them (stopping Record Actions saves a script), and other workbooks stay -editable. Each answer gives the next step with its parameters as they would run, the parameters +command makes them (stopping Record Actions saves a script), and so is Automate › Stop Action, +which would roll the run's undo group back under the debugger; `stop` ends the run instead. Other +workbooks stay editable. Each answer gives the next step with its parameters as they would run, the parameters of the step that just ran, and the cells changed so far. ## Storage From 7dad14e77e231d4b72db8b2b0b53c0ba90f56e0e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:17:42 -0400 Subject: [PATCH 119/166] Engine: the macro debugger locks its workbook to looking around, ends with Stop Action and checks its parameters - `macro.debug` refuses to run as a step of a script or macro, and is never recorded (the recording buffer and Record Actions' journal both leave it out); a debugger whose undo group something else closed locks nothing, and its next action says the run had ended instead of reporting a keep or a rollback. Tests: `the_debugger_runs_on_its_own_and_is_never_recorded`, `a_debugger_whose_group_was_closed_elsewhere_locks_nothing`. - Stop Action is no longer refused under the debugger: it rolls the run back, ends the debugger and names the macro. Test: `stop_action_stops_a_macro_being_debugged` (was `stop_action_waits_while_a_macro_is_being_debugged`). - Between steps the debugged workbook runs only `macro.debug`, queries and an allow-list of commands that look around (selection, sheet changes, Go To, Find, Copy, zoom, Switch Window, Close, Stop Action); everything else is refused, saving and view modes included. The run's active sheet and selection are put back before its next step. Test: `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`. - Only the top-level `macro.debug` call rolls the run back after a panic; a panic nested in a step is that step's error. Test: `a_panic_inside_a_step_is_that_steps_error_and_the_run_goes_on`. - `keep` and `breakpointsOnly` must be true or false, breakpoints must be step paths of the macro (at most 10,000), and the action is checked before breakpoints change. Test: `debugger_parameters_are_checked_before_anything_changes`. - Answers: `executed` counts the run's own steps (not the footprint's), `shifted` and `wroteFiles` are reported, a rollback's message names the files that stay, `changed` lists deleted and renamed sheets and at most 500 entries with `changedCells`, and a Repeat over whole rows or columns shows the part in use. Tests: `the_debuggers_answers_count_its_own_steps_and_say_what_stays`, `the_debugger_lists_deleted_and_renamed_sheets_and_caps_the_list`, `the_debugger_shows_a_repeat_over_whole_columns_as_the_run_goes_over_them`. --- crates/engine/src/cmd/extra.rs | 24 +- crates/engine/src/cmd/macros.rs | 2 +- crates/engine/src/lib.rs | 33 ++- crates/engine/src/macros/debug.rs | 379 +++++++++++++++++++++++------- crates/engine/src/macros/run.rs | 6 +- crates/engine/src/record.rs | 7 +- crates/engine/src/tests/macros.rs | 278 +++++++++++++++++++++- 7 files changed, 617 insertions(+), 112 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 858dc9372..679728a63 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -217,7 +217,7 @@ pub fn specs() -> Vec { "Stop Action", [], None, - "{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began", + "{} → {stopped: bool, macro?, message?}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began; a macro being debugged there is stopped (`macro` names it)", has_doc, stop_action ), @@ -978,8 +978,13 @@ fn queries(s: &mut Session, _: &Json) -> Result { fn record(s: &mut Session, p: &Json) -> Result { if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; - let cmds: Vec = - s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect(); + let cmds: Vec = s + .journal + .iter() + .skip(start) + .filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str())) + .map(|(id, p)| json!({"command": id, "params": p})) + .collect(); let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); @@ -1102,7 +1107,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { } /// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and -/// Redo work there again. +/// Redo work there again. A macro being debugged on that workbook is stopped with them. fn stop_action(s: &mut Session, _: &Json) -> Result { // Inside another command the open groups are that command's own (an action in progress): // a step of it that stopped them would pull the workbook from under the rest of its run. @@ -1110,7 +1115,16 @@ fn stop_action(s: &mut Session, _: &Json) -> Result { return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into())); } let uid = s.doc()?.uid; - Ok(json!({"stopped": s.abort_groups(uid)})) + let mut out = json!({"stopped": s.abort_groups(uid)}); + // A macro being debugged here had its group among them: its run is over. + if let Some((name, wrote_files)) = crate::macros::debug::end_stopped(s, uid) { + out["macro"] = json!(name); + out["message"] = json!(format!( + "Macro '{name}', which was being debugged, was stopped and rolled back. {}", + crate::macros::run::rolled_back(&wrote_files) + )); + } + Ok(out) } fn delete_script(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 76c0134b3..b7e53ec03 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Step Into", [], None, - "{action: start|step|continue|runTo|stop|state, name? and inputs?, onConflict?, approve? (start), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"], breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time as one undo step; edits of the workbooks it touched wait until it finishes or stops (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed, atBreakpoint?, error?}", + "{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}", has_doc, |s, p| crate::macros::debug::debug(s, p) ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 19e76227a..d85616c5e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -607,18 +607,19 @@ impl Session { if let Err(why) = (spec.enabled)(self) { return Err(EngineError::Disabled(id.to_string(), why)); } - // While a macro is being debugged its undo group is open: other edits of the workbooks it - // touched wait, also when another command runs them (a script, or a recording that stops - // and saves). Only the run's own steps go ahead. Other workbooks stay editable. + // A debugger whose undo group something else ended locks nothing any more (`macro.debug` + // itself says that the run had ended). + if id != "macro.debug" { + macros::debug::drop_if_ended(self); + } + let query = !spec.journal && !spec.undoable; + // While a macro is being debugged its undo group is open, and its workbook is locked: + // between steps only the debugger, queries and commands that look around run there + // (`macros::debug::LOOKS_AROUND`). Anything else waits, undoable or not (saving, view + // modes, stopping Record Actions, which saves a script), also when another command runs + // it. Only the run's own steps go ahead. Other workbooks stay editable. let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping())); - // Stopping Record Actions saves a script into the workbook; refuse it before it takes the - // recording, so stopping again after the run keeps what was recorded. - let stops_record_actions = - id == "automate.recordActions" && (params.get("stop").and_then(Json::as_bool) == Some(true) || self.recording.is_some()); - // Stop Action rolls back the open groups, the debugger's among them, and would leave the - // debugger pointing at a group that is gone: stopping the macro is the debugger's own. - let stops_groups = id == "automate.stopAction"; - if (spec.undoable || stops_record_actions || stops_groups) && id != "macro.debug" && locked { + if locked && !macros::debug::runs_while_locked(id, query) { return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into())); } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); @@ -631,7 +632,6 @@ impl Session { // because a nested `edit.undo` removes entries. A query runs nothing that changes the // history (`every_query_leaves_the_history_alone` runs each one), so it needs no // copy; front ends run queries all the time. - let query = !spec.journal && !spec.undoable; let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { #[cfg(test)] tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1))); @@ -689,8 +689,10 @@ impl Session { } d.calc.rebuild(&d.wb); } - macros::debug::recover(self); if self.depth == 0 { + // Only the `macro.debug` call itself can lose the run's place: a command nested + // in a step that panics is an error of that step, which the run handles. + macros::debug::recover(self); self.trim_history(); self.group_step = None; } @@ -970,6 +972,11 @@ impl Session { r } + /// Whether the token's undo group is still open (nothing has ended or rolled it back). + pub fn group_is_open(&self, token: &GroupToken) -> bool { + self.groups.iter().any(|g| g.id == token.id) + } + /// Whether an undo group is open on the document with this uid. pub fn in_group(&self, doc: u64) -> bool { self.groups.iter().any(|g| g.doc == doc) diff --git a/crates/engine/src/macros/debug.rs b/crates/engine/src/macros/debug.rs index 19aa03167..acbc320f3 100644 --- a/crates/engine/src/macros/debug.rs +++ b/crates/engine/src/macros/debug.rs @@ -2,10 +2,11 @@ //! //! `macro.debug` starts a run that pauses before its first step, then steps, continues to the next //! breakpoint, runs to a chosen step, or stops. The run lives on the session between calls (one -//! action per call) inside an undo group that spans them, so a debugged run is still -//! one undo step; Stop keeps the changes or rolls them back. While a macro is being debugged, -//! other edits of the workbooks it touched (its own, and any other its steps changed) are refused -//! ("Finish or stop the macro first"); other workbooks stay editable. +//! action per call) inside an undo group that spans them; Stop keeps the changes as one undo step +//! or rolls them back. While a macro is being debugged its workbook is locked: only the +//! debugger, queries and the commands in [`LOOKS_AROUND`] run there, and everything else, saving +//! included, is refused ("Finish or stop the macro first"). Other workbooks stay editable. The +//! debugger runs on its own, never as a step of a script or macro, and is never recorded. use std::collections::BTreeSet; use std::sync::Arc; @@ -13,8 +14,45 @@ use std::sync::Arc; use gridcraft_model::Workbook; use serde_json::{Value as Json, json}; -use super::run::{Machine, StepError}; -use crate::{EngineError, GroupToken, Result, Session}; +use super::run::{Machine, StepError, rolled_back}; +use crate::{EngineError, GroupToken, Result, Selection, Session}; + +/// The most breakpoints one call sets. +pub const MAX_BREAKPOINTS: usize = 10_000; +/// The most entries `changed` lists (ranges and sheet notes), as the footprint lists ranges. +const MAX_LISTED: usize = 500; + +/// Commands that run on the debugged workbook between steps, besides `macro.debug` and queries. +/// They only look around (the selection, the active sheet, Go To, Find, zoom, copying), switch to +/// another window, close the workbook (which ends the run) or stop the run (Stop Action). The +/// debugger puts the run's active sheet and selection back before its next step runs. +pub const LOOKS_AROUND: &[&str] = &[ + "selection.set", + "selection.move", + "selection.next", + "selection.row", + "selection.column", + "selection.currentRegion", + "edit.selectAll", + "sheet.activate", + "sheet.next", + "sheet.previous", + "edit.goTo", + "edit.goToSpecial", + "edit.find", + "edit.copy", + "view.zoom", + "view.zoom100", + "view.zoomToSelection", + "window.activate", + "file.close", + "automate.stopAction", +]; + +/// Whether command `id` (a query when `query`) runs on a workbook the debugger locks. +pub fn runs_while_locked(id: &str, query: bool) -> bool { + query || id == "macro.debug" || LOOKS_AROUND.contains(&id) +} /// A macro being debugged. pub struct Debugger { @@ -30,11 +68,27 @@ pub struct Debugger { pub only_listed: bool, /// The workbook when the run started, for "changed so far". start: Arc, - /// Every step path in the macro, for checking `runTo` targets. + /// Every step path in the macro, for checking `runTo` targets and breakpoints. paths: BTreeSet, - /// The workbooks the run has touched (by uid): its own, and any other a step changed. Edits - /// of these wait until the run ends. + /// The workbooks the run has touched (by uid). Steps run on the macro's own workbook and the + /// policy refuses window and file commands, so this is the macro's workbook unless a command + /// reaches another one some other way; edits of these wait until the run ends. touched: BTreeSet, + /// Steps the footprint's dry runs executed before the run (they count towards the run's + /// budget, not towards `executed`). + base: u64, + /// The conflict rule that shifted the data at the start (`shiftDown`, `shiftRight`). + shifted: Option<&'static str>, + /// Where the run left its workbook after the last action. + view: Option, +} + +/// The active sheet and the selections of the debugged workbook where the run left them. +#[derive(Clone)] +struct View { + sheet: usize, + selection: Selection, + sheet_selections: Vec, } impl Debugger { @@ -47,6 +101,11 @@ impl Debugger { pub fn stepping(&self) -> bool { self.machine.is_none() } + + /// The run's own steps so far. + fn executed(&self) -> u64 { + self.machine.as_ref().map_or(0, |m| m.executed.saturating_sub(self.base)) + } } /// Each open workbook's uid and its current version, to see which ones a step changes. @@ -63,52 +122,131 @@ fn note_touched(s: &mut Session, before: &[(u64, Arc)]) { } } +/// Remembers where the run left its workbook: the active sheet and the selections. +fn remember_view(s: &mut Session) { + let Some(uid) = s.macro_debug.as_ref().map(|d| d.doc) else { return }; + let view = s.docs.iter().find(|d| d.uid == uid).map(|d| View { + sheet: d.wb.active_sheet, + selection: d.selection.clone(), + sheet_selections: d.sheet_selections.clone(), + }); + if let Some(dbg) = s.macro_debug.as_mut() { + dbg.view = view; + } +} + +/// Puts back the active sheet and the selections where the run left them, so the next step runs +/// where `macro.run` would run it, however the person looked around meanwhile. +fn put_back_view(s: &mut Session) { + let Some((uid, Some(view))) = s.macro_debug.as_ref().map(|d| (d.doc, d.view.clone())) else { return }; + let Some(d) = s.docs.iter_mut().find(|d| d.uid == uid) else { return }; + if d.wb.active_sheet != view.sheet && view.sheet < d.wb.sheets.len() { + // The active sheet is view state: change it without making the workbook dirty. + let dirty = d.is_dirty(); + Arc::make_mut(&mut d.wb).active_sheet = view.sheet; + if !dirty { + d.saved = d.wb.clone(); + } + } + d.selection = view.selection; + d.sheet_selections = view.sheet_selections; +} + /// The message for edits refused while a macro is being debugged. pub const BUSY: &str = "Finish or stop the macro first"; +/// Ends a debugger whose undo group is no longer open (something other than the debugger ended +/// or rolled it back): its workbook is not locked any more. Returns the macro's name when it did. +pub(crate) fn drop_if_ended(s: &mut Session) -> Option { + let d = s.macro_debug.as_ref()?; + if d.stepping() || d.token.as_ref().is_some_and(|t| s.group_is_open(t)) { + return None; + } + s.macro_debug.take().map(|d| d.name) +} + +/// After Stop Action rolled back the undo groups of the workbook with uid `doc`: ends the +/// debugger if its run was there. Returns the macro's name and the files the run wrote, which stay. +pub(crate) fn end_stopped(s: &mut Session, doc: u64) -> Option<(String, Vec)> { + if !s.macro_debug.as_ref().is_some_and(|d| d.doc == doc && !d.stepping()) { + return None; + } + let d = s.macro_debug.take()?; + Some((d.name, d.machine.map(|m| m.wrote_files).unwrap_or_default())) +} + +/// Why an action finds the run already ended: its undo group was closed outside the debugger. +fn already_ended(name: &str) -> String { + format!("Macro '{name}' was no longer being debugged: its undo group had been closed outside the debugger, so nothing was kept or rolled back.") +} + enum Mode { Step, Continue, RunTo(String), } +const ACTIONS: [&str; 6] = ["start", "step", "continue", "runTo", "stop", "state"]; + /// `macro.debug {name?, inputs?, action: start|step|continue|runTo|stop|state, step?, keep?, -/// breakpoints?}`. +/// breakpoints?, breakpointsOnly?}`. pub fn debug(s: &mut Session, p: &Json) -> Result { + // A step of a script or macro that started the debugger would leave it behind when the + // script fails and rolls back its groups, the debugger's among them. + if s.depth() > 1 { + return Err(EngineError::Disabled("macro.debug".into(), "The macro debugger runs on its own, not as a step of a script or macro.".into())); + } + let ended = drop_if_ended(s); let action = match p.get("action") { + None | Some(Json::Null) if s.macro_debug.is_some() || ended.is_some() => "state".to_string(), + None | Some(Json::Null) => "start".to_string(), Some(Json::String(a)) => a.clone(), // `{"action": {"runTo": {"step": "3"}}}` - Some(Json::Object(o)) => o.keys().next().cloned().unwrap_or_default(), - _ if s.macro_debug.is_some() => "state".into(), - _ => "start".into(), + Some(Json::Object(o)) if o.len() == 1 => o.keys().next().cloned().unwrap_or_default(), + Some(_) => return Err(bad("`action` must be start, step, continue, runTo, stop or state")), }; - if action != "start" - && let (Some(d), Some(bps)) = (s.macro_debug.as_mut(), p.get("breakpoints")) + if !ACTIONS.contains(&action.as_str()) { + return Err(bad(format!("action must be start, step, continue, runTo, stop or state, not '{action}'"))); + } + if let Some(name) = ended + && action != "start" { - d.breakpoints = paths_param(bps)?; - if let Some(only) = p.get("breakpointsOnly").and_then(Json::as_bool) { + return Err(EngineError::Other(already_ended(&name))); + } + let only = bool_param(p, "breakpointsOnly")?; + if action == "start" { + return start(s, p, only.unwrap_or(false)); + } + let keep = bool_param(p, "keep")?; + let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?; + let target = if action == "runTo" { + let target = p + .get("step") + .or_else(|| p.pointer("/action/runTo/step")) + .and_then(path_of) + .ok_or_else(|| bad("`runTo` needs `step`: the path of the step to stop at"))?; + if !d.paths.contains(&target) { + return Err(bad(format!("macro '{}' has no step {target}", d.name))); + } + Some(target) + } else { + None + }; + let breakpoints = p.get("breakpoints").filter(|v| !v.is_null()).map(|v| paths_param(v, &d.paths, &d.name)).transpose()?; + if let Some(d) = s.macro_debug.as_mut() { + if let Some(bps) = breakpoints { + d.breakpoints = bps; + } + if let Some(only) = only { d.only_listed = only; } } - match action.as_str() { - "start" => start(s, p), - "step" => advance(s, Mode::Step), - "continue" => advance(s, Mode::Continue), - "runTo" => { - let target = p - .get("step") - .or_else(|| p.pointer("/action/runTo/step")) - .and_then(path_of) - .ok_or_else(|| bad("`runTo` needs `step`: the path of the step to stop at"))?; - let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?; - if !d.paths.contains(&target) { - return Err(bad(format!("macro '{}' has no step {target}", d.name))); - } - advance(s, Mode::RunTo(target)) - } - "stop" => stop(s, p.get("keep").and_then(Json::as_bool).unwrap_or(false)), - "state" => state(s), - other => Err(bad(format!("action must be start, step, continue, runTo, stop or state, not '{other}'"))), + match (action.as_str(), target) { + ("step", _) => advance(s, Mode::Step), + ("continue", _) => advance(s, Mode::Continue), + ("runTo", Some(target)) => advance(s, Mode::RunTo(target)), + ("stop", _) => stop(s, keep.unwrap_or(false)), + _ => state(s), } } @@ -120,6 +258,15 @@ fn not_debugging() -> EngineError { EngineError::Other("No macro is being debugged. Start one with action: start.".into()) } +/// `true`, `false`, or not given (`null` counts as not given); anything else is refused. +fn bool_param(p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(format!("`{key}` must be true or false"))), + } +} + /// A step path from JSON: `"2.1"` or `3`. fn path_of(v: &Json) -> Option { match v { @@ -129,47 +276,66 @@ fn path_of(v: &Json) -> Option { } } -fn paths_param(v: &Json) -> Result> { +/// The `breakpoints` list: step paths of macro `name` (one of `paths`), at most +/// [`MAX_BREAKPOINTS`]. +fn paths_param(v: &Json, paths: &BTreeSet, name: &str) -> Result> { let list = v.as_array().ok_or_else(|| bad("`breakpoints` must be a list of step paths"))?; - list.iter().take(10_000).map(|x| path_of(x).ok_or_else(|| bad("a breakpoint is a step path such as \"3\" or \"2.1\""))).collect() + if list.len() > MAX_BREAKPOINTS { + return Err(bad(format!("`breakpoints` lists {} steps; at most {MAX_BREAKPOINTS} can be set", list.len()))); + } + let mut out = BTreeSet::new(); + for x in list { + let path = path_of(x).ok_or_else(|| bad("a breakpoint is a step path such as \"3\" or \"2.1\""))?; + if !paths.contains(&path) { + return Err(bad(format!("macro '{name}' has no step {path} for a breakpoint"))); + } + out.insert(path); + } + Ok(out) } -fn start(s: &mut Session, p: &Json) -> Result { +fn start(s: &mut Session, p: &Json, only_listed: bool) -> Result { if let Some(d) = &s.macro_debug { return Err(EngineError::Other(format!("Macro '{}' is being debugged. Stop it first.", d.name))); } let name = p.get("name").and_then(Json::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("missing `name`"))?; let b = super::run::begin(s, name, p)?; - let breakpoints = match p.get("breakpoints") { - Some(v) => paths_param(v)?, + let paths = all_paths(&b.mac.steps, ""); + let breakpoints = match p.get("breakpoints").filter(|v| !v.is_null()) { + Some(v) => paths_param(v, &paths, &b.mac.name)?, None => BTreeSet::new(), }; let start_wb = s.doc()?.wb.clone(); let token = s.group_begin(&format!("Run Macro: {}", b.mac.name))?; // The run's machine: unless the rule is overwrite, it checks each step for writes over data. - let mut machine = match super::run::make_way(s, &b) { - Ok((machine, _)) => machine, + let (mut machine, shifted) = match super::run::make_way(s, &b) { + Ok(made) => made, Err(e) => { let _ = s.group_rollback(token); return Err(e); } }; + // Each action has the time limit to itself; the dry runs just had theirs. machine.restart_clock(); - let paths = all_paths(&b.mac.steps, ""); + let base = machine.executed; s.macro_debug = Some(Debugger { name: b.mac.name.clone(), doc: b.prepared.doc, machine: Some(machine), token: Some(token), breakpoints, - only_listed: p.get("breakpointsOnly").and_then(Json::as_bool).unwrap_or(false), + only_listed, start: start_wb, paths, touched: BTreeSet::from([b.prepared.doc]), + base, + shifted: shifted.then(|| b.rule.name()), + view: None, }); if s.macro_debug.as_ref().and_then(|d| d.machine.as_ref()).is_some_and(Machine::is_done) { return finish(s, None); } + remember_view(s); state(s) } @@ -192,8 +358,9 @@ fn all_paths(steps: &[gridcraft_model::Step], prefix: &str) -> BTreeSet /// Runs one step (`Step`), or steps until a breakpoint, the target step or the end. fn advance(s: &mut Session, mode: Mode) -> Result { + put_back_view(s); let d = s.macro_debug.as_mut().ok_or_else(not_debugging)?; - let name = d.name.clone(); + let (name, base) = (d.name.clone(), d.base); let breakpoints = d.breakpoints.clone(); let only_listed = d.only_listed; let mut machine = d.machine.take().ok_or_else(not_debugging)?; @@ -225,13 +392,12 @@ fn advance(s: &mut Session, mode: Mode) -> Result { } } let done = machine.is_done(); - let executed = machine.executed; match s.macro_debug.as_mut() { Some(d) => d.machine = Some(machine), - // A step panicked and `recover` has already rolled the run back. + // The macro.debug call panicked and `recover` has already rolled the run back. None => { return match outcome { - Err(e) => Ok(failed(&name, executed, &e)), + Err(e) => Ok(failed(&name, machine.executed.saturating_sub(base), &e)), Ok(()) => Err(not_debugging()), }; } @@ -243,23 +409,41 @@ fn advance(s: &mut Session, mode: Mode) -> Result { if done { return finish(s, None); } + remember_view(s); let mut out = state(s)?; out["atBreakpoint"] = json!(hit); Ok(out) } +/// What every answer about the run's end or its state adds: the data shift at the start and the +/// files the run wrote (they stay, whatever happens to the workbook). +fn add_notes(out: &mut Json, d: &Debugger, wrote_files: &[String]) { + if let Some(rule) = d.shifted { + out["shifted"] = json!(rule); + } + if !wrote_files.is_empty() { + out["wroteFiles"] = json!(wrote_files); + } +} + /// Ends the run after its last step (kept as one undo step, unless a Stop step asked for a /// rollback) or a failed step (rolled back). fn finish(s: &mut Session, error: Option) -> Result { let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) }; + let (changed, cells) = changed(s, &d); + let executed = d.executed(); let machine = d.machine.take(); - let changed = changed(s, &d); let stopped = machine.as_ref().and_then(|m| m.stopped.clone()); - let executed = machine.as_ref().map_or(0, |m| m.executed); + let wrote = machine.map(|m| m.wrote_files).unwrap_or_default(); let rollback = error.is_some() || matches!(stopped, Some((_, true))); - if let Some(token) = d.token.take() { - // The group is gone only if its workbook was closed; then there is nothing to end. - let _ = if rollback { s.group_rollback(token) } else { s.group_end(token).map(|_| ()) }; + let ended = match d.token.take() { + Some(token) if rollback => s.group_rollback(token).is_ok(), + Some(token) => s.group_end(token).is_ok(), + None => false, + }; + if !ended { + let step = error.map(|e| format!("{} ", e.describe())).unwrap_or_default(); + return Err(EngineError::Other(format!("{step}{}", already_ended(&d.name)))); } if let Some(e) = &error { return Ok(failed(&d.name, executed, e)); @@ -270,79 +454,114 @@ fn finish(s: &mut Session, error: Option) -> Result { out["state"] = json!("stopped"); out["stopped"] = json!(msg); out["kept"] = json!(!rolled); + if *rolled { + out["message"] = json!(rolled_back(&wrote)); + } } None => out["state"] = json!("finished"), } if !rollback { out["changed"] = json!(changed); + out["changedCells"] = json!(cells); } + add_notes(&mut out, &d, &wrote); Ok(out) } /// The result of a run that a failed step rolled back. fn failed(name: &str, executed: u64, e: &StepError) -> Json { - json!({ + let mut out = json!({ "macro": name, "executed": executed, "state": "failed", "error": serde_json::to_value(e).unwrap_or(Json::Null), - "message": format!("{} Nothing was changed.", e.describe()), - }) + "message": format!("{} {}", e.describe(), rolled_back(&e.wrote_files)), + }); + if !e.wrote_files.is_empty() { + out["wroteFiles"] = json!(e.wrote_files); + } + out } /// Stops the run: keeps what it did as one undo step, or rolls it back. fn stop(s: &mut Session, keep: bool) -> Result { let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) }; - let changed = changed(s, &d); - if let Some(token) = d.token.take() { - let _ = if keep { s.group_end(token).map(|_| ()) } else { s.group_rollback(token) }; + let (changed, cells) = changed(s, &d); + let ended = match d.token.take() { + Some(token) if keep => s.group_end(token).is_ok(), + Some(token) => s.group_rollback(token).is_ok(), + None => false, + }; + if !ended { + return Err(EngineError::Other(already_ended(&d.name))); } - let executed = d.machine.as_ref().map_or(0, |m| m.executed); - let mut out = json!({"state": "stopped", "macro": d.name, "kept": keep, "executed": executed}); + let wrote = d.machine.as_ref().map(|m| m.wrote_files.clone()).unwrap_or_default(); + let mut out = json!({"state": "stopped", "macro": d.name, "kept": keep, "executed": d.executed()}); if keep { out["changed"] = json!(changed); + out["changedCells"] = json!(cells); + } else { + out["message"] = json!(rolled_back(&wrote)); } + add_notes(&mut out, &d, &wrote); Ok(out) } /// Where the run is: the next step with its parameters as they would run, the step that just -/// ran, and the cells changed so far. +/// ran, and what the run has changed in its workbook so far. fn state(s: &mut Session) -> Result { let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?; let machine = d.machine.as_ref().ok_or_else(not_debugging)?; let wb = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()).ok_or(EngineError::NoDocument)?; - Ok(json!({ + let (changed, cells) = changed(s, d); + let mut out = json!({ "state": "paused", "macro": d.name, "current": machine.current_json(&wb), "lastParams": machine.last_params, - "executed": machine.executed, + "executed": d.executed(), "breakpoints": d.breakpoints, - "changed": changed(s, d), - })) + "changed": changed, + "changedCells": cells, + }); + add_notes(&mut out, d, &machine.wrote_files); + Ok(out) } -/// The cells changed since the run started, as `Sheet!A1:B2` ranges. -fn changed(s: &Session, d: &Debugger) -> Vec { - let Some(now) = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()) else { return vec![] }; +/// What the run changed in its workbook since it started: cells as `Sheet!A1:B2` ranges and the +/// sheets it added, deleted or renamed (at most [`MAX_LISTED`] entries), and how many cells +/// changed on the sheets the workbook had at the start. +fn changed(s: &Session, d: &Debugger) -> (Vec, usize) { + let Some(now) = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()) else { return (vec![], 0) }; + let (pairs, added) = super::run::pair_sheets(&d.start, &now); let mut out = Vec::new(); - for sh in &now.sheets { - let Some(before) = d.start.sheet_index(&sh.name).and_then(|i| d.start.sheets.get(i)) else { - out.push(format!("{} (new sheet)", sh.name)); + let mut count = 0usize; + for (bi, ai) in pairs { + let Some(before) = d.start.sheets.get(bi) else { continue }; + let Some(after) = ai.and_then(|a| now.sheets.get(a)) else { + out.push(format!("{} (deleted sheet)", before.name)); continue; }; - if Arc::ptr_eq(before, sh) { + if before.name != after.name { + out.push(format!("{} (renamed from {})", after.name, before.name)); + } + if Arc::ptr_eq(before, after) { continue; } - let mut cells = before.cells.diff(&sh.cells); - let q = super::resolve::quote_sheet(&sh.name); - out.extend(super::run::ranges(&mut cells).into_iter().map(|r| format!("{q}!{r}"))); + let mut cells = before.cells.diff(&after.cells); + count = count.saturating_add(cells.len()); + if out.len() < MAX_LISTED { + let q = super::resolve::quote_sheet(&after.name); + out.extend(super::run::ranges(&mut cells).into_iter().map(|r| format!("{q}!{r}"))); + } } - out + out.extend(added.into_iter().filter_map(|a| now.sheets.get(a)).map(|sh| format!("{} (new sheet)", sh.name))); + out.truncate(MAX_LISTED); + (out, count) } -/// After a command panicked while a macro was being debugged: if the run lost its place, roll -/// it back so its undo group doesn't stay open. +/// After the `macro.debug` call itself panicked: if the run lost its place, roll it back so its +/// undo group doesn't stay open. pub(crate) fn recover(s: &mut Session) { if s.macro_debug.as_ref().is_some_and(Debugger::stepping) && let Some(mut d) = s.macro_debug.take() diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 337acc23d..089823973 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -631,7 +631,9 @@ impl Machine { } StepKind::Repeat { over, of, .. } => { out["kind"] = json!("repeat"); - out["over"] = json!(resolve::resolve_ref(over, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string())); + // Whole rows or columns shrink to the part in use, as when the Repeat runs. + out["over"] = + json!(resolve::resolve_ref(over, &env).map(|p| resolve::a1(trim(wb, p), &env)).unwrap_or_else(|e| e.message().to_string())); out["of"] = json!(if *of == RepeatOf::Rows { "rows" } else { "columns" }); } StepKind::If { cell, .. } => { @@ -1217,7 +1219,7 @@ fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(Strin /// 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) { +pub(crate) 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)); diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 4a5d7c4c9..67dcb1cfb 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -18,9 +18,10 @@ pub const RECORD_LIMIT: usize = 100_000; /// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive. pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024; -/// Commands never recorded: recovery actions, not steps of the person's work. A recording that -/// replayed Stop Action would stop the run it is a step of. -pub const NOT_RECORDED: [&str; 1] = ["automate.stopAction"]; +/// Commands never recorded: recovery actions and the debugger, not steps of the person's work. A +/// recording that replayed Stop Action would stop the run it is a step of, and the debugger runs +/// on its own, never as a step of a script or macro. +pub const NOT_RECORDED: [&str; 2] = ["automate.stopAction", "macro.debug"]; /// One recorded command and where it ran. #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 7f5adf0f9..c90acd9c9 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -296,19 +296,281 @@ fn shifting_makes_room_and_is_checked_again() { } #[test] -fn stop_action_waits_while_a_macro_is_being_debugged() { - // Stop Action rolls back the open undo groups; the debugger's is one, so it waits for the - // debugger, which stops the macro itself. +fn stop_action_stops_a_macro_being_debugged() { + // Stop Action rolls back the open undo groups, the debugger's among them; the run ends with + // its group, and the answer says so. let mut s = s(); data(&mut s); put(&mut s, totals()); + let (start, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s)); s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); - let r = s.execute("automate.stopAction", json!({})); - assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why == crate::macros::debug::BUSY), "{r:?}"); - assert!(s.macro_debug.is_some(), "the debugger is still there"); - s.execute("macro.debug", json!({"action": "stop"})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(v(&s, "E2").display(), "Total"); + let r = s.execute("automate.stopAction", json!({})).unwrap(); + assert_eq!((r["stopped"].as_bool(), r["macro"].as_str()), (Some(true), Some("Totals")), "{r}"); + assert!(r["message"].as_str().unwrap().contains("was stopped and rolled back"), "{r}"); + assert!(s.macro_debug.is_none(), "the debugger ended with its group"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!(undo_labels(&s), undo); + assert!(!s.in_group(s.doc().unwrap().uid)); + set(&mut s, "A1", "free"); + assert!(s.execute("macro.debug", json!({"action": "step"})).unwrap_err().to_string().contains("No macro is being debugged")); + // Stop Action on another workbook leaves the debugger alone. + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5"})).unwrap(); + s.new_workbook(); + let r = s.execute("automate.stopAction", json!({})).unwrap(); + assert_eq!(r, json!({"stopped": false})); + assert!(s.macro_debug.is_some()); +} + +#[test] +fn the_debugger_runs_on_its_own_and_is_never_recorded() { + // As a step of a script, the debugger would outlive the script's rollback of its groups. + let mut s = s(); + stepper(&mut s); + let start = s.doc().unwrap().wb.clone(); + let commands = json!([ + {"command": "macro.debug", "params": {"action": "start", "name": "Walk", "inputs": "B2:C5"}}, + {"command": "cell.set", "params": {"cell": "A1", "input": "after"}} + ]); + s.execute("automate.newScript", json!({"name": "S", "commands": commands})).unwrap(); + let after_script = s.doc().unwrap().wb.clone(); + let e = s.execute("automate.runScript", json!({"name": "S"})).unwrap_err().to_string(); + assert!(e.contains("runs on its own"), "{e}"); assert!(s.macro_debug.is_none()); - s.execute("automate.stopAction", json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &after_script)); + assert!(!s.in_group(s.doc().unwrap().uid)); + set(&mut s, "A1", "free"); + assert!(!Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + // Neither recorder keeps it. + s.start_recording(); + s.execute("automate.recordActions", json!({})).unwrap(); + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "stop"})); + let saved = s.execute("automate.recordActions", json!({"stop": true, "name": "R"})).unwrap(); + assert_eq!(saved["steps"], 0, "{saved}"); + let recorded = s.stop_recording().unwrap(); + assert!(!recorded.steps.iter().any(|st| st.id == "macro.debug"), "{:?}", recorded.steps); +} + +#[test] +fn a_debugger_whose_group_was_closed_elsewhere_locks_nothing() { + let mut s = s(); + stepper(&mut s); + let uid = s.doc().unwrap().uid; + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + // As if a command had rolled back the open groups without telling the debugger. + assert!(s.abort_groups(uid)); + set(&mut s, "A1", "free"); + assert!(s.macro_debug.is_none(), "the lock let the edit through and ended the debugger"); + // A debugger action says the run had ended; nothing is reported as kept or rolled back. + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + assert!(s.abort_groups(uid)); + let e = s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap_err().to_string(); + assert!(e.contains("no longer being debugged"), "{e}"); + assert!(s.macro_debug.is_none()); + // Starting again works at once. + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + assert!(s.abort_groups(uid)); + assert_eq!(debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"}))["state"], "paused"); +} + +/// Whether `a` on sheet `sheet` is bold. +fn bold_on(s: &Session, sheet: &str, a: &str) -> bool { + let d = s.doc().unwrap(); + let sh = d.wb.sheet(d.wb.sheet_index(sheet).unwrap()).unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).font.bold +} + +#[test] +fn between_steps_only_looking_around_runs_and_the_run_continues_where_it_was() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + put( + &mut s, + json!({"name": "Sel", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "selection.set", "params": {"range": "C3"}}, + {"command": "home.bold", "params": {}} + ]}), + ); + debug(&mut s, json!({"action": "start", "name": "Sel"})); + debug(&mut s, json!({"action": "step"})); + // Looking around runs: another sheet, another selection, Go To, Find, Copy, zoom. + s.execute("sheet.activate", json!({"sheet": "Other"})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s.execute("edit.goTo", json!({"reference": "B2"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("view.zoom", json!({"percent": 150})).unwrap(); + assert!(s.execute("selection.stats", json!({})).is_ok(), "queries run"); + // Anything else waits, undoable or not, saving included. + let path = std::env::temp_dir().join(format!("gc-macro-debug-save-{}.xlsx", std::process::id())); + let busy = |r: Result| matches!(&r, Err(EngineError::Disabled(_, why)) if why == crate::macros::debug::BUSY); + assert!(busy(s.execute("view.customViews", json!({"save": "Mine"})))); + assert!(busy(s.execute("view.pageLayout", json!({})))); + assert!(busy(s.execute("file.saveAs", json!({"path": path.to_string_lossy()})))); + assert!(busy(s.execute("file.save", json!({})))); + assert!(busy(s.execute("automate.recordActions", json!({})))); + assert!(busy(s.execute("cell.set", json!({"cell": "A1", "input": "x"})))); + assert!(!path.exists(), "nothing was saved"); + // The next step runs on the run's sheet and selection, as macro.run would. + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "finished", "{st}"); + assert!(bold_on(&s, "Sheet1", "C3")); + assert!(!bold_on(&s, "Other", "B2") && !bold_on(&s, "Other", "A1")); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet, d.selection.active), (0, CellRef::parse("C3").unwrap())); +} + +#[test] +fn a_panic_inside_a_step_is_that_steps_error_and_the_run_goes_on() { + crate::register_test_command("test.debugInnerPanics", |_, _| panic!("inner")); + // A step whose command catches a nested command's panic as an error and goes on editing. + crate::register_test_command("test.debugSwallowsPanic", |s, _| { + let _ = s.execute("test.debugInnerPanics", json!({})); + s.execute("cell.set", json!({"cell": "B1", "input": "after"})) + }); + let mut s = s(); + put( + &mut s, + json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "test.debugSwallowsPanic", "params": {}} + ]}), + ); + let undo = undo_labels(&s); + debug(&mut s, json!({"action": "start", "name": "M"})); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "finished", "{st}"); + assert_eq!((v(&s, "A1").display(), v(&s, "B1").display()), ("x".into(), "after".into())); + let after = undo_labels(&s); + assert_eq!((after.len(), after.last().map(String::as_str)), (undo.len() + 1, Some("Run Macro: M")), "{after:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty()); +} + +#[test] +fn debugger_parameters_are_checked_before_anything_changes() { + let mut s = s(); + stepper(&mut s); + let start = |bps: Json| json!({"action": "start", "name": "Walk", "inputs": "B2:C5", "breakpoints": bps}); + let e = s.execute("macro.debug", start(json!(["9"]))).unwrap_err().to_string(); + assert!(e.contains("has no step 9 for a breakpoint"), "{e}"); + let many: Vec = (0..10_001).map(|_| "1".to_string()).collect(); + let e = s.execute("macro.debug", start(json!(many))).unwrap_err().to_string(); + assert!(e.contains("at most 10000"), "{e}"); + assert!(s.macro_debug.is_none() && !s.in_group(s.doc().unwrap().uid)); + debug(&mut s, start(json!([]))); + // `keep` is true or false: "true" would have read as false and rolled the run back. + assert!(s.execute("macro.debug", json!({"action": "stop", "keep": "true"})).is_err()); + assert!(s.execute("macro.debug", json!({"action": "step", "breakpointsOnly": "yes"})).is_err()); + assert!(s.execute("macro.debug", json!({"action": 5})).is_err()); + // An unknown action or step path changes nothing, breakpoints included. + assert!(s.execute("macro.debug", json!({"action": "leap", "breakpoints": ["2"]})).is_err()); + assert!(s.execute("macro.debug", json!({"action": "step", "breakpoints": ["2.x"]})).is_err()); + assert!(s.execute("macro.debug", json!({"action": "runTo", "step": "9", "breakpoints": ["2"]})).is_err()); + let st = debug(&mut s, json!({"action": "state"})); + assert_eq!((st["current"]["step"].as_str(), &st["breakpoints"]), (Some("1"), &json!([])), "{st}"); + // `breakpointsOnly` alone applies to the list the run has (none here): the flag on 3.1 no + // longer stops it. + let st = debug(&mut s, json!({"action": "continue", "breakpointsOnly": true})); + assert_eq!(st["state"], "finished", "{st}"); +} + +#[test] +fn the_debuggers_answers_count_its_own_steps_and_say_what_stays() { + // `executed` counts the run's steps, not the footprint's dry run before it. + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + let st = debug(&mut s, json!({"action": "start", "name": "Totals", "inputs": "B2:C5"})); + assert_eq!(st["executed"], 0, "{st}"); + assert_eq!(debug(&mut s, json!({"action": "step"}))["executed"], 1); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["state"].as_str(), st["executed"].as_u64()), (Some("finished"), Some(3)), "{st}"); + // A shift at the start is reported, as macro.run reports it. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old"); + put(&mut s, totals()); + let st = debug(&mut s, json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "onConflict": "shiftDown"})); + assert_eq!(st["shifted"], "shiftDown", "{st}"); + assert_eq!(debug(&mut s, json!({"action": "stop"}))["shifted"], "shiftDown"); + // The files the run wrote stay when the workbook is put back: a failed step, a Stop step + // that rolls back, and stopping without keeping each say so. + let dir = std::env::temp_dir().join(format!("gc-macro-debug-files-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let export = json!({"command": "file.exportCsv", "params": {"path": format!("{}/out.csv", dir.to_string_lossy())}}); + for (last, how) in [ + (json!({"command": "cell.set", "params": {"cell": "Nowhere!A2", "input": "two"}}), "failed"), + (json!({"type": "stop", "message": "undo", "rollback": true}), "stopped"), + (json!({"command": "cell.set", "params": {"cell": "A2", "input": "two"}}), "stop"), + ] { + let mut s = super::s(); + put(&mut s, json!({"name": "F", "output": {"onConflict": "overwrite"}, "permissions": ["file.exportCsv"], "steps": [export, last]})); + debug(&mut s, json!({"action": "start", "name": "F", "approve": true})); + debug(&mut s, json!({"action": "step"})); + let st = if how == "stop" { debug(&mut s, json!({"action": "stop"})) } else { debug(&mut s, json!({"action": "step"})) }; + let msg = st["message"].as_str().unwrap_or_default(); + assert!(msg.contains("the file step 1 (file.exportCsv) wrote stays") && !msg.contains("Nothing was changed"), "{how}: {st}"); + assert_eq!(st["wroteFiles"], json!(["step 1 (file.exportCsv)"]), "{how}: {st}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_debugger_lists_deleted_and_renamed_sheets_and_caps_the_list() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Sheet3"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + put( + &mut s, + json!({"name": "Sheets", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "sheet.rename", "params": {"sheet": "Sheet2", "name": "Renamed"}}, + {"command": "home.deleteSheet", "params": {"sheet": "Sheet3"}}, + {"command": "cell.set", "params": {"cell": "A1", "input": "end"}} + ]}), + ); + debug(&mut s, json!({"action": "start", "name": "Sheets"})); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + let changed = st["changed"].as_array().unwrap(); + assert!(changed.contains(&json!("Renamed (renamed from Sheet2)")), "{st}"); + assert!(changed.contains(&json!("Sheet3 (deleted sheet)")), "{st}"); + assert!(!changed.iter().any(|c| c.as_str().is_some_and(|c| c.contains("(new sheet)"))), "{st}"); + // As if a step had written cells that make more ranges than the answer lists, on two sheets. + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + for sh in wb.sheets.iter_mut() { + let sh = Arc::make_mut(sh); + for r in 0..600u32 { + sh.set_value(CellRef::new(r + 10, r % 2), gridcraft_core::Value::Number(1.0)); + } + } + d.wb = Arc::new(wb); + let st = debug(&mut s, json!({"action": "state"})); + assert_eq!(st["changed"].as_array().unwrap().len(), 500, "{}", st["changed"].as_array().unwrap().len()); + assert_eq!(st["changedCells"], 1200); + debug(&mut s, json!({"action": "stop"})); +} + +#[test] +fn the_debugger_shows_a_repeat_over_whole_columns_as_the_run_goes_over_them() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Cols", "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "repeat", "over": "B:C", "of": "columns", "body": [{"command": "cell.set", "params": {"cell": "Z1", "input": "x"}}]} + ]}), + ); + let st = debug(&mut s, json!({"action": "start", "name": "Cols"})); + assert_eq!(st["current"]["over"], "B2:C5", "{st}"); + debug(&mut s, json!({"action": "stop"})); } #[test] From f66ebd6fb20295cdf322833d1e6f0f16ed9a14b8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:17:42 -0400 Subject: [PATCH 120/166] Docs: debugging macros: actions, start options, the lock and the time limit per action - The start options are those of `macro.run`, refused as it refuses them. - The time limit applies to each debugger action; the step and parameter limits to the whole run. - The lock lists what runs between steps, and says the sheet and selection are put back. - Stop Action ends the run; the debugger is never a step and never recorded. - The answers' fields, including `changedCells`, `shifted`, `wroteFiles` and `message`. --- docs/macros.md | 52 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index 4006096a7..5b9d9be00 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -62,7 +62,7 @@ The engine has no macro recorder or management commands in this version, and no pass through a Repeat counts), passes its commands at most 256 MB of parameters, all steps together, and takes at most 60 seconds, all three shared by the footprint dry runs and the run itself (`timeLimit` to change the time; the web build has no clock, so there only the step and - parameter limits apply). + parameter limits apply). The debugger gives each of its actions the time limit on its own. ## Tokens @@ -145,15 +145,47 @@ on, as after any recalculation. ## Debugging -`macro.debug {action, …}` with `start` (`name`, `inputs`, `breakpoints`), `step`, `continue`, -`runTo` (`step`: a path), `stop` (`keep: true` keeps the changes) and `state`. `breakpoints` can -be given with any action; `breakpointsOnly: true` makes the list replace the steps' own flags. The -run stays one undo step; while it is open, edits of the workbooks it touched (its own, and any -other its steps changed) are refused with "Finish or stop the macro first", also when another -command makes them (stopping Record Actions saves a script), and so is Automate › Stop Action, -which would roll the run's undo group back under the debugger; `stop` ends the run instead. Other -workbooks stay editable. Each answer gives the next step with its parameters as they would run, the parameters -of the step that just ran, and the cells changed so far. +`macro.debug {action, …}` runs a macro a step at a time. The actions are: + +- `start` with `name` and the options of `macro.run` (`inputs`, `onConflict`, `overwrite`, + `approve`, `timeLimit`), refused as `macro.run` refuses them when they are of the wrong kind. The + checks, the approval and the footprint come first, as for `macro.run`; then the run pauses + before its first step. +- `step` runs the next step; `continue` runs to the next breakpoint or the end; `runTo` (`step`: + a path) runs to that step, stopping at a breakpoint on the way. +- `stop` ends the run: `keep: true` keeps the changes as one undo step, otherwise they are rolled + back. +- `state` answers without running anything (the default while a macro is being debugged). + +`breakpoints` (a list of step paths of the macro, at most 10,000) can be given with any action and +replaces the list set before; `breakpointsOnly: true` makes the list replace the steps' own +breakpoint flags, also when it is given without a list. An unknown action, a breakpoint or `runTo` +path the macro doesn't have, and `keep` or `breakpointsOnly` that isn't `true` or `false` are +refused before anything changes. The time limit applies to each action on its own (`start` with +its dry runs, and each `step`, `continue` or `runTo`); the step and parameter limits apply to the +whole run, counting the dry runs. + +Each answer gives the next step with its parameters as they would run (a Repeat over whole rows or +columns shows the part of the sheet in use, as the run goes over it), the parameters of the step +that just ran, the steps the run has executed (the dry runs not counted), and what the run has +changed in its workbook: `changed` lists at most 500 entries (cell ranges, and sheets added, +deleted or renamed) and `changedCells` counts the changed cells on the sheets it had at the start. `shifted` names the rule that made +room at the start, and `wroteFiles` the files the run wrote, which stay when the run is rolled +back; the answer to a rolled-back run says so in `message`. A run that ends (its last step, a +Stop step, or a failed step, which rolls it back) or is stopped answers with `state` `finished`, +`stopped` or `failed`. + +While a macro is being debugged, its workbook is locked between steps: only `macro.debug`, queries +(commands that only read), commands that look around (`selection.*`, Select All, `sheet.activate`, +`sheet.next` and `sheet.previous`, Go To, Go To Special, Find, Copy and the zoom commands), Switch +Window, Close (which ends the run with its workbook) and Automate › Stop Action run there. +Everything else, undoable or not, saving included, is refused with "Finish or stop the macro +first", also when another command runs it (stopping Record Actions saves a script). Other +workbooks stay editable. The person may look around between steps: before the next step runs, the +debugger puts the active sheet and the selection back where the run left them, so the run goes on +as `macro.run` would run it. A zoom change made meanwhile is part of the run, and is undone with +it. Stop Action rolls the run back and ends it, and its answer names the macro. The debugger runs +on its own, never as a step of a script or macro, and is never recorded. ## Storage From bc0d3035fb69af237fcc086688cbbab122c78abf Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:18:26 -0400 Subject: [PATCH 121/166] Engine: kept macros count toward the macro limit and keep their names; saving over a macro needs replace: true; renaming checks the size - macro.save and a stopped recording count the macros kept in a newer format with the readable ones, so a save never holds more than the next open keeps; the message names the kept count (kept_macros_count_toward_the_limit_and_keep_their_names, which saves and reopens as xlsx and JSON). - The names of kept macros are taken: macro.save and macro.rename refuse them, unique_name and next_name step around them (same test). - macro.save no longer replaces by default: `replace` defaults to false, the error names the existing macro and says to pass `replace: true`, and a `replace` that is not a boolean is refused (saving_over_a_macro_needs_replace). - macro.rename runs the whole macro check, size included, so a longer name can't take a macro past what an xlsx file holds (renaming_checks_the_macro_size). - Docs: macros.md, the management commands and kept macros. --- crates/engine/src/cmd/macros.rs | 39 +++++++++++++---- crates/engine/src/macros/mod.rs | 48 ++++++++++++++++++-- crates/engine/src/tests/macros.rs | 73 +++++++++++++++++++++++++++++++ docs/macros.md | 10 ++++- 4 files changed, 156 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index a7b9fa9aa..0023cdaf9 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Save Macro", [], None, - "{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default true)} → {name, steps}", + "{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default false: saving over a macro with the name is refused)} → {name, steps}", has_doc, save ), @@ -102,26 +102,44 @@ fn get(s: &mut Session, p: &Json) -> Result { fn save(s: &mut Session, p: &Json) -> Result { let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p); let m = crate::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?; - let replace = bool_param(p, "replace").unwrap_or(true); + // Replacing is asked for, never assumed: a save with a name in use is refused. + let replace = match p.get("replace") { + None | Some(Json::Null) => false, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("macro.save", "`replace` must be true or false")), + }; let (name, steps) = (m.name.clone(), m.step_count()); edit(s, |cx| { + if let Some(kept) = crate::macros::kept_name(&cx.wb, &m.name) { + return Err(EngineError::Other(taken_by_kept(&kept))); + } match crate::macros::find(&cx.wb, &m.name) { - Some(_) if !replace => return Err(EngineError::Other(format!("A macro called '{}' already exists.", m.name))), + Some(i) if !replace => { + let existing = cx.wb.macros.get(i).map_or(m.name.as_str(), |o| o.name.as_str()); + return Err(EngineError::Other(format!("A macro called '{existing}' already exists. Pass `replace: true` to replace it."))); + } Some(i) => { if let Some(old) = cx.wb.macros.get_mut(i) { *old = m; } } - None if cx.wb.macros.len() >= gridcraft_model::MAX_MACROS => { - return Err(EngineError::Other(format!("A workbook can hold at most {} macros.", gridcraft_model::MAX_MACROS))); + None => { + crate::macros::check_room(&cx.wb).map_err(EngineError::Other)?; + cx.wb.macros.push(m); } - None => cx.wb.macros.push(m), } Ok(()) })?; Ok(json!({"name": name, "steps": steps})) } +/// The refusal of a name that a macro kept in a newer format has. +fn taken_by_kept(name: &str) -> String { + format!( + "A macro called '{name}' made by a newer version of GridCraft is kept in this workbook; this version can't change it. Choose another name." + ) +} + fn delete(s: &mut Session, p: &Json) -> Result { let i = index_of(s, name_param("macro.delete", p)?)?; edit(s, |cx| { @@ -138,14 +156,19 @@ fn rename(s: &mut Session, p: &Json) -> Result { if to.chars().any(char::is_control) { return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters")); } - if crate::macros::find(&s.doc()?.wb, &to).is_some_and(|j| j != i) { + let wb = &s.doc()?.wb; + if crate::macros::find(wb, &to).is_some_and(|j| j != i) { return Err(EngineError::Other(format!("A macro called '{to}' already exists."))); } + if let Some(kept) = crate::macros::kept_name(wb, &to) { + return Err(EngineError::Other(taken_by_kept(&kept))); + } edit(s, |cx| { let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) }; let mut renamed = m.clone(); renamed.name = to.clone(); - renamed.check_limits().map_err(EngineError::Other)?; + // The whole check, size included: a longer name can take a macro past what a file holds. + crate::macros::check(&renamed).map_err(EngineError::Other)?; *m = renamed; Ok(()) })?; diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 66886125f..1fc0ab674 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -54,7 +54,8 @@ pub fn check(m: &Macro) -> Result<(), String> { } /// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of -/// JSON), so a macro's size never makes a save fail. (A clashing visible name doesn't either: +/// JSON). Every command that adds or changes a macro calls [`check`], so the size of a macro kept +/// in the workbook doesn't make a save fail. (A clashing visible name doesn't either: /// [`with_names`] stores the macro under another index.) fn check_size(m: &Macro) -> Result<(), String> { if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS { @@ -63,6 +64,42 @@ fn check_size(m: &Macro) -> Result<(), String> { Ok(()) } +/// How many macros the workbook holds: the readable ones and the ones kept in a newer format +/// (`Workbook::macros_unread`), which a save writes back and the next open counts too. +pub fn count(wb: &Workbook) -> usize { + wb.macros.len().saturating_add(wb.macros_unread.len()) +} + +/// Refuses one more macro in a workbook that holds [`gridcraft_model::MAX_MACROS`] already, +/// counting the kept ones, so a save never holds more than the next open keeps. +pub fn check_room(wb: &Workbook) -> Result<(), String> { + let max = gridcraft_model::MAX_MACROS; + if count(wb) < max { + return Ok(()); + } + Err(match wb.macros_unread.len() { + 0 => format!("A workbook can hold at most {max} macros."), + kept => format!( + "A workbook can hold at most {max} macros, and {kept} of the {} in this one {} made by a newer version of GridCraft and kept unchanged.", + count(wb), + if kept == 1 { "was" } else { "were" } + ), + }) +} + +/// The name of the macro kept in a newer format called `name` (compared as [`find`] compares), +/// as the file held it. Its name is taken: a new or renamed macro with it would clash when the +/// newer version reads the file. +pub fn kept_name(wb: &Workbook, name: &str) -> Option { + let name = name.trim(); + wb.macros_unread + .iter() + .filter_map(|v| v.get("name").and_then(Json::as_str)) + .map(str::trim) + .find(|k| k.eq_ignore_ascii_case(name)) + .map(str::to_string) +} + /// Rewrites the short step form into the tagged one, in place, refusing nesting past the limit /// before the steps are deserialized. fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> { @@ -284,7 +321,7 @@ fn macro_item(n: &DefinedName) -> Option<(String, Option)> { Some((key, i)) } -/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A free name comes back as it is; +/// `name`, or `name (2)`, `name (3)`… if a macro already has it, a kept one too. A free name comes back as it is; /// a renamed one is shortened to make room for the suffix, so it stays within /// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`]. pub fn unique_name(wb: &Workbook, name: &str) -> String { @@ -292,6 +329,9 @@ pub fn unique_name(wb: &Workbook, name: &str) -> String { for m in &wb.macros { taken.insert(&m.name); } + for kept in wb.macros_unread.iter().filter_map(|v| v.get("name").and_then(Json::as_str)) { + taken.insert(kept); + } taken.unique(name) } @@ -350,9 +390,9 @@ impl Taken { } } -/// The first free `Macro1`, `Macro2`… name. +/// The first free `Macro1`, `Macro2`… name, also free among the kept macros ([`kept_name`]). pub fn next_name(wb: &Workbook) -> String { - (1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none()).unwrap_or_else(|| "Macro".into()) + (1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none() && kept_name(wb, n).is_none()).unwrap_or_else(|| "Macro".into()) } /// Every command step of `steps` (inside Repeat and If too), with its path. diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 168680c51..33a6f6249 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1728,3 +1728,76 @@ fn a_recording_waits_while_a_macro_in_its_workbook_is_debugged() { assert_eq!(r["saved"], "Typed"); assert_eq!(recorded(&mut s, "Typed"), ["cell.set {\"cell\":\"Z1\",\"input\":\"typed\"}"]); } + +/// Opens a JSON workbook whose `macros` field holds `macros`, as a file the person opened. +fn open_with_macros(s: &mut Session, macros: Vec) -> Vec { + let mut file = serde_json::to_value(gridcraft_model::Workbook::new()).unwrap(); + file["macros"] = json!(macros); + let r = s.execute("file.open", json!({"name": "kept.json", "base64": crate::io::base64_encode(file.to_string().as_bytes())})).unwrap(); + r["warnings"].as_array().map(|w| w.iter().map(|x| x.as_str().unwrap_or_default().to_string()).collect()).unwrap_or_default() +} + +#[test] +fn kept_macros_count_toward_the_limit_and_keep_their_names() { + // Macros in a newer format count toward the limit when the file opens again, so they count + // when one is added; their names are taken. + let mut s = s(); + let newer = |name: &str| json!({"name": name, "v": gridcraft_model::MACRO_VERSION + 1}); + let mut list: Vec = (0..gridcraft_model::MAX_MACROS - 2).map(|i| json!({"name": format!("M{i}")})).collect(); + list.extend([newer("Future"), newer("Macro1")]); + open_with_macros(&mut s, list); + assert_eq!((s.doc().unwrap().wb.macros.len(), s.doc().unwrap().wb.macros_unread.len()), (gridcraft_model::MAX_MACROS - 2, 2)); + let err = s.execute("macro.save", json!({"name": "New"})).unwrap_err().to_string(); + assert!(err.contains("at most 1000 macros") && err.contains("2 of the 1000"), "{err}"); + s.execute("macro.delete", json!({"name": "M0"})).unwrap(); + for (cmd, p) in [("macro.save", json!({"name": " future "})), ("macro.rename", json!({"name": "M1", "to": "MACRO1"}))] { + let err = s.execute(cmd, p).unwrap_err().to_string(); + assert!(err.contains("newer version"), "{cmd}: {err}"); + } + // A recording takes the next free name, around the kept one, and fills the last place. + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + assert_eq!(s.execute("macro.record", json!({"stop": true})).unwrap()["saved"], "Macro2"); + assert_eq!(crate::macros::unique_name(&s.doc().unwrap().wb, "Future"), "Future (2)"); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A2", "more"); + let err = s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap_err().to_string(); + assert!(err.contains("2 of the 1000"), "{err}"); + s.execute("macro.record", json!({"cancel": true})).unwrap(); + // Saved and opened again: every macro, the kept ones too, is still there. + for format in ["xlsx", "json"] { + let b64 = s.execute("file.saveBytes", json!({"format": format})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": format!("again.{format}"), "base64": b64})).unwrap(); + let wb = &s.doc().unwrap().wb; + assert_eq!((wb.macros.len(), wb.macros_unread.len()), (gridcraft_model::MAX_MACROS - 2, 2), "{format}"); + assert_eq!(*wb.macros_unread, [newer("Future"), newer("Macro1")], "{format}"); + } +} + +#[test] +fn renaming_checks_the_macro_size() { + // A longer name can take a macro past the size a file holds: renaming is refused, as saving is. + let mut s = s(); + let mut m = json!({"name": "A", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": ""}}]}); + let size = |m: &Json| crate::macros::macro_json(&crate::macros::parse_macro(m).unwrap()).to_string().chars().count(); + let pad = crate::cmd::stash::MAX_ITEM_CHARS - size(&m); + m["steps"][0]["params"]["input"] = json!("x".repeat(pad)); + assert_eq!(size(&m), crate::cmd::stash::MAX_ITEM_CHARS); + s.execute("macro.save", json!({"macro": m})).unwrap(); + let err = s.execute("macro.rename", json!({"name": "A", "to": "Longer"})).unwrap_err().to_string(); + assert!(err.contains("too large"), "{err}"); + s.execute("macro.rename", json!({"name": "A", "to": "B"})).unwrap(); + crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); +} + +#[test] +fn saving_over_a_macro_needs_replace() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Tidy", "description": "first"})).unwrap(); + let err = s.execute("macro.save", json!({"name": "TIDY", "description": "second"})).unwrap_err().to_string(); + assert!(err.contains("'Tidy' already exists") && err.contains("replace: true"), "{err}"); + assert!(s.execute("macro.save", json!({"name": "Tidy", "replace": "yes"})).is_err()); + assert_eq!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["description"], "first"); + s.execute("macro.save", json!({"name": "TIDY", "description": "second", "replace": true})).unwrap(); + assert_eq!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["description"], "second"); +} diff --git a/docs/macros.md b/docs/macros.md index 1e2817abf..b7c196956 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -178,7 +178,9 @@ it ran: editing or deleting the script before the recording stops changes nothin ## Other commands and storage `macro.list`, `macro.get`, `macro.save {macro, replace?}`, `macro.delete` and `macro.rename -{name, to}`. Saving, renaming and deleting are undoable. +{name, to}`. Saving under a name a macro has is refused unless `replace: true` is passed, and +renaming to one is refused; names compare ignoring the case of the letters A to Z. A renamed macro is checked +against every limit again, its size included. Saving, renaming and deleting are undoable. Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's @@ -190,7 +192,11 @@ Each macro carries its format version, `v` (1 in this version; a missing `v` rea file is opened, a macro in a newer format (a `v` above 1) is kept unread, with a warning: this version can't show or run it, and saving writes it back unchanged, so the newer version gets it back whole. It is never read in part. Kept macros count toward the 1,000-macro -limit after the readable ones, and one too large for an xlsx file is left out. A macro that is +limit after the readable ones, and one too large for an xlsx file is left out. They count when a +macro is added too (saving, or stopping a recording, is refused once readable and kept macros +together reach 1,000, and the message says how many are kept), so a save holds no more macros +than the next open keeps. Their names are taken: saving or renaming a macro to one is refused, and +a recording gets a suffix. A macro that is damaged or over the limits is left out with one warning, and the rest of the workbook and its other macros open; a macro that was left out is not in the saved file. From 8b018d89cdc104c77347536488fb7b912831c47b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:19:03 -0400 Subject: [PATCH 122/166] Model: the undo budget counts each macro's whole JSON, parameters included - Macros::estimated_bytes counted names, descriptions and step counts but not step parameters, so each save, rename or delete (which copies the macro list an undo step shares) could keep a 4 M-character parameter per step unseen by the 128 MB budget. It now counts each macro's JSON as it is written, without building it. - This changes the macro storage layer below #2152, the smallest change that makes the budget hold (answers CodeRabbit thread 4366060037). - Test: re_saving_a_large_macro_keeps_the_undo_history_within_its_budget. --- crates/engine/src/tests/macros.rs | 18 ++++++++++++++++++ crates/model/src/macros.rs | 24 ++++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 33a6f6249..6155f8b90 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1774,6 +1774,24 @@ fn kept_macros_count_toward_the_limit_and_keep_their_names() { } } +#[test] +fn re_saving_a_large_macro_keeps_the_undo_history_within_its_budget() { + // Each save keeps a copy of the macro list in the undo step before it. The budget counts + // the copies' parameters, so it trims the history instead of keeping every copy. + let mut s = s(); + let big = "x".repeat(3_900_000); + let saves = 36; + for i in 0..saves { + let m = json!({"name": "Big", "description": i.to_string(), "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": big}}]}); + s.execute("macro.save", json!({"macro": m, "replace": true})).unwrap(); + } + let d = s.doc().unwrap(); + let kept: usize = d.undo.iter().map(|e| e.retained_bytes).sum(); + assert!(kept <= crate::UNDO_BYTES_LIMIT, "{kept}"); + assert!(d.undo.len() < saves, "{} undo steps", d.undo.len()); + assert!(d.undo.iter().skip(1).all(|e| e.retained_bytes > big.len()), "each kept copy is charged for its parameters"); +} + #[test] fn renaming_checks_the_macro_size() { // A longer name can take a macro past the size a file holds: renaming is refused, as saving is. diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 8ec4b03e5..3459cdb36 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -217,12 +217,28 @@ impl Macros { std::sync::Arc::ptr_eq(&self.0, &other.0) } - /// A rough size of the list in memory, for the undo budget. + /// A rough size of the list in memory, for the undo budget: each macro's JSON, which holds + /// every name, text and step parameter, plus its structs. An undo step that keeps a copy of + /// the list (after a save, rename or delete) is charged for all of it. The JSON is counted as + /// it is written, without building it. pub fn estimated_bytes(&self) -> usize { + /// Counts the bytes written to it. + struct Count(usize); + impl std::io::Write for Count { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(buf.len()); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } self.0.iter().fold(0usize, |bytes, m| { - let text = m.name.len().saturating_add(m.description.len()); - let steps = m.step_count().saturating_mul(std::mem::size_of::().saturating_add(64)); - bytes.saturating_add(std::mem::size_of::()).saturating_add(text).saturating_add(steps) + let mut json = Count(0); + // Writing to a counter fails only for a value JSON can't hold, which a macro never is. + let _ = serde_json::to_writer(&mut json, m); + let steps = m.step_count().saturating_mul(std::mem::size_of::()); + bytes.saturating_add(std::mem::size_of::()).saturating_add(json.0).saturating_add(steps) }) } } From 460a72d3bbdc4346920f4e392130704924c19733 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:19:35 -0400 Subject: [PATCH 123/166] Engine: a recording keeps what ran and plays back as it was recorded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A script step that only opened a dialog (a menu-style call that left the workbook unchanged) is left out of the script run's steps, by the recorder hook's rule; a script run whose steps the recording couldn't keep is left out and listed in `skipped`, not read from the workbook as the script stands when recording stops (a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording). - A script's toggles keep the state they set (`"on": true`), as a top-level Bold does (a_script_run_records_the_state_its_toggles_set). - A recorded macro.run keeps the steps it ran, as a script run does, so saving or deleting the macro before the recording stops changes nothing (a_recorded_macro_run_keeps_the_steps_that_ran). - The example input's shape and column types are inferred when recording starts, not from the data the recording changed (the_recorded_input_is_inferred_as_it_was_when_recording_started). - Recorded text that playback would read as a token stays text: a reference parameter that reads as one becomes {"$text": …}, and `{{` becomes `{{{{` (recorded_text_that_reads_as_a_token_plays_back_as_text). The resolver (below #2152) gains `{"$text": …}`, text passed as it is (text_marked_as_text_is_never_a_token). - A recording over the macro size limit is cut at the limit and the steps left out are counted in `dropped` (a_recording_past_the_macro_size_is_cut_and_counted). - A macro recorded in a workbook opened from a file is approved for the session, since the person recorded it (a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking). - macro.record refuses parameters it would ignore: name or description when starting, relative when stopping, stop: false while recording, others beside cancel: true, and wrong kinds (record_refuses_parameters_it_would_ignore). - Undo while recording keeps the undone step, as in Excel; documented (undo_while_recording_keeps_the_undone_step_as_excel_does). - Docs: macros.md Recording and Tokens; module comments no longer say scripts are read. --- crates/engine/src/cmd/extra.rs | 29 ++-- crates/engine/src/cmd/macros.rs | 98 ++++++++++---- crates/engine/src/lib.rs | 22 ++- crates/engine/src/macros/mod.rs | 27 +++- crates/engine/src/macros/recorder.rs | 169 +++++++++++++++-------- crates/engine/src/macros/resolve.rs | 37 +++++- crates/engine/src/record.rs | 24 +++- crates/engine/src/tests/macros.rs | 192 +++++++++++++++++++++++++++ docs/macros.md | 64 ++++++--- 9 files changed, 528 insertions(+), 134 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 8902b1740..e983d7971 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1088,6 +1088,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { continue; } let before = s.ui_requests.len(); + let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone())); let ran = s.execute(id, st["params"].clone()); // A step that asks the host to open a URL leaves the app: drop the request and stop. if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { @@ -1098,10 +1099,17 @@ fn run_script(s: &mut Session, p: &Json) -> Result { }); return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); } - ran?; + let result = ran?; n += 1; if s.recorder.is_some() { - ran_steps.push((id.to_string(), st["params"].clone())); + // The recorder's rule for each step, as for a command run on its own: a menu-style + // step that only opened a dialog is left out (playing it back would stop at the + // dialog), and a toggle keeps the state it set. + let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..)))); + let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb))); + if !opened_dialog || changed { + ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result))); + } } } if s.recorder.is_some() { @@ -1110,23 +1118,6 @@ fn run_script(s: &mut Session, p: &Json) -> Result { Ok(json!({"ran": n})) } -/// The command steps of the Automate script called `name`, as `automate.runScript` runs them -/// (steps that run other scripts are left out), or `None` when there is no such script. -pub(crate) fn script_steps(wb: &Workbook, name: &str) -> Option> { - let all = stash_get(wb, SCRIPTS_NAME); - let steps = all.get(name)?.as_array()?; - Some( - steps - .iter() - .take(100_000) - .filter_map(|st| { - let id = st["command"].as_str()?; - (!id.starts_with("automate.")).then(|| (id.to_string(), st["params"].clone())) - }) - .collect(), - ) -} - /// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and /// Redo work there again. fn stop_action(s: &mut Session, _: &Json) -> Result { diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 0023cdaf9..5f8afb716 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -1,6 +1,7 @@ //! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model, //! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate, -//! older feature with their own storage; these commands neither read nor change them. +//! older feature with their own storage; these commands neither read nor change it. A recording +//! keeps the steps a script run ran while it recorded, as the run reported them. use serde_json::{Value as Json, json}; @@ -177,7 +178,20 @@ fn rename(s: &mut Session, p: &Json) -> Result { fn run(s: &mut Session, p: &Json) -> Result { let name = name_param("macro.run", p)?; - crate::macros::run::run(s, name, p) + // While recording: the steps a recording keeps for this run, taken from the macro as it runs, + // so saving or deleting the macro before the recording stops doesn't change what it saved. + let inline = if s.is_recording() { + let wb = &s.doc()?.wb; + crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).and_then(crate::macros::recorder::inline_steps) + } else { + None + }; + let out = crate::macros::run::run(s, name, p)?; + if s.is_recording() { + // `None` when the macro's steps don't stand on their own: the recording leaves the run out. + s.script_ran = inline; + } + Ok(out) } fn check(s: &mut Session, p: &Json) -> Result { @@ -186,8 +200,9 @@ fn check(s: &mut Session, p: &Json) -> Result { } fn record(s: &mut Session, p: &Json) -> Result { + check_record_params(s.is_recording(), p)?; // `cancel` ends a recording without saving it: the way out of a recording whose macro - // cannot be saved (too large for the workbook, for one). + // cannot be saved (a name the check refuses, a full workbook). if bool_param(p, "cancel").unwrap_or(false) { let was = s.stop_recording().is_some(); s.macro_recording = None; @@ -199,15 +214,47 @@ fn record(s: &mut Session, p: &Json) -> Result { start_recording(s, bool_param(p, "relative").unwrap_or(true)) } +/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `name` and +/// `description` when starting (they name the macro when it stops), `relative` when stopping (it +/// is chosen when recording starts), `stop: false` while recording (the call stops it), anything +/// beside `cancel: true`, and parameters of the wrong kind. +fn check_record_params(recording: bool, p: &Json) -> Result<()> { + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + for k in ["stop", "cancel", "relative"] { + if given(k).is_some_and(|v| !v.is_boolean()) { + return Err(bad("macro.record", format!("`{k}` must be true or false"))); + } + } + for k in ["name", "description"] { + if given(k).is_some_and(|v| !v.is_string()) { + return Err(bad("macro.record", format!("`{k}` must be text"))); + } + } + if given("cancel").and_then(Json::as_bool) == Some(true) { + if let Some(k) = ["stop", "relative", "name", "description"].into_iter().find(|k| given(k).is_some()) { + return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored"))); + } + return Ok(()); + } + let stop = given("stop").and_then(Json::as_bool); + if recording && stop == Some(false) { + return Err(bad( + "macro.record", + "a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved", + )); + } + if recording || stop == Some(true) { + if given("relative").is_some() { + return Err(bad("macro.record", "`relative` is chosen when recording starts")); + } + } else if let Some(k) = ["name", "description"].into_iter().find(|k| given(k).is_some()) { + return Err(bad("macro.record", format!("`{k}` names the macro when recording stops, not when it starts"))); + } + Ok(()) +} + fn start_recording(s: &mut Session, relative: bool) -> Result { - let d = s.doc()?; - let start = RecordingStart { - doc: d.uid, - sheet: d.wb.active_sheet, - sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), - selection: d.selection.clone(), - relative, - }; + let start = RecordingStart::new(s.doc()?, relative); let input = start.selection.current().a1(); s.start_recording(); s.macro_recording = Some(start); @@ -244,27 +291,24 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) { return Err(EngineError::Disabled("macro.record".into(), crate::macros::debug::BUSY.into())); } - let start = start.unwrap_or_else(|| RecordingStart { - doc: d.uid, - sheet: d.wb.active_sheet, - sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), - selection: d.selection.clone(), - relative: true, - }); - let wb = d.wb.clone(); + // A recording started without `macro.record` (`Session::start_recording`) takes its example + // input from the workbook as it is now. + let start = start.unwrap_or_else(|| RecordingStart::new(d, true)); + let (doc, wb) = (d.uid, d.wb.clone()); // A name another macro has gets a suffix, so stopping never replaces a macro. let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { Some(n) => crate::macros::unique_name(&wb, n), None => crate::macros::next_name(&wb), }; - let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran, &wb); - // Steps past the macro limit, and ones recorded in another workbook, are left out and counted - // with the ones the recorder dropped. - let cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS)); + let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran); + // Steps past the macro limit or its size, and ones recorded in another workbook, are left + // out and counted with the ones the recorder dropped. + let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS)); converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS); if let Some(desc) = str_param(p, "description") { converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect(); } + cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac)); let steps = converted.mac.steps.len(); let inputs: Vec = converted.mac.inputs.iter().map(|i| i.name.clone()).collect(); // Saved into that workbook even when another one is active now. @@ -273,6 +317,14 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false})); s.set_active(active); saved?; + // The person recorded it, so it runs without the approval a macro that came with a file + // needs, until it changes. + if let Some(d) = s.docs.iter().find(|d| d.uid == doc) + && let Some(m) = crate::macros::find(&d.wb, &name).and_then(|i| d.wb.macros.get(i)) + { + let key = (doc, crate::macros::fingerprint(m)); + s.macro_approvals.insert(key); + } Ok( json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}), ) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6a1e151b1..8fd54f809 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -459,8 +459,9 @@ pub struct Session { pub recorder: Option, /// How many `execute` calls are running (0 between top-level commands). depth: u32, - /// The steps the last successful `automate.runScript` ran while a recorder was on, for the - /// recorder to keep with that step (see [`record::Recorder::ran`]); taken by the recording hook. + /// The steps the last successful `automate.runScript` or `macro.run` ran while a recorder was + /// on, for the recorder to keep with that step (see [`record::Recorder::ran`]); taken by the + /// recording hook. pub(crate) script_ran: Option>, /// Macros the person allowed to run in this session: (document uid, the macro's content as /// canonical JSON, from [`macros::fingerprint`]). @@ -720,19 +721,16 @@ impl Session { { // A toggle (Bold) is recorded with the state it set, as Excel records // `Bold = True`: playing it back sets that state rather than flipping it again. - let mut params = cmd::journal_params(¶ms); - if let (Ok(Json::Object(r)), Json::Object(p)) = (&result, &mut params) - && !p.contains_key("on") - && let Some(on @ Json::Bool(_)) = r.get("on") - { - p.insert("on".into(), on.clone()); - } + let params = match &result { + Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(¶ms), r), + _ => cmd::journal_params(¶ms), + }; let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params }); - // A script run is kept with the steps it ran, so editing or deleting the script - // before the recording stops does not change what was recorded. + // A script or macro run is kept with the steps it ran, so editing or deleting the + // script or macro before the recording stops does not change what was recorded. if let Some(ran) = self.script_ran.take() && kept - && id == "automate.runScript" + && matches!(id, "automate.runScript" | "macro.run") { rec.attach_ran(ran); } diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 1fc0ab674..9c8caa349 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -5,7 +5,8 @@ //! GridCraft's hidden workbook data ([`crate::cmd::stash`]), `_gc_macro__`, written when the //! file is saved and taken back out of the names when it is opened, so the names never linger in //! the open document. Scripts made with Automate › New Script or Record Actions are a separate, -//! older feature with their own storage; macros neither read nor change them. +//! older feature with their own storage; macros neither read nor change it. A recording keeps the +//! steps a script run ran while it recorded, as the run reported them. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -64,6 +65,30 @@ fn check_size(m: &Macro) -> Result<(), String> { Ok(()) } +/// Leaves out the last top-level steps of `m` until it fits the size [`check_size`] allows, and +/// returns how many steps it left out (a recording cut to fit, as at the step limit). Each step's +/// JSON is measured once. +pub fn fit_size(m: &mut Macro) -> usize { + let chars = |v: &Json| v.to_string().chars().count(); + let steps = std::mem::take(&mut m.steps); + // The macro without steps holds `"steps":[]`; each step adds its JSON, and a comma after the + // first. + let mut size = chars(¯o_json(m)); + let mut kept = 0usize; + for (i, st) in steps.iter().enumerate() { + let add = chars(&serde_json::to_value(st).unwrap_or(Json::Null)).saturating_add(usize::from(i > 0)); + if size.saturating_add(add) > stash::MAX_ITEM_CHARS { + break; + } + size = size.saturating_add(add); + kept = i.saturating_add(1); + } + let cut = steps.len().saturating_sub(kept); + m.steps = steps; + m.steps.truncate(kept); + cut +} + /// How many macros the workbook holds: the readable ones and the ones kept in a newer format /// (`Workbook::macros_unread`), which a save writes back and the next open counts too. pub fn count(wb: &Workbook) -> usize { diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index 54ff10811..53b8edc30 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -6,13 +6,18 @@ //! - commands a macro may not run (saving, opening files…) and other macros' commands are left //! out; a macro run while recording is replaced by its steps when it has no inputs, no output //! and only plain commands (otherwise it is left out), and an Automate script run while -//! recording by the script's steps, leaving out the steps a macro may not run; +//! recording by the steps it ran, leaving out the steps a macro may not run; both are the steps +//! as they ran, kept with the recording ([`crate::record::Recorder::ran`]), and a run without +//! them is left out; //! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed //! between commands without one, and runs of `selection.set` collapse to the last; //! - with relative references (the default), the selection when recording started is the example //! input: references on its sheet in, next to or near it become tokens (`Input.col(2)`, //! `Input.below`, `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's -//! shape, header and column types are inferred from it. +//! shape, header and column types are inferred from it as it was when recording started; +//! - recorded text that playback would read as a token stays text: a reference parameter that +//! reads as one (a defined name called `Output`) becomes `{"$text": …}`, and `{{` in a string +//! becomes `{{{{`. use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook}; @@ -35,6 +40,55 @@ pub struct RecordingStart { pub sheet_name: String, pub selection: Selection, pub relative: bool, + /// With relative references, the example input (the selection) as it was when recording + /// started: the recorded steps may change its values (text made into numbers), and the + /// macro is for data like the data it was recorded on. + pub input: Option, +} + +impl RecordingStart { + /// Recording starting now in `d`. + pub fn new(d: &crate::DocState, relative: bool) -> RecordingStart { + let sheet = d.wb.active_sheet; + RecordingStart { + doc: d.uid, + sheet, + sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), + selection: d.selection.clone(), + relative, + input: relative.then(|| infer_input(&d.wb, sheet, d.selection.current())), + } + } +} + +/// The steps a recording keeps for a run of `m`: its enabled command steps as written, when they +/// stand on their own (no inputs, no output and only plain commands; a Repeat, If or Stop means +/// nothing outside its macro). `None` when they don't, and the recording leaves the run out. +pub fn inline_steps(m: &Macro) -> Option> { + if !m.inputs.is_empty() || m.output.anchor.is_some() || !m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) { + return None; + } + Some( + m.steps + .iter() + .filter(|x| x.enabled) + .filter_map(|x| match &x.kind { + StepKind::Command { id, params } => Some((id.clone(), params.clone())), + _ => None, + }) + .collect(), + ) +} + +/// Where a gathered command came from, which says how its parameters are written. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Origin { + /// Run while recording: plain parameters, which may become relative. + Recorded, + /// A step of a recorded script run: plain parameters, kept as they are. + Script, + /// A step of a recorded macro run: macro parameters (tokens and all), kept as written. + Macro, } /// A recording turned into a macro, with what was left out. @@ -48,18 +102,18 @@ pub struct Converted { } /// Builds a macro called `name` from the recorded steps. -/// `ran` is what a script step ran, by step index ([`crate::record::Recorder::ran`]); a script step -/// without an entry is read from `wb`. -pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap>, wb: &Workbook) -> Converted { +/// `ran` is what a macro or script run ran, by step index ([`crate::record::Recorder::ran`]); a +/// run without an entry is left out and listed in `skipped`. +pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap>) -> Converted { let mut skipped = Vec::new(); - // Commands as they'll be played back: (id, params, sheet before, selection before, whether - // they came from another macro and stay as written). At most the macro's step limit are - // gathered: a recording of a thousand runs of a long macro would otherwise expand to - // millions of steps before the limit cut them back. - let mut cmds: Vec<(String, Json, usize, String, Selection, bool)> = Vec::new(); + // Commands as they'll be played back: (id, params, sheet before, selection before, where + // they came from). At most the macro's step limit are gathered: a recording of a thousand + // runs of a long macro would otherwise expand to millions of steps before the limit cut them + // back. + let mut cmds: Vec<(String, Json, usize, String, Selection, Origin)> = Vec::new(); let mut cut = steps.iter().filter(|st| st.doc != start.doc).count(); let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)); - let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, bool)>, cmd: (String, Json, usize, String, Selection, bool)| { + let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, Origin)>, cmd: (String, Json, usize, String, Selection, Origin)| { if cmds.len() < MAX_MACRO_STEPS { cmds.push(cmd); } else { @@ -68,37 +122,23 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: }; for (index, st) in steps.iter().enumerate().filter(|(_, st)| st.doc == start.doc) { let id = st.id.as_str(); - if id == "macro.run" { - // Another macro's steps, when they stand on their own: no inputs, no output and only - // plain commands (a Repeat, If or Stop means nothing outside its macro). - let inner = st.params.get("name").and_then(Json::as_str).and_then(|n| super::find(wb, n)).and_then(|i| wb.macros.get(i)); - match inner { - Some(m) if m.inputs.is_empty() && m.output.anchor.is_none() && m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) => { - for x in m.steps.iter().filter(|x| x.enabled) { - let StepKind::Command { id: cid, params } = &x.kind else { continue }; + // Another macro's steps (when they stand on their own, [`inline_steps`]) or an Automate + // script's, as they ran: what the run kept with the recording. Without them the run is + // left out; the macro or script as it stands now may not be what ran. + let origin = match id { + "macro.run" => Some(Origin::Macro), + "automate.runScript" => Some(Origin::Script), + _ => None, + }; + if let Some(origin) = origin { + match ran.get(&index) { + Some(steps) => { + for (cid, params) in steps { if refused(cid, params) { skipped.push(cid.clone()); continue; } - push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); - } - } - _ => skipped.push(id.to_string()), - } - continue; - } - if id == "automate.runScript" { - // An Automate script's steps as they ran, or as the script stands now when the recorder - // could not keep them. - let now = || st.params.get("name").and_then(Json::as_str).and_then(|n| crate::cmd::extra::script_steps(wb, n)); - match ran.get(&index).cloned().or_else(now) { - Some(steps) => { - for (cid, params) in steps { - if refused(&cid, ¶ms) { - skipped.push(cid); - continue; - } - push(&mut cmds, (cid, params, st.sheet, st.sheet_name.clone(), st.selection.clone(), true)); + push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), origin)); } } None => skipped.push(id.to_string()), @@ -109,37 +149,37 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: skipped.push(id.to_string()); continue; } - push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), false)); + push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), Origin::Recorded)); } // Sheet and selection changes made without a command: where a command ran on another sheet, // or with another selection, than the one before it, say so (a command that moved the // selection itself does it again when played back, so the extra step is harmless). // Each step keeps the name of the sheet it runs on. - let mut out: Vec<(String, Json, String, bool)> = Vec::new(); + let mut out: Vec<(String, Json, String, Origin)> = Vec::new(); let (mut sheet, mut sel) = (start.sheet, start.selection.clone()); - for (id, params, at_sheet, sheet_name, at_sel, kept) in cmds { + for (id, params, at_sheet, sheet_name, at_sel, origin) in cmds { if at_sheet != sheet { - out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), false)); + out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), Origin::Recorded)); } // A recorded selection.set just before already put the selection there. let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set"); if (at_sel != sel || at_sheet != sheet) && !selected { - out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), false)); + out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), Origin::Recorded)); } (sheet, sel) = (at_sheet, at_sel); - out.push((id, params, sheet_name, kept)); + out.push((id, params, sheet_name, origin)); } // Runs of selection.set (or sheet.activate): the last one wins. - let mut collapsed: Vec<(String, Json, String, bool)> = Vec::new(); - for (id, params, on, kept) in out { + let mut collapsed: Vec<(String, Json, String, Origin)> = Vec::new(); + for (id, params, on, origin) in out { if matches!(id.as_str(), "selection.set" | "sheet.activate") && let Some((last, ..)) = collapsed.last() && *last == id { collapsed.pop(); } - collapsed.push((id, params, on, kept)); + collapsed.push((id, params, on, origin)); } // Relative references. Only references on the input's sheet become tokens: on another sheet @@ -148,7 +188,7 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: let mut used_input = false; let steps: Vec = collapsed .into_iter() - .map(|(id, params, on, kept)| { + .map(|(id, params, on, origin)| { // A step that names another sheet with its `sheet` parameter runs there, whatever // sheet was active: its unqualified references are that sheet's cells. let names_other_sheet = params.get("sheet").is_some_and(|v| match v { @@ -157,13 +197,19 @@ pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: _ => false, }); let here = on.eq_ignore_ascii_case(&start.sheet_name) && !names_other_sheet; - let params = if start.relative && !kept { relative(¶ms, example, &start.sheet_name, here, &mut used_input, None) } else { params }; + // Plain text first, so the tokens added next are the only ones. + let params = if origin == Origin::Macro { params } else { literal(¶ms, None) }; + let params = if start.relative && origin == Origin::Recorded { + relative(¶ms, example, &start.sheet_name, here, &mut used_input, None) + } else { + params + }; Step::command(id, params) }) .collect(); let mut mac = Macro { name: name.to_string(), steps, ..Macro::default() }; if start.relative && used_input { - mac.inputs = vec![infer_input(wb, start.sheet, example)]; + mac.inputs = vec![start.input.clone().unwrap_or_default()]; } Converted { mac, skipped, cut } } @@ -173,10 +219,27 @@ fn selection_params(sel: &Selection) -> Json { json!({"range": ranges.join(","), "active": sel.active.a1()}) } +/// Plain recorded `params` written so playback reads them as they are: a string in a reference +/// parameter that playback would read as a token, or that holds `{{`, becomes `{"$text": …}`, +/// and `{{` in any other string becomes `{{{{`. +fn literal(v: &Json, key: Option<&str>) -> Json { + match v { + Json::Object(o) => Json::Object(o.iter().map(|(k, x)| (k.clone(), literal(x, Some(k)))).collect()), + Json::Array(a) => Json::Array(a.iter().map(|x| literal(x, key)).collect()), + Json::String(t) if key.is_some_and(super::resolve::is_ref_key) && (t.contains("{{") || super::resolve::reads_as_token(t, &["Input"])) => { + json!({"$text": t}) + } + Json::String(t) if t.contains("{{") => json!(t.replace("{{", "{{{{")), + other => other.clone(), + } +} + /// `params` with references near `example` (on `sheet`) made into tokens; `here` says whether the /// step runs on that sheet, so its unqualified references are on it. fn relative(v: &Json, example: RangeRef, sheet: &str, here: bool, used: &mut bool, key: Option<&str>) -> Json { match v { + // Text kept as text ([`literal`]). + Json::Object(o) if o.len() == 1 && o.contains_key("$text") => v.clone(), Json::Object(o) => { // A selection of several areas stays as it is, its active cell with it: a token for // the active cell alone would put it outside the areas on playback. @@ -299,7 +362,9 @@ fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) { if ch == '"' { in_text = !in_text; } - let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':')); + // A reference right after `{` stays as it is: `{{` before the token would read as the + // escape of a literal `{{`. + let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':' | b'{')); if !in_text && prev_ok && ch.is_ascii_alphabetic() { // The longest run of reference characters here: letters, digits and one `:`. let n = rest.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b':').count(); @@ -349,7 +414,7 @@ fn formula_token(t: RangeRef, r: RangeRef) -> Option { /// The example input's shape: a header when the first row is all text over other kinds of /// values, and each column's type when all its values agree. -fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput { +pub fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput { let mut input = NamedInput::default(); if r.is_single() { input.kind = InputKind::Cell; diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index 4e84c0965..3825dcc96 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -16,7 +16,9 @@ //! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole //! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending //! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`). -//! Each becomes A1 text before the command runs, so commands never see a token. Plain A1 +//! Each becomes A1 text before the command runs, so commands never see a token. Text that would +//! read as a token is written `{"$text": "Output"}`: it is passed as it is, never resolved or +//! interpolated (a defined name called `Output`, or `Dear {{name}}`). Plain A1 //! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor //! does. @@ -128,6 +130,15 @@ const MAX_JSON_DEPTH: usize = 64; /// Whether `text` starts like a token (its base is one this environment knows). A reference on a /// sheet with such a name (`Output!A1`) is not a token. pub fn is_token(text: &str, env: &Env) -> bool { + starts_like_token(text, |base| env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base))) +} + +/// Whether `text` would read as a token in a macro whose inputs are `inputs` (see [`is_token`]). +pub fn reads_as_token(text: &str, inputs: &[&str]) -> bool { + starts_like_token(text, |base| inputs.iter().any(|n| n.eq_ignore_ascii_case(base))) +} + +fn starts_like_token(text: &str, is_input: impl Fn(&str) -> bool) -> bool { let t = text.trim(); if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { return true; @@ -137,7 +148,7 @@ pub fn is_token(text: &str, env: &Env) -> bool { if t.get(n..).unwrap_or("").trim_start().starts_with('!') { return false; } - is_builtin(base) || env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)) + is_builtin(base) || is_input(base) } fn is_builtin(base: &str) -> bool { @@ -503,6 +514,12 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { { return Ok(Json::String(a1(resolve_ref(t, env)?, env))); } + // Text passed as it is: never a token, and no `{{…}}` in it is read. + if o.len() == 1 + && let Some(Json::String(t)) = o.get("$text") + { + return Ok(Json::String(t.clone())); + } let mut out = Map::new(); for (k, x) in o { out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?); @@ -904,6 +921,22 @@ mod tests { assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error"); } + #[test] + fn text_marked_as_text_is_never_a_token() { + // A defined name called Output or Input in a reference parameter, a Name: prefix and + // braces: `$text` passes each as it is, in any key; unmarked, each reads as a token. + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for text in ["Output", "Input", "Name:Nowhere", "Row", "Dear {{name}}"] { + let p = json!({"range": {"$text": text}, "input": {"$text": text}}); + assert_eq!(resolve_params(&p, &env).unwrap(), json!({"range": text, "input": text}), "{text}"); + assert_ne!(resolve_params(&json!({"range": text}), &env).ok(), Some(json!({"range": text})), "{text}"); + assert!(reads_as_token(text, &["Input"]) || text.contains("{{"), "{text}"); + } + assert!(!reads_as_token("Output!A1", &["Input"]) && !reads_as_token("B2", &["Input"])); + } + #[test] fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() { let mut wb = wb(); diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index d4b3599c6..3a296d048 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -50,12 +50,28 @@ pub struct Recorder { /// The size of `steps` and `ran` serialized as JSON. bytes: usize, /// What a step ran, by its index in `steps`, for a command that runs steps stored in the - /// workbook (`automate.runScript`): the commands as they ran, so a recording keeps them even - /// when the script is edited or deleted before the recording stops. A step without an entry - /// is read from the workbook when the recording is used. + /// workbook (`automate.runScript`, `macro.run`): the commands as they ran, so a recording + /// keeps them even when the script or macro is edited or deleted before the recording stops. + /// A run without an entry is left out when the recording is used. pub ran: BTreeMap>, } +/// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel +/// records `Bold = True`: playing the step back sets that state rather than flipping it again. +pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json { + if let Some(on @ Json::Bool(_)) = result.get("on") { + if params.is_null() { + params = Json::Object(serde_json::Map::new()); + } + if let Json::Object(p) = &mut params + && !p.contains_key("on") + { + p.insert("on".into(), on.clone()); + } + } + params +} + impl Recorder { /// Adds a step, or counts it as dropped once the buffer is full; `true` when it was kept. pub fn push(&mut self, step: RecordedStep) -> bool { @@ -77,7 +93,7 @@ impl Recorder { } /// Attaches what the last kept step ran ([`Recorder::ran`]), within the byte budget: past it, - /// the step stays and its script is read from the workbook when the recording is used. + /// the step stays without it, and the recording leaves the run out and lists it as skipped. pub fn attach_ran(&mut self, steps: Vec<(String, Json)>) { let Some(index) = self.steps.len().checked_sub(1) else { return }; let size = serde_json::to_vec(&steps).map_or(0, |v| v.len()); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 6155f8b90..dd3d4acf3 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -1792,6 +1792,74 @@ fn re_saving_a_large_macro_keeps_the_undo_history_within_its_budget() { assert!(d.undo.iter().skip(1).all(|e| e.retained_bytes > big.len()), "each kept copy is charged for its parameters"); } +#[test] +fn the_recorded_input_is_inferred_as_it_was_when_recording_started() { + // The recording turns text numbers into numbers; the macro is for data like the data it was + // recorded on, so the input's column types are the ones it had when recording started. + let mut s = s(); + for (cell, input) in [("B2", "Name"), ("C2", "Amount"), ("B3", "Ann"), ("C3", "'120"), ("B4", "Bo"), ("C4", "'80")] { + set(&mut s, cell, input); + } + s.execute("selection.set", json!({"range": "B2:C4"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "C3", "120"); + set(&mut s, "C4", "80"); + s.execute("macro.record", json!({"stop": true, "name": "Numbers"})).unwrap(); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Numbers"})).unwrap()).unwrap(); + assert!(!m.inputs[0].col_types.contains(&gridcraft_model::ColType::Number), "{:?}", m.inputs[0]); + // Played back on data like the original, text numbers and all. + for (cell, input) in [("G10", "Who"), ("H10", "Paid"), ("G11", "A"), ("H11", "'1"), ("G12", "B"), ("H12", "'2")] { + set(&mut s, cell, input); + } + s.execute("macro.run", json!({"name": "Numbers", "inputs": "G10:H12", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "H11"), gridcraft_core::Value::Number(120.0)); +} + +#[test] +fn a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording() { + // Played back, a menu-style step with no parameters would stop the run at its dialog; the + // script's edit is kept. A run whose steps the recording couldn't keep is left out, not read + // from the workbook as the script stands at the end. + let mut s = s(); + let steps = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}, {"command": "home.formatCells", "params": {}}]); + s.execute("automate.newScript", json!({"name": "EditThenAsk", "commands": steps})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "EditThenAsk"})).unwrap(); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"B1\",\"input\":\"1\"}"]); + set(&mut s, "B1", ""); + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "B1").display(), "1"); + assert!(!s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "EditThenAsk"})).unwrap(); + s.recorder.as_mut().unwrap().ran.clear(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Without"})).unwrap(); + assert_eq!(r["skipped"], json!(["automate.runScript"])); + assert!(recorded(&mut s, "Without").is_empty()); +} + +#[test] +fn a_script_run_records_the_state_its_toggles_set() { + let mut s = s(); + s.execute( + "automate.newScript", + json!({"name": "Bolder", "commands": [{"command": "home.bold", "params": {"range": "A1"}}, {"command": "home.italic"}]}), + ) + .unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Bolder"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(recorded(&mut s, "Rec"), ["home.bold {\"on\":true,\"range\":\"A1\"}", "home.italic {\"on\":true}"]); + // Played back twice, A1 stays bold: the step sets the state rather than flipping it. + for _ in 0..2 { + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); + assert!(bold(&s, "A1")); + } +} + #[test] fn renaming_checks_the_macro_size() { // A longer name can take a macro past the size a file holds: renaming is refused, as saving is. @@ -1808,6 +1876,117 @@ fn renaming_checks_the_macro_size() { crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); } +#[test] +fn a_recorded_macro_run_keeps_the_steps_that_ran() { + // The macro is changed and another deleted before the recording stops: the recording holds + // the steps as they ran. + let mut s = s(); + let one = |cell: &str, input: &str| json!({"output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": cell, "input": input}}]}); + for (name, cell, input) in [("Changed", "C1", "first"), ("Gone", "C2", "kept")] { + let mut m = one(cell, input); + m["name"] = json!(name); + put(&mut s, m); + } + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("macro.run", json!({"name": "Changed"})).unwrap(); + s.execute("macro.run", json!({"name": "Gone"})).unwrap(); + let mut changed = one("C1", "changed"); + changed["name"] = json!("Changed"); + s.execute("macro.save", json!({"macro": changed, "replace": true})).unwrap(); + s.execute("macro.delete", json!({"name": "Gone"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["macro.save", "macro.delete"]), "{r}"); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"C1\",\"input\":\"first\"}", "cell.set {\"cell\":\"C2\",\"input\":\"kept\"}"]); +} + +#[test] +fn recorded_text_that_reads_as_a_token_plays_back_as_text() { + // Braces typed into a cell and a defined name called Output are text and a name when + // recorded; played back they stay so, instead of being read as tokens. + let mut s = s(); + data(&mut s); + s.execute("formulas.defineName", json!({"name": "Output", "refersTo": "=Sheet1!$H$1"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "A1", "Dear {{name}}"); + set(&mut s, "E3", "=SUM(C3:C5)&\"{{x\""); + s.execute("edit.goTo", json!({"reference": "Output"})).unwrap(); + s.execute("home.bold", json!({})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Letters"})).unwrap(); + for cell in ["A1", "E3"] { + set(&mut s, cell, ""); + } + s.execute("home.bold", json!({"range": "H1"})).unwrap(); + assert!(!bold(&s, "H1")); + s.execute("macro.run", json!({"name": "Letters", "inputs": "B2:C5", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "A1").display(), "Dear {{name}}"); + assert_eq!(v(&s, "E3").display(), "400{{x"); + assert!(bold(&s, "H1")); + let steps = recorded(&mut s, "Letters"); + assert!(steps.contains(&"edit.goTo {\"reference\":{\"$text\":\"Output\"}}".to_string()), "{steps:?}"); + assert!( + steps.iter().any(|st| st.contains("=SUM({{Input.rows(2..).col(2)}})&\\\"{{{{x\\\"")), + "the formula's own reference is still a token: {steps:?}" + ); +} + +#[test] +fn a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking() { + // The person recorded it: it doesn't ask as a macro that came with the file does. + let mut s = s(); + open_with_macros(&mut s, vec![]); + assert_eq!(s.doc().unwrap().trust, crate::Trust::FromFile); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "mine"); + s.execute("macro.record", json!({"stop": true, "name": "Mine"})).unwrap(); + set(&mut s, "A1", ""); + s.execute("macro.run", json!({"name": "Mine", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "A1").display(), "mine"); + // Changed, it asks again. + s.execute("macro.save", json!({"name": "Mine", "replace": true, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})) + .unwrap(); + assert!(matches!(s.execute("macro.run", json!({"name": "Mine"})), Err(EngineError::Approve(_)))); +} + +#[test] +fn a_recording_past_the_macro_size_is_cut_and_counted() { + // Three steps of 1.5 million characters each don't fit one macro: the third is left out and + // counted in `dropped`, and the rest is saved. + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + let (doc, selection) = (s.doc().unwrap().uid, s.doc().unwrap().selection.clone()); + let rec = s.recorder.as_mut().unwrap(); + for i in 0..3 { + rec.push(crate::record::RecordedStep { + doc, + sheet: 0, + sheet_name: "Sheet1".into(), + selection: selection.clone(), + id: "cell.set".into(), + params: json!({"cell": format!("A{}", i + 1), "input": "x".repeat(1_500_000)}), + }); + } + let r = s.execute("macro.record", json!({"stop": true, "name": "Large"})).unwrap(); + assert_eq!((r["steps"].as_u64(), r["dropped"].as_u64()), (Some(2), Some(1)), "{r}"); + assert!(!s.is_recording()); + crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); +} + +#[test] +fn record_refuses_parameters_it_would_ignore() { + let mut s = s(); + for p in [json!({"name": "Early"}), json!({"description": "d"}), json!({"relative": "no"}), json!({"stop": 1})] { + assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); + assert!(!s.is_recording(), "{p}"); + } + s.execute("macro.record", json!({"relative": false, "name": null})).unwrap(); + for p in [json!({"relative": true}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] { + assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); + assert!(s.is_recording(), "{p}"); + } + assert_eq!(s.execute("macro.record", json!({"cancel": true})).unwrap()["cancelled"], true); +} + #[test] fn saving_over_a_macro_needs_replace() { let mut s = s(); @@ -1819,3 +1998,16 @@ fn saving_over_a_macro_needs_replace() { s.execute("macro.save", json!({"name": "TIDY", "description": "second", "replace": true})).unwrap(); assert_eq!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["description"], "second"); } + +#[test] +fn undo_while_recording_keeps_the_undone_step_as_excel_does() { + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + s.execute("edit.undo", json!({})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["edit.undo"]), "{r}"); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}"]); + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "A1").display(), "typed"); +} diff --git a/docs/macros.md b/docs/macros.md index b7c196956..e448ad19e 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands) +> **Last updated:** 2026-10-25 · **Change:** minor (recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands) 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 @@ -86,7 +86,9 @@ exists is used and methods follow it. Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside -any string, such as a formula; `{{{{` in a string is a literal `{{`. Under the general keys that +any string, such as a formula; `{{{{` in a string is a literal `{{`, and `{"$text": "Output"}` +anywhere is the text as it is, never read as a token or for `{{…}}` (a defined name called +`Output`, or `Dear {{name}}`). Under the general keys that other commands use for plain words (`to`, `from`, `at`, `area`, `source`, `target`, `destination`, `location`), a bare `Row` or `Column` stays text (a replacement word, a pivot area); write `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` there. Plain A1 references stay @@ -157,23 +159,42 @@ of the step that just ran, and the cells changed so far. ## Recording -`macro.record` toggles recording (`relative: false` for absolute references; `name` and -`description` when stopping; a name that another macro has gets a suffix, such as `Tidy (2)`, so -stopping never replaces a macro; `cancel: true` ends a recording without saving it, the way out of -one whose macro is too large for the workbook). The macro is saved in the workbook where recording started, even if -another one is active; if that workbook was closed, stopping ends the recording without saving it. -When saving the recording fails (a name the check refuses, a workbook at its macro limit, a -macro being debugged in that workbook), recording goes on, so stopping again keeps what was -recorded. A recording keeps up to 100,000 commands and a macro up to 10,000 steps; steps past -either limit are left out and counted in `dropped`. With relative references the selection when -recording starts is the example input: references in and near it on its sheet become tokens (on -another sheet `C4` stays that sheet's `C4`), and the input's shape, header and column types are -inferred. Toggles such as Bold are recorded with the state they set (`"on": true`), so playing -them back sets them rather than flipping them. Commands a macro may not run are left out and -listed in the result, also from the steps of a macro or script run while recording; a macro run -while recording is replaced by its steps when it has no inputs, no output and only plain commands -(otherwise it is left out and listed), and an Automate script run by the steps it ran, kept when -it ran: editing or deleting the script before the recording stops changes nothing. +`macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute +references); stopping takes `name` and `description`. A name that another macro has, a kept one +included, gets a suffix, such as `Tidy (2)`, so stopping never replaces a macro. `cancel: true` +ends a recording without saving it, the way out of one whose macro can't be saved. A parameter +the call would ignore is refused: `name` or `description` when starting, `relative` when +stopping, `stop: false` while recording, `stop`, `relative`, `name` or `description` beside +`cancel: true`, and one of the wrong kind. The macro is saved in the workbook where recording started, even if another one is active; +if that workbook was closed, stopping ends the recording without saving it. When saving the +recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged +in that workbook), recording goes on, so stopping again keeps what was recorded. The person +recorded the macro, so in a workbook opened from a file it runs without the approval a macro that +came with the file needs, until it changes. + +A recording keeps up to 100,000 commands and 64 MB of them as JSON, and a macro holds up to +10,000 steps and 4,194,304 characters of JSON. The steps past any of these limits, and steps +recorded in another workbook, are left out and counted in `dropped`. + +With relative references the selection when recording starts is the example input: references in +and near it on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and the +input's shape, header and column types are inferred from it as it was when recording started, +before the recorded steps changed it. Recorded text that playback would read as a token stays +text: a reference parameter that reads as one (a defined name called `Output`) is written +`{"$text": "Output"}`, and `{{` in a string is written `{{{{`. Toggles such as Bold are recorded +with the state they set (`"on": true`), also inside a script run, so playing them back sets them +rather than flipping them. + +Commands a macro may not run are left out and listed in `skipped`, also from the steps of a macro +or script run while recording. A macro run while recording is replaced by its steps when it has +no inputs, no output and only plain commands (otherwise it is left out and listed), and an +Automate script run by the steps it ran, without a step that only opened a dialog (a menu-style +call without its parameters). Both are kept as they ran, so editing or deleting the macro or +script before the recording stops changes nothing; a run whose steps did not fit in the +recording's 64 MB is left out and listed. + +Undo while recording works as in Excel: `edit.undo` is left out (a macro never runs it), and the +step it undid stays in the recording, so playing the recording back makes that edit again. ## Other commands and storage @@ -204,8 +225,9 @@ other macros open; a macro that was left out is not in the saved file. Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`, `deleteScript`) are an older, separate feature. They keep their commands, parameters, storage -(`_sc_scripts`) and results, and View › Macros (`view.macros`) still lists them. Macros neither -read nor change them; a workbook can hold a script and a macro with the same name. +(`_sc_scripts`) and results, and View › Macros (`view.macros`) still lists them. Macro commands +neither read nor change their storage; a recording keeps the steps a script run ran while it +recorded. A workbook can hold a script and a macro with the same name. A VBA project in an `.xlsm` or `.xltm` file is preserved on save and never run. GridCraft macros are stored apart from it. From 43134b308eea32a55543a5d94f564ac9ee08aab3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:19:35 -0400 Subject: [PATCH 124/166] Docs: catalog count 276 / 292 in the parity summary; macro storage names; the macro page's scope - target-app-parity.md: the summary table and the method say 276 / 292 and 292 unique ids, as `cargo xtask parity` measures; Last updated and revision history. - architecture.md: macros are kept as `_gc_macro__` (CodeRabbit 4366060044). - macros.md: the intro says the page covers the engine and its commands, without implying later work. --- docs/architecture.md | 5 +++-- docs/macros.md | 3 +-- docs/target-app-parity.md | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index db99955e9..edf1bb679 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (macro engine on the engine building blocks) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (the hidden names that hold macros; earlier: macro engine on the engine building blocks) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -132,7 +132,7 @@ Actions made of many commands (macros, a debugger that runs one step per call) u The macro engine (`engine/src/macros/`, commands in `cmd/macros.rs`, described in [`macros.md`](macros.md)) is built on these: a run is an undo group, the recorder reads the recording buffer, the policy asks before a macro in a `FromFile` workbook runs, and xlsx keeps -macros as chunked hidden names (`_gc_macro_`). +macros as chunked hidden names (`_gc_macro__`: macro `n`, chunk `k`). ## Agent control @@ -159,6 +159,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro__` | | 2026-10-24 | minor | Macro engine on the building blocks (`macros/`, `cmd/macros.rs`) | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | diff --git a/docs/macros.md b/docs/macros.md index e448ad19e..7c99c39c4 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -9,8 +9,7 @@ error (a file it already wrote stays), checks where its results will land before and does not touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the engine and its commands. The macro interface (a Macros list, a recorder -button and a step editor) is not part of it yet. +This page describes the engine and its commands, not a user interface for macros. ## A macro diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f9f0a7d34..0a602643f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (automation: typed macros in the engine) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (catalog count 276 / 292, as `cargo xtask parity` measures; earlier: automation: typed macros in the engine) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **275 / 291 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **276 / 292 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | @@ -54,7 +54,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus `SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only `ADD2`: **505**, all of them Excel names. -- **Ribbon catalog:** `crates/engine/src/catalog.rs` (291 unique ids across 15 tabs) against the +- **Ribbon catalog:** `crates/engine/src/catalog.rs` (292 unique ids across 15 tabs) against the 313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and identical to the committed `parity-checklist.md`. The catalog is our own transcription of Excel's main ribbon tabs; it leaves out most contextual tabs (Picture Format, Shape Format, @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | | 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) | From 198fe482a1d76d9349be88d4e748c3cbca0fe308 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:19:59 -0400 Subject: [PATCH 125/166] Engine: the run's check of each step finds the inputs on their sheet after a sheet moved - The check for writes over data compared the inputs' sheet with the sheet's index when the run started, while the inputs follow their cells: after a step inserted a sheet in front, a step writing into its own input on the moved sheet was stopped as writing over data. It now uses the sheet's index now. Test: the input case in `inputs_and_the_output_follow_their_cells_when_a_step_moves_them`. - Docs: the page says `macro.run` checks the macro, its inputs and parameters and the policy before it runs, and that a formula whose references a move rewrites counts as changed too. --- crates/engine/src/macros/run.rs | 3 ++- crates/engine/src/tests/macros.rs | 13 ++++++++++++- docs/macros.md | 10 ++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 3d293e609..917deed74 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -508,7 +508,8 @@ impl Guard { if Arc::ptr_eq(ps, sa) { continue; } - let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == si && p.range.contains(c)); + // 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) diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 0df51122c..a4871f2f2 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -191,10 +191,21 @@ fn inputs_and_the_output_follow_their_cells_when_a_step_moves_them() { {"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})); + 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"); diff --git a/docs/macros.md b/docs/macros.md index c0a09890f..d4f1cc43e 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -9,9 +9,10 @@ error (a file it already wrote stays), stops before it writes over data it may n touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the macro format, its tokens, and running macros. The engine has no check of a -macro before it runs, no macro debugger, recorder or management commands in this version, and no -macro interface. +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 @@ -115,7 +116,8 @@ refused rather than read as the header. 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: the check compares cells by place. The rule is `onConflict` (or + 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 From dadffdb33f2b9fc212cf4b55996c9a38815405e5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:21:15 -0400 Subject: [PATCH 126/166] Engine: macro.check shifts and checks again, as the run does, before it says ok Under `shiftDown` or `shiftRight` with conflicts, `macro.check` looked only for the refusals made before a shift, so it said `ok` for a run the second footprint stops (an anchor that moves with the shifted data). It now shifts the data inside an undo group, takes the footprint again, rolls both back and puts the session back, and reports what still stands in the way. - Test: the `Named` case and a shift that makes room in `shifting_makes_room_and_is_checked_again`. --- crates/engine/src/macros/run.rs | 26 +++++++++++++++++++++++++- crates/engine/src/tests/macros.rs | 13 +++++++++++++ docs/macros.md | 3 ++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index bff4e0b90..6a6d3f5c8 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -1488,6 +1488,27 @@ fn conflict_rule(p: &Json, m: &Macro) -> Result { Ok(rule) } +/// For `macro.check`, as the run would: shifts the data, checks the footprint again, and puts +/// everything back. What still stands in the way, or `None`. +fn shifted_conflicts(s: &mut Session, fp: &Footprint, p: &Prepared, budget: &Machine, down: bool) -> Result> { + let saved = Saved::take(s, p.doc); + let token = s.group_begin("Check Macro")?; + let mut spent = budget.clone(); + (spent.executed, spent.param_bytes) = (fp.executed, fp.param_bytes); + let again = make_room(s, fp, p, down).and_then(|()| footprint(s, p, &spent)); + let rolled = s.group_rollback(token); + saved.restore(s, p.doc); + rolled?; + Ok(match again { + Err(e) => Some(e.to_string()), + Ok(again) => match (&again.error, again.conflict_count()) { + (Some(e), _) => Some(e.describe()), + (None, 0) => None, + (None, _) => Some(format!("Shifting the data doesn't make room ({}).", again.describe_conflicts())), + }, + }) +} + /// `macro.check`: what running the macro would do, without changing anything. pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { let d = s.doc()?; @@ -1549,7 +1570,10 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { OnConflict::Fail => problems.push(format!("It would write over cells that hold data ({what}).")), OnConflict::Overwrite => {} OnConflict::ShiftDown | OnConflict::ShiftRight => { - if let Some(why) = shift_refusal(&s.doc()?.wb, &fp, &prepared, rule == OnConflict::ShiftDown) { + let down = rule == OnConflict::ShiftDown; + if let Some(why) = shift_refusal(&s.doc()?.wb, &fp, &prepared, down) { + problems.push(why); + } else if let Some(why) = shifted_conflicts(s, &fp, &prepared, &budget, down)? { problems.push(why); } } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 57307ee8c..50fbcfc14 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -304,6 +304,19 @@ fn shifting_makes_room_and_is_checked_again() { let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string(); assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}"); assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + // The check shifts and checks again as the run does, and puts everything back. + let journal = s.journal.len(); + let c = s.execute("macro.check", json!({"name": "Named"})).unwrap(); + assert_eq!((&c["ok"], &c["errors"]), (&json!(false), &json!(["Shifting the data doesn't make room (E3 on Sheet1)."])), "{c}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!(s.journal.len(), journal); + // Where the shift does make room, the check says the run would go ahead. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old 1"); + put(&mut s, totals()); + let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap(); + assert_eq!((&c["ok"], c.get("errors")), (&json!(true), None), "{c}"); } #[test] diff --git a/docs/macros.md b/docs/macros.md index fe169fb3b..4286fb487 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -143,7 +143,8 @@ refused rather than read as the header. output anchor, any approval needed and the footprint, and puts back everything the dry run changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; when it wouldn't, `errors` says why: the run would fail at a step, a Stop step rolls it back, it -would write over data under `ask` or `fail`, shifting can't make room, or the run would go past +would write over data under `ask` or `fail`, shifting can't make room (the check shifts and +checks again, then puts everything back), or the run would go past the step or parameter limit counting the dry runs before it. The random numbers RAND gives move on, as after any recalculation. From 00b22d5e33c25e6c72ef5e56756ddc81410aafb1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:55:46 -0400 Subject: [PATCH 127/166] Docs: the macro page keeps what the pull requests below wrote The merge of #2145 took this branch's macro page whole instead of merging it, which left out the page's newer text from below: the debugging section as #2145 rewrote it (the lock, Stop Action, parameters, the time limit for each action), the time limit for each debugger action in the limits, the check after a shift, and moves that rewrite formula references. The page is now the merge of both, with this branch's own scope line. --- docs/macros.md | 59 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index 7c99c39c4..55ed2319b 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -62,7 +62,7 @@ This page describes the engine and its commands, not a user interface for macros pass through a Repeat counts), passes its commands at most 256 MB of parameters, all steps together, and takes at most 60 seconds, all three shared by the footprint dry runs and the run itself (`timeLimit` to change the time; the web build has no clock, so there only the step and - parameter limits apply). + parameter limits apply). The debugger gives each of its actions the time limit on its own. ## Tokens @@ -125,8 +125,9 @@ refused rather than read as the header. or objects that held data when the run started stops the run and rolls the workbook back (a file already written stays): it asks (`confirm: …`) under `ask` and fails otherwise, since shifting can't make room that late. Moving cells that hold data, as inserting or deleting rows - or cells above them does, counts as writing over them, in the footprint and in this check: - both compare cells by place. + or cells above them does, counts as writing over them, in the footprint and in this check, and + so does a formula whose references such a move rewrites: both compare cells by place, so they + may ask about data that only moved, never the other way. 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, @@ -141,20 +142,54 @@ refused rather than read as the header. output anchor, any approval needed and the footprint, and puts back everything the dry run changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; when it wouldn't, `errors` says why: the run would fail at a step, a Stop step rolls it back, it -would write over data under `ask` or `fail`, shifting can't make room, or the run would go past +would write over data under `ask` or `fail`, shifting can't make room (the check shifts and +checks again, then puts everything back), or the run would go past the step or parameter limit counting the dry runs before it. The random numbers RAND gives move on, as after any recalculation. ## Debugging -`macro.debug {action, …}` with `start` (`name`, `inputs`, `breakpoints`), `step`, `continue`, -`runTo` (`step`: a path), `stop` (`keep: true` keeps the changes) and `state`. `breakpoints` can -be given with any action; `breakpointsOnly: true` makes the list replace the steps' own flags. The -run stays one undo step; while it is open, edits of the workbooks it touched (its own, and any -other its steps changed) are refused with "Finish or stop the macro first", also when another -command makes them (stopping Record Actions saves a script), and other workbooks stay -editable. Each answer gives the next step with its parameters as they would run, the parameters -of the step that just ran, and the cells changed so far. +`macro.debug {action, …}` runs a macro a step at a time. The actions are: + +- `start` with `name` and the options of `macro.run` (`inputs`, `onConflict`, `overwrite`, + `approve`, `timeLimit`), refused as `macro.run` refuses them when they are of the wrong kind. The + checks, the approval and the footprint come first, as for `macro.run`; then the run pauses + before its first step. +- `step` runs the next step; `continue` runs to the next breakpoint or the end; `runTo` (`step`: + a path) runs to that step, stopping at a breakpoint on the way. +- `stop` ends the run: `keep: true` keeps the changes as one undo step, otherwise they are rolled + back. +- `state` answers without running anything (the default while a macro is being debugged). + +`breakpoints` (a list of step paths of the macro, at most 10,000) can be given with any action and +replaces the list set before; `breakpointsOnly: true` makes the list replace the steps' own +breakpoint flags, also when it is given without a list. An unknown action, a breakpoint or `runTo` +path the macro doesn't have, and `keep` or `breakpointsOnly` that isn't `true` or `false` are +refused before anything changes. The time limit applies to each action on its own (`start` with +its dry runs, and each `step`, `continue` or `runTo`); the step and parameter limits apply to the +whole run, counting the dry runs. + +Each answer gives the next step with its parameters as they would run (a Repeat over whole rows or +columns shows the part of the sheet in use, as the run goes over it), the parameters of the step +that just ran, the steps the run has executed (the dry runs not counted), and what the run has +changed in its workbook: `changed` lists at most 500 entries (cell ranges, and sheets added, +deleted or renamed) and `changedCells` counts the changed cells on the sheets it had at the start. `shifted` names the rule that made +room at the start, and `wroteFiles` the files the run wrote, which stay when the run is rolled +back; the answer to a rolled-back run says so in `message`. A run that ends (its last step, a +Stop step, or a failed step, which rolls it back) or is stopped answers with `state` `finished`, +`stopped` or `failed`. + +While a macro is being debugged, its workbook is locked between steps: only `macro.debug`, queries +(commands that only read), commands that look around (`selection.*`, Select All, `sheet.activate`, +`sheet.next` and `sheet.previous`, Go To, Go To Special, Find, Copy and the zoom commands), Switch +Window, Close (which ends the run with its workbook) and Automate › Stop Action run there. +Everything else, undoable or not, saving included, is refused with "Finish or stop the macro +first", also when another command runs it (stopping Record Actions saves a script). Other +workbooks stay editable. The person may look around between steps: before the next step runs, the +debugger puts the active sheet and the selection back where the run left them, so the run goes on +as `macro.run` would run it. A zoom change made meanwhile is part of the run, and is undone with +it. Stop Action rolls the run back and ends it, and its answer names the macro. The debugger runs +on its own, never as a step of a script or macro, and is never recorded. ## Recording From 90a46da4968dfa132f4f61166552343f09cbda7f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:13:05 -0400 Subject: [PATCH 128/166] Engine: macro.run's description lists only what it returns here `shifted` comes with the footprint in the pull request above; here a shift rule stops the run as `fail` does, which the description now says. --- crates/engine/src/cmd/macros.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index d81902b1b..e53482e94 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { "Run Macro", [], None, - "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", + "{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 )] From f6f4585c931c5edca099bb4d66027a49310bc751 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:14:12 -0400 Subject: [PATCH 129/166] Engine: a dry run puts the document's revision back The dry run's rollback counts as a change of the document and moves its revision on, which front ends key their caches on, so a `macro.check` that changed nothing (and a shift it tried and took back) looked like a change. `Saved` keeps the revision and puts it back with the rest. - Test: the revision in `shifting_makes_room_and_is_checked_again`. --- crates/engine/src/macros/run.rs | 7 +++++++ crates/engine/src/tests/macros.rs | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 6a6d3f5c8..373d61d8b 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -998,6 +998,9 @@ struct Saved { /// The workbook as last saved: `sheet.activate` and the view commands move it to the /// current workbook on a clean document, and the rollback puts the workbook back but not this. saved: Option>, + /// The document's revision: the rollback counts as a change, and front ends key their caches + /// on it, so a check that changed nothing leaves it as it was. + revision: Option, sheet_selections: Vec, unlocked_ranges: Vec<(String, String)>, trust: Trust, @@ -1027,6 +1030,7 @@ impl Saved { active: s.active, docs: s.docs.iter().map(|d| d.uid).collect(), saved: d.map(|d| d.saved.clone()), + revision: d.map(|d| d.revision), sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), trust: d.map_or(Trust::Local, |d| d.trust), @@ -1054,6 +1058,9 @@ impl Saved { if let Some(saved) = self.saved { d.saved = saved; } + if let Some(revision) = self.revision { + d.revision = revision; + } d.sheet_selections = self.sheet_selections; d.unlocked_ranges = self.unlocked_ranges; d.trust = self.trust; diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 50fbcfc14..d94494ba5 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -304,12 +304,14 @@ fn shifting_makes_room_and_is_checked_again() { let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string(); assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}"); assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); - // The check shifts and checks again as the run does, and puts everything back. + // The check shifts and checks again as the run does, and puts everything back, the document's + // revision included. let journal = s.journal.len(); + let revision = s.doc().unwrap().revision; let c = s.execute("macro.check", json!({"name": "Named"})).unwrap(); assert_eq!((&c["ok"], &c["errors"]), (&json!(false), &json!(["Shifting the data doesn't make room (E3 on Sheet1)."])), "{c}"); assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); - assert_eq!(s.journal.len(), journal); + assert_eq!((s.journal.len(), s.doc().unwrap().revision), (journal, revision)); // Where the shift does make room, the check says the run would go ahead. let mut s = super::s(); data(&mut s); From f225cd102650a3b6fff93cc0b6b70bef35ed8697 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:16:23 -0400 Subject: [PATCH 130/166] Engine: Check Macro waits while a macro is being debugged `macro.check` is a query, so the debugger's lock let it through, but its dry run runs the macro's steps, which the lock refuses: the check reported the lock as the failure of a step. It now waits with "Finish or stop the macro first", as other commands that change the workbook do. - Test: `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`. --- crates/engine/src/macros/debug.rs | 4 +++- crates/engine/src/tests/macros.rs | 2 ++ docs/macros.md | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/macros/debug.rs b/crates/engine/src/macros/debug.rs index acbc320f3..f5932daee 100644 --- a/crates/engine/src/macros/debug.rs +++ b/crates/engine/src/macros/debug.rs @@ -51,7 +51,9 @@ pub const LOOKS_AROUND: &[&str] = &[ /// Whether command `id` (a query when `query`) runs on a workbook the debugger locks. pub fn runs_while_locked(id: &str, query: bool) -> bool { - query || id == "macro.debug" || LOOKS_AROUND.contains(&id) + // `macro.check` is a query, but its dry run runs the macro's steps, which the lock refuses: it + // waits too, rather than report the lock as the failure of a step. + (query && id != "macro.check") || id == "macro.debug" || LOOKS_AROUND.contains(&id) } /// A macro being debugged. diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 86a4e993c..13530cb9b 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -440,6 +440,8 @@ fn between_steps_only_looking_around_runs_and_the_run_continues_where_it_was() { assert!(busy(s.execute("file.save", json!({})))); assert!(busy(s.execute("automate.recordActions", json!({})))); assert!(busy(s.execute("cell.set", json!({"cell": "A1", "input": "x"})))); + // A check would dry-run the macro's steps, which the lock refuses: it waits too. + assert!(busy(s.execute("macro.check", json!({"name": "Sel"})))); assert!(!path.exists(), "nothing was saved"); // The next step runs on the run's sheet and selection, as macro.run would. let st = debug(&mut s, json!({"action": "step"})); diff --git a/docs/macros.md b/docs/macros.md index d88f940e0..4a6268da9 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -185,7 +185,8 @@ While a macro is being debugged, its workbook is locked between steps: only `mac `sheet.next` and `sheet.previous`, Go To, Go To Special, Find, Copy and the zoom commands), Switch Window, Close (which ends the run with its workbook) and Automate › Stop Action run there. Everything else, undoable or not, saving included, is refused with "Finish or stop the macro -first", also when another command runs it (stopping Record Actions saves a script). Other +first", also when another command runs it (stopping Record Actions saves a script), and so is +`macro.check`, whose dry run runs the macro's steps. Other workbooks stay editable. The person may look around between steps: before the next step runs, the debugger puts the active sheet and the selection back where the run left them, so the run goes on as `macro.run` would run it. A zoom change made meanwhile is part of the run, and is undone with From bfcf50aad3b0fc33af790d6ee4940688949c22f0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:00:13 -0400 Subject: [PATCH 131/166] Engine: rolling back an undo group takes back the steps it recorded Stop Action (or any rollback) put a group's edits back but left the steps recorded while the group was open in the recording, so playing the recording back made edits the workbook no longer had. A group remembers where the recording was when it began, and its rollback cuts the recording back to there. - Test: `stop_action_runs_only_on_its_own_and_is_not_recorded`, which kept the rolled-back edit before. --- crates/engine/src/lib.rs | 9 +++++++++ crates/engine/src/record.rs | 15 +++++++++++++++ crates/engine/src/tests/seams.rs | 5 +++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3f84203a3..b2aabd7da 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -96,6 +96,9 @@ struct OpenGroup { selection: Selection, /// The redo stack when the group began (a rollback puts it back). redo: Vec, + /// Where the recording was when the group began (`None` when nothing was recording): a + /// rollback takes back the steps recorded since, as it takes back their edits. + recorded: Option<(usize, usize)>, } /// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the @@ -776,6 +779,7 @@ impl Session { wb: d.wb.clone(), selection: d.selection.clone(), redo: d.redo.clone(), + recorded: self.recorder.as_ref().map(record::Recorder::mark), }; let token = GroupToken { id: g.id, doc: g.doc }; self.next_group = self.next_group.saturating_add(1); @@ -872,6 +876,11 @@ impl Session { d.undo.truncate(g.start); d.redo = g.redo; d.revision += 1; + // The steps recorded while the group was open go with their edits (all of them when the + // recording started inside the group). + if let Some(rec) = self.recorder.as_mut() { + rec.cut_back(g.recorded.unwrap_or((0, 0))); + } // The record of the last group step stays when the document is back exactly where that // step left it (this group began there); otherwise the rollback is where the group ended. let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb)); diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 4a5d7c4c9..f1a7e29e5 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -67,4 +67,19 @@ impl Recorder { self.dropped = self.dropped.saturating_add(1); } } + + /// Where the recording is now, for [`Recorder::cut_back`]. + pub(crate) fn mark(&self) -> (usize, usize) { + (self.steps.len(), self.dropped) + } + + /// Takes back the steps recorded since `mark` (an undo group that recorded them was rolled + /// back, so playing them back would make edits the workbook no longer has). + pub(crate) fn cut_back(&mut self, (len, dropped): (usize, usize)) { + if self.steps.len() > len { + self.steps.truncate(len); + self.bytes = self.steps.iter().map(|s| serde_json::to_vec(s).map_or(0, |v| v.len())).sum(); + } + self.dropped = self.dropped.min(dropped); + } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index e93116120..48d5abf4c 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -837,12 +837,13 @@ fn stop_action_runs_only_on_its_own_and_is_not_recorded() { r.unwrap(); assert_eq!(v(&s, "A2").display(), "two"); assert_eq!(undo_labels(&s), ["Outer"], "the action kept its group"); - // On its own it stops a group left open; the recording keeps the edits and not the stop. + // On its own it stops a group left open; the recording keeps neither the stop nor the edits + // the stop rolled back, so playing it back makes only the edits the workbook has. let _left = s.group_begin("Left open").unwrap(); set(&mut s, "A3", "three"); assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); assert!(v(&s, "A3").is_empty()); - assert_eq!(recorded_ids(&s), ["cell.set", "cell.set", "cell.set"]); + assert_eq!(recorded_ids(&s), ["cell.set", "cell.set"]); assert_eq!(s.stop_recording().unwrap().dropped, 0); } From 0aae1ffeea37e9d6a86deebb9c1c594d0aa9d835 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:00:13 -0400 Subject: [PATCH 132/166] Engine: an xlsx save refuses a macro that skipped the check, as a JSON save does `with_names` checked only the size of each item, so a macro built in code past the model's limits (a name with a line break) was written to the file and dropped the next time it opened. The xlsx save now runs `check` on every macro first. - Test: the macro past the limits in `a_macro_built_in_code_goes_through_the_size_check_too`. --- crates/engine/src/macros/mod.rs | 5 +++++ crates/engine/src/macros/tests.rs | 14 +++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index ab692b5b7..8c23fbf0e 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -106,6 +106,11 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) { return Ok(None); } + // A macro that skipped `check` (built in code) fails the save, as the JSON save does, rather + // than being written and dropped the next time the file opens. + for m in wb.macros.iter() { + check(m).map_err(crate::EngineError::Other)?; + } let mut out = wb.clone(); // One pass drops the items of the last save, and one writes the macros (a file can hold a // thousand macros and many thousands of names). diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index dc25de621..5f5531207 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -211,12 +211,16 @@ fn a_macro_built_in_code_goes_through_the_size_check_too() { }; assert!(big.check_limits().is_ok(), "the model's limits say nothing about the size"); assert!(check(&big).unwrap_err().contains("too large")); - let mut wb = Workbook::new(); - wb.macros.push(big); // A macro that fails `check` fails the save in either format, rather than being written and - // dropped the next time the file opens. - for file in ["book.xlsx", "book.json"] { - assert!(crate::io::save_bytes(&wb, file).is_err(), "{file}"); + // dropped the next time the file opens: too large, or past the model's limits. + let bad_name = Macro { name: "Two\nlines".into(), ..Macro::default() }; + assert!(check(&bad_name).is_err()); + for m in [big, bad_name] { + let mut wb = Workbook::new(); + wb.macros.push(m); + for file in ["book.xlsx", "book.json"] { + assert!(crate::io::save_bytes(&wb, file).is_err(), "{file}"); + } } } From 361fff982a3e90fe94b4520b42d74726b8eca9ef Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:02:18 -0400 Subject: [PATCH 133/166] Engine: a dry run journals nothing, so the journal and Record Actions' start stay as they were Every nested command a dry run executed was journaled. Past the journal's limits that evicted the person's own oldest entries, and the restore could only cut back what was left, so one `macro.check` of a long macro could empty the journal and move a Record Actions recording's start. While `Saved` holds the session's state, commands are not journaled (`Session::journal_paused`), and the restore puts the journal and the recording start back exactly; the count of dropped entries it relied on is gone. The fallible calls of a dry run come before the pause, and a panic at the top level turns journaling back on. - Test: `a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were` (a full journal, a Record Actions start, and a check of 12,000 nested steps; replaces the test that accepted the loss). --- crates/engine/src/lib.rs | 18 ++++++++--------- crates/engine/src/macros/run.rs | 26 ++++++++++++------------- crates/engine/src/tests/macros.rs | 32 ++++++++++++++----------------- 3 files changed, 35 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 16ab84aab..daea7f7e8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -445,9 +445,9 @@ pub struct Session { /// Serialized size of each journal entry, measured once when it is pushed, and their sum. journal_sizes: Vec, journal_bytes: usize, - /// Entries the journal has dropped from its front to stay within its limits, ever; a dry run - /// that puts the journal back needs to know how many went while it ran. - journal_dropped: u64, + /// While a dry run runs (the footprint, `macro.check`), its commands are not journaled: the + /// dry run is put back, and journaling it would evict the person's own oldest entries. + pub(crate) journal_paused: u32, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). pub format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -572,16 +572,10 @@ impl Session { self.journal.drain(..n); self.journal_sizes.drain(..n); self.journal_bytes = kept; - self.journal_dropped = self.journal_dropped.saturating_add(n as u64); self.recording = self.recording.map(|r| r.saturating_sub(n)); } } - /// Entries dropped from the journal's front so far, for a caller that puts the journal back. - pub(crate) fn journal_dropped(&self) -> u64 { - self.journal_dropped - } - /// Cuts the journal back to its first `keep` entries, with its size bookkeeping. pub(crate) fn truncate_journal(&mut self, keep: usize) { if self.journal_sizes.len() == self.journal.len() { @@ -675,6 +669,8 @@ impl Session { if self.depth == 0 { self.trim_history(); self.group_step = None; + // A dry run the panic cut short never put journaling back on. + self.journal_paused = 0; } return Err(EngineError::Internal(id.to_string(), msg)); } @@ -700,7 +696,9 @@ impl Session { { rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); } - self.push_journal(id, cmd::journal_params(¶ms)); + if self.journal_paused == 0 { + self.push_journal(id, cmd::journal_params(¶ms)); + } } // Inside an undo group the group owns the step. A command that ended a group open before // it leaves the group its step and makes one of its own for what it changed after the end. diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 373d61d8b..2cfa37b34 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -1008,10 +1008,9 @@ struct Saved { clipboard: Option, prefs: crate::Prefs, mode: crate::Mode, - /// The journal's length and how many entries it had dropped from its front; a dry run that - /// pushes past the journal's limits drops more, which a length alone cannot see. + /// The journal's length: the dry run journals nothing (`Session::journal_paused`), so nothing + /// of the person's is evicted, and the length puts back exactly what was there. journal: usize, - journal_dropped: u64, recording: Option, ui_requests: usize, format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -1024,9 +1023,10 @@ struct Saved { } impl Saved { + /// Takes the state and stops journaling until [`Saved::restore`]. fn take(s: &mut Session, doc: u64) -> Saved { let d = s.docs.iter().find(|d| d.uid == doc); - Saved { + let saved = Saved { active: s.active, docs: s.docs.iter().map(|d| d.uid).collect(), saved: d.map(|d| d.saved.clone()), @@ -1039,7 +1039,6 @@ impl Saved { prefs: s.prefs.clone(), mode: s.mode, journal: s.journal.len(), - journal_dropped: s.journal_dropped(), recording: s.recording, ui_requests: s.ui_requests.len(), format_painter: s.format_painter.clone(), @@ -1049,7 +1048,9 @@ impl Saved { draw: (s.draw_tool.clone(), s.draw_color.clone(), s.draw_width), comments_pane: s.comments_pane, show_all_notes: s.show_all_notes, - } + }; + s.journal_paused = s.journal_paused.saturating_add(1); + saved } fn restore(self, s: &mut Session, doc: u64) { @@ -1070,11 +1071,9 @@ impl Saved { s.clipboard = self.clipboard; s.prefs = self.prefs; s.mode = self.mode; - // Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good, - // and the recording's start moves back with them. - let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX); - s.truncate_journal(self.journal.saturating_sub(dropped)); - s.recording = self.recording.map(|r| r.saturating_sub(dropped)); + s.truncate_journal(self.journal); + s.recording = self.recording; + s.journal_paused = s.journal_paused.saturating_sub(1); s.ui_requests.truncate(self.ui_requests); s.format_painter = self.format_painter; s.last_find = self.last_find; @@ -1092,9 +1091,10 @@ impl Saved { pub fn footprint(s: &mut Session, p: &Prepared, run: &Machine) -> Result { let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; s.set_active(i); - let saved = Saved::take(s, p.doc); let before = s.doc()?.wb.clone(); let token = s.group_begin("Check Macro")?; + // After the calls that can fail, so nothing returns between taking and putting back. + let saved = Saved::take(s, p.doc); if let Some(d) = s.docs.iter_mut().find(|d| d.uid == p.doc) { d.touched = Some(Vec::new()); } @@ -1498,8 +1498,8 @@ fn conflict_rule(p: &Json, m: &Macro) -> Result { /// For `macro.check`, as the run would: shifts the data, checks the footprint again, and puts /// everything back. What still stands in the way, or `None`. fn shifted_conflicts(s: &mut Session, fp: &Footprint, p: &Prepared, budget: &Machine, down: bool) -> Result> { - let saved = Saved::take(s, p.doc); let token = s.group_begin("Check Macro")?; + let saved = Saved::take(s, p.doc); let mut spent = budget.clone(); (spent.executed, spent.param_bytes) = (fp.executed, fp.param_bytes); let again = make_room(s, fp, p, down).and_then(|()| footprint(s, p, &spent)); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index d94494ba5..5df8faa11 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -418,33 +418,29 @@ fn a_dry_run_leaves_a_clean_workbook_clean() { } #[test] -fn a_dry_run_puts_the_journal_back_even_after_it_trimmed_it() { - // Every nested command a dry run executes is journaled, so a long macro pushes the journal - // past its limit and the journal drops its oldest entries. Putting the journal back by its - // old length would then keep entries from the dry run and point a recording at the wrong - // place; the restore counts what was dropped. +fn a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were() { + // A dry run journals nothing: journaling its nested commands would push a full journal past + // its limit and evict the person's own oldest entries, and move Record Actions' start. let mut s = s(); data(&mut s); - s.execute("selection.set", json!({"range": "A1"})).unwrap(); - let journal = s.journal.clone(); - s.recording = Some(journal.len().saturating_sub(1)); + for i in 0..crate::JOURNAL_MAX_ENTRIES { + s.execute("selection.set", json!({"range": format!("A{}", i % 50 + 1)})).unwrap(); + } + s.recording = Some(s.journal.len().saturating_sub(100)); put( &mut s, json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A200", "body": [ {"type": "repeat", "over": "A1:A60", "body": [{"command": "cell.set", "params": {"cell": "Row.cell(1,1)", "input": "x"}}]} ]}]}), ); - s.execute("macro.check", json!({"name": "Long"})).unwrap(); - let sets = |j: &[(String, Json)]| j.iter().filter(|(id, _)| id == "cell.set").count(); - assert!(sets(&s.journal) <= sets(&journal), "no dry-run entry stays"); - assert!(s.journal.len() <= journal.len(), "{} entries, had {}", s.journal.len(), journal.len()); - assert!(journal.ends_with(&s.journal), "what stays is the journal's own tail"); - assert!(s.recording.is_some_and(|r| r <= s.journal.len()), "{:?}", s.recording); - // A short dry run leaves the journal exactly as it was. - put(&mut s, json!({"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "x"}}]})); let (journal, recording) = (s.journal.clone(), s.recording); - s.execute("macro.check", json!({"name": "Short"})).unwrap(); - assert_eq!((&s.journal, s.recording), (&journal, recording)); + let c = s.execute("macro.check", json!({"name": "Long"})).unwrap(); + assert!(c.get("footprint").is_some(), "{c}"); + assert_eq!((s.journal.len(), s.recording), (journal.len(), recording)); + assert!(s.journal == journal, "the journal is as it was"); + // The check itself is a query and isn't journaled; a command after it is. + s.execute("selection.set", json!({"range": "B1"})).unwrap(); + assert_eq!(s.journal.last().map(|(id, _)| id.as_str()), Some("selection.set")); } #[test] From 2e8284c191aa575f29d0da4a734ad580d82e0baa Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:26:51 -0400 Subject: [PATCH 134/166] Engine: a panic during a dry run puts the journal pause and the touch log back at any depth The pause of a dry run was cleared only by a panic at the top level. A command that started a dry run, nested in another, and panicked left journaling paused for the rest of the session (and the touch log open) once the outer command finished. Each command now keeps the pause count it started with, and its panic guard puts it back, closing the touch logs, whatever its depth. - Test: `a_dry_run_cut_short_by_a_panic_leaves_journaling_on`. --- crates/engine/src/lib.rs | 11 +++++++++-- crates/engine/src/tests/macros.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index daea7f7e8..e492c4dd6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -599,6 +599,9 @@ impl Session { return Err(EngineError::Disabled(id.to_string(), why)); } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); + // A dry run inside this command that a panic cuts short leaves journaling paused and its + // touch log open; the panic branch puts both back. + let paused_before = self.journal_paused; let params = if params.is_null() { Json::Object(Default::default()) } else { params }; let (first_group, group_epoch) = (self.next_group, self.group_epoch); // Whether a group was open on the document before the command, and how long its history was. @@ -666,11 +669,15 @@ impl Session { } d.calc.rebuild(&d.wb); } + if self.journal_paused != paused_before { + self.journal_paused = paused_before; + for d in self.docs.iter_mut() { + d.touched = None; + } + } if self.depth == 0 { self.trim_history(); self.group_step = None; - // A dry run the panic cut short never put journaling back on. - self.journal_paused = 0; } return Err(EngineError::Internal(id.to_string(), msg)); } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 5df8faa11..153c832ca 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -417,6 +417,27 @@ fn a_dry_run_leaves_a_clean_workbook_clean() { assert!(!s.doc().unwrap().is_dirty()); } +#[test] +fn a_dry_run_cut_short_by_a_panic_leaves_journaling_on() { + // A panic inside a command that started a dry run, nested in another command: the panic + // guard of the nested call puts the pause and the touch log back, so the session journals + // the person's commands again. + crate::cmd::register_undoable_test_command("test.dryRunThenPanic", |s, _| { + s.journal_paused = s.journal_paused.saturating_add(1); + if let Ok(d) = s.doc_mut() { + d.touched = Some(Vec::new()); + } + panic!("in a dry run"); + }); + let mut s = s(); + let err = s.execute_nested("test.dryRunThenPanic", json!({})).unwrap_err().to_string(); + assert!(err.contains("in a dry run"), "{err}"); + assert_eq!((s.journal_paused, s.doc().unwrap().touched.is_none()), (0, true)); + let n = s.journal.len(); + s.execute("selection.set", json!({"range": "B2"})).unwrap(); + assert_eq!(s.journal.len(), n + 1, "journaled again"); +} + #[test] fn a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were() { // A dry run journals nothing: journaling its nested commands would push a full journal past From 0a929eaf6884a038845c1f1587d501ffc9fdefa4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:27:53 -0400 Subject: [PATCH 135/166] Engine: a rolled-back group takes back only the steps recorded in its workbook A rollback took back every step recorded since its group began, so an edit recorded in another workbook while the group was open was lost from the recording although the edit stayed. It now takes back only the steps recorded in the group's own workbook. - Test: the other workbook's edit in `stop_action_runs_only_on_its_own_and_is_not_recorded`. --- crates/engine/src/lib.rs | 2 +- crates/engine/src/record.rs | 17 +++++++++++------ crates/engine/src/tests/seams.rs | 10 ++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b2aabd7da..a0305b5a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -879,7 +879,7 @@ impl Session { // The steps recorded while the group was open go with their edits (all of them when the // recording started inside the group). if let Some(rec) = self.recorder.as_mut() { - rec.cut_back(g.recorded.unwrap_or((0, 0))); + rec.cut_back(g.recorded.unwrap_or((0, 0)), g.doc); } // The record of the last group step stays when the document is back exactly where that // step left it (this group began there); otherwise the rollback is where the group ended. diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index f1a7e29e5..b0d28ef40 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -73,13 +73,18 @@ impl Recorder { (self.steps.len(), self.dropped) } - /// Takes back the steps recorded since `mark` (an undo group that recorded them was rolled - /// back, so playing them back would make edits the workbook no longer has). - pub(crate) fn cut_back(&mut self, (len, dropped): (usize, usize)) { - if self.steps.len() > len { - self.steps.truncate(len); + /// Takes back the steps recorded in the document with uid `doc` since `mark` (an undo group + /// on it was rolled back, so playing them back would make edits it no longer has). Steps in + /// other documents stay: their edits do too. Steps the buffer dropped stay counted. + pub(crate) fn cut_back(&mut self, (len, _): (usize, usize), doc: u64) { + let before = self.steps.len(); + let mut i = 0; + self.steps.retain(|s| { + i += 1; + i <= len || s.doc != doc + }); + if self.steps.len() != before { self.bytes = self.steps.iter().map(|s| serde_json::to_vec(s).map_or(0, |v| v.len())).sum(); } - self.dropped = self.dropped.min(dropped); } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 48d5abf4c..3fa37b100 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -844,6 +844,16 @@ fn stop_action_runs_only_on_its_own_and_is_not_recorded() { assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); assert!(v(&s, "A3").is_empty()); assert_eq!(recorded_ids(&s), ["cell.set", "cell.set"]); + // An edit recorded in another workbook while the group was open stays, as its edit does. + let first = s.active_index(); + let _left = s.group_begin("Left open").unwrap(); + set(&mut s, "A4", "four"); + s.new_workbook(); + set(&mut s, "B1", "other"); + s.set_active(first); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); + assert_eq!(recorded_ids(&s), ["cell.set", "cell.set", "cell.set"], "the other workbook's edit stays"); + assert_eq!(s.recorder.as_ref().unwrap().steps.last().map(|st| st.params["cell"].clone()), Some(json!("B1"))); assert_eq!(s.stop_recording().unwrap().dropped, 0); } From ca2be34e8a637d9d32939388b018f6d8a02f3c0b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:29:23 -0400 Subject: [PATCH 136/166] Engine: a rolled-back group takes what its script runs ran out of the recording too Taking back the steps a rolled-back group recorded left what a taken-back script or macro run ran (`Recorder::ran`) under its old index, where a later run could pick it up, and left it out of the size count. `cut_back` now drops it with its step, moves a kept run's entry to the step's new index and counts both again. - Test: `a_rolled_back_script_run_takes_what_it_ran_out_of_the_recording`. --- crates/engine/src/record.rs | 27 +++++++++++++++++++-------- crates/engine/src/tests/macros.rs | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 413a8f62d..4dfbe82d0 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -112,15 +112,26 @@ impl Recorder { /// Takes back the steps recorded in the document with uid `doc` since `mark` (an undo group /// on it was rolled back, so playing them back would make edits it no longer has). Steps in /// other documents stay: their edits do too. Steps the buffer dropped stay counted. + /// What a step ran ([`Recorder::ran`]) goes with its step, and stays with a kept one under its + /// new index. pub(crate) fn cut_back(&mut self, (len, _): (usize, usize), doc: u64) { - let before = self.steps.len(); - let mut i = 0; - self.steps.retain(|s| { - i += 1; - i <= len || s.doc != doc - }); - if self.steps.len() != before { - self.bytes = self.steps.iter().map(|s| serde_json::to_vec(s).map_or(0, |v| v.len())).sum(); + if !self.steps.iter().skip(len).any(|s| s.doc == doc) { + return; } + let mut ran = std::mem::take(&mut self.ran); + let mut steps = Vec::with_capacity(self.steps.len()); + for (i, step) in std::mem::take(&mut self.steps).into_iter().enumerate() { + if i >= len && step.doc == doc { + continue; + } + if let Some(r) = ran.remove(&i) { + self.ran.insert(steps.len(), r); + } + steps.push(step); + } + self.steps = steps; + let size = |v: Result, serde_json::Error>| v.map_or(0, |v| v.len()); + self.bytes = self.steps.iter().map(|s| size(serde_json::to_vec(s))).sum::() + + self.ran.values().map(|r| size(serde_json::to_vec(r))).sum::(); } } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index b4ee076e2..459faa406 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2164,6 +2164,26 @@ fn a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording() { assert!(recorded(&mut s, "Without").is_empty()); } +#[test] +fn a_rolled_back_script_run_takes_what_it_ran_out_of_the_recording() { + // What a recorded script run ran goes with its step when a group rollback takes the step + // back, and a kept run keeps what it ran under its new place. + let mut s = s(); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Mark"})).unwrap(); + let _left = s.group_begin("Left open").unwrap(); + set(&mut s, "B1", "gone"); + s.execute("automate.runScript", json!({"name": "Mark"})).unwrap(); + s.execute("automate.stopAction", json!({})).unwrap(); + let rec = s.recorder.as_ref().unwrap(); + assert_eq!((rec.steps.len(), rec.ran.keys().copied().collect::>()), (1, vec![0])); + s.execute("automate.runScript", json!({"name": "Mark"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + let a1 = "cell.set {\"cell\":\"A1\",\"input\":\"m\"}"; + assert_eq!(recorded(&mut s, "Rec"), [a1, a1]); +} + #[test] fn a_script_run_records_the_state_its_toggles_set() { let mut s = s(); From 0533480c970ac558fabff66bbaa11fe380eb0b80 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:48:03 -0400 Subject: [PATCH 137/166] Engine: recorded steps carry a number, so a group's rollback finds its own after others A group remembered the recording's length when it began, which another workbook's rollback (or a recording started again inside the group) made point at the wrong steps. Each kept step now has a number, and each recording one of its own; a group remembers both, and its rollback takes back the steps of its workbook numbered from its mark, or all of them when the recording started inside the group. - Test: `rollbacks_take_back_their_own_recorded_steps_in_any_order` (groups on two workbooks rolled back in turn, and a recording started again inside a group). --- crates/engine/src/lib.rs | 2 +- crates/engine/src/record.rs | 53 +++++++++++++++++++++++--------- crates/engine/src/tests/seams.rs | 28 +++++++++++++++++ 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a0305b5a8..d7aa1bfe1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -98,7 +98,7 @@ struct OpenGroup { redo: Vec, /// Where the recording was when the group began (`None` when nothing was recording): a /// rollback takes back the steps recorded since, as it takes back their edits. - recorded: Option<(usize, usize)>, + recorded: Option<(u64, u64)>, } /// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index b0d28ef40..b6ccae410 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -38,8 +38,11 @@ pub struct RecordedStep { pub params: Json, } +/// Numbers each recording, so an undo group's mark from an earlier one isn't read as this one's. +static NEXT_RECORDING: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + /// The steps recorded since recording started. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct Recorder { pub steps: Vec, /// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`]. @@ -48,6 +51,18 @@ pub struct Recorder { pub dropped: usize, /// The size of `steps` serialized as JSON. bytes: usize, + /// Which recording this is, and the number the next kept step gets: each step's number is in + /// `seqs`, beside it, so a mark stays right however many steps rollbacks take out. + generation: u64, + next_seq: u64, + seqs: Vec, +} + +impl Default for Recorder { + fn default() -> Self { + let generation = NEXT_RECORDING.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Recorder { steps: Vec::new(), dropped: 0, bytes: 0, generation, next_seq: 0, seqs: Vec::new() } + } } impl Recorder { @@ -63,28 +78,38 @@ impl Recorder { if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { self.bytes = self.bytes.saturating_add(size); self.steps.push(step); + self.seqs.push(self.next_seq); + self.next_seq = self.next_seq.saturating_add(1); } else { self.dropped = self.dropped.saturating_add(1); } } - /// Where the recording is now, for [`Recorder::cut_back`]. - pub(crate) fn mark(&self) -> (usize, usize) { - (self.steps.len(), self.dropped) + /// Where the recording is now, for [`Recorder::cut_back`]: this recording and the number its + /// next step gets. + pub(crate) fn mark(&self) -> (u64, u64) { + (self.generation, self.next_seq) } /// Takes back the steps recorded in the document with uid `doc` since `mark` (an undo group /// on it was rolled back, so playing them back would make edits it no longer has). Steps in - /// other documents stay: their edits do too. Steps the buffer dropped stay counted. - pub(crate) fn cut_back(&mut self, (len, _): (usize, usize), doc: u64) { - let before = self.steps.len(); - let mut i = 0; - self.steps.retain(|s| { - i += 1; - i <= len || s.doc != doc - }); - if self.steps.len() != before { - self.bytes = self.steps.iter().map(|s| serde_json::to_vec(s).map_or(0, |v| v.len())).sum(); + /// other documents stay: their edits do too. A mark from another recording (the recording + /// started again inside the group) means every step of this one came after it. Steps the + /// buffer dropped stay counted. + pub(crate) fn cut_back(&mut self, (generation, seq): (u64, u64), doc: u64) { + let from = if generation == self.generation { seq } else { 0 }; + let gone = |i: usize, step: &RecordedStep| step.doc == doc && self.seqs.get(i).is_some_and(|&n| n >= from); + if !self.steps.iter().enumerate().any(|(i, s)| gone(i, s)) { + return; } + let (mut steps, mut seqs) = (Vec::with_capacity(self.steps.len()), Vec::with_capacity(self.seqs.len())); + for (i, step) in self.steps.iter().enumerate() { + if !gone(i, step) { + steps.push(step.clone()); + seqs.push(self.seqs.get(i).copied().unwrap_or(0)); + } + } + (self.steps, self.seqs) = (steps, seqs); + self.bytes = self.steps.iter().map(|s| serde_json::to_vec(s).map_or(0, |v| v.len())).sum(); } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 3fa37b100..a9692d45b 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -857,6 +857,34 @@ fn stop_action_runs_only_on_its_own_and_is_not_recorded() { assert_eq!(s.stop_recording().unwrap().dropped, 0); } +#[test] +fn rollbacks_take_back_their_own_recorded_steps_in_any_order() { + // Groups open on two workbooks, each with a recorded edit: rolling back the first takes out + // only its edit, and the second's mark still finds its own after that. + let mut s = s(); + s.start_recording(); + let a = s.active_index(); + let _in_a = s.group_begin("A").unwrap(); + set(&mut s, "A1", "a"); + s.new_workbook(); + let b = s.active_index(); + let _in_b = s.group_begin("B").unwrap(); + set(&mut s, "B1", "b"); + s.set_active(a); + s.execute("automate.stopAction", json!({})).unwrap(); + let cells = |s: &Session| s.recorder.as_ref().unwrap().steps.iter().map(|st| st.params["cell"].clone()).collect::>(); + assert_eq!(cells(&s), [json!("B1")]); + s.set_active(b); + s.execute("automate.stopAction", json!({})).unwrap(); + assert!(cells(&s).is_empty()); + // A recording started again inside a group: every step of it came after the group began. + let _again = s.group_begin("Again").unwrap(); + s.start_recording(); + set(&mut s, "C1", "c"); + s.execute("automate.stopAction", json!({})).unwrap(); + assert!(cells(&s).is_empty(), "{:?}", cells(&s)); +} + #[test] fn a_run_that_edits_and_opens_a_dialog_is_recorded() { // A script with an edit before a menu-style step: the edit happened, so the run is a step of From 7c66aaa2d3349cb8be8d0dc5b614fd9a8e1a80b4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:08:32 -0400 Subject: [PATCH 138/166] Engine: Record Macro takes a name, shortcut and description macro.record checks name, shortcut and description when recording starts and keeps them for the save, so a name that is taken, too long or holds line breaks, or a Ctrl+Shift letter that GridCraft or another macro uses, is refused before anything is recorded. With empty parameters it records under the next MacroN, as before. A name or description given when stopping still wins, and a shortcut that another macro took while recording is dropped. The shortcut rules (macros::parse_shortcut, shortcut_text, shortcut_conflict, RESERVED_SHORTCUTS and check_new_name) live in the engine, so the Record Macro dialog, Macro Options and agents agree. --- crates/engine/src/cmd/macros.rs | 75 ++++++++++++++++++++-------- crates/engine/src/macros/mod.rs | 61 ++++++++++++++++++++++ crates/engine/src/macros/recorder.rs | 10 +++- crates/engine/src/tests/macros.rs | 63 ++++++++++++++++++++++- docs/macros.md | 23 ++++++--- 5 files changed, 202 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 71dfbef67..de1edd367 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Record Macro", ["View", "Macros"], None, - "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, description? (when stopping; a taken name gets a suffix)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", + "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+K\" | \"K\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", has_doc, record ), @@ -211,13 +211,13 @@ fn record(s: &mut Session, p: &Json) -> Result { if bool_param(p, "stop").unwrap_or(false) || s.is_recording() { return stop_recording(s, p); } - start_recording(s, bool_param(p, "relative").unwrap_or(true)) + start_recording(s, p, bool_param(p, "relative").unwrap_or(true)) } -/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `name` and -/// `description` when starting (they name the macro when it stops), `relative` when stopping (it -/// is chosen when recording starts), `stop: false` while recording (the call stops it), anything -/// beside `cancel: true`, and parameters of the wrong kind. +/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `relative` and +/// `shortcut` when stopping (they are chosen when recording starts), `stop: false` while +/// recording (the call stops it), anything beside `cancel: true`, and parameters of the wrong +/// kind. `name` and `description` count when starting and when stopping (stopping wins). fn check_record_params(recording: bool, p: &Json) -> Result<()> { let given = |k: &str| p.get(k).filter(|v| !v.is_null()); for k in ["stop", "cancel", "relative"] { @@ -225,13 +225,13 @@ fn check_record_params(recording: bool, p: &Json) -> Result<()> { return Err(bad("macro.record", format!("`{k}` must be true or false"))); } } - for k in ["name", "description"] { + for k in ["name", "description", "shortcut"] { if given(k).is_some_and(|v| !v.is_string()) { return Err(bad("macro.record", format!("`{k}` must be text"))); } } if given("cancel").and_then(Json::as_bool) == Some(true) { - if let Some(k) = ["stop", "relative", "name", "description"].into_iter().find(|k| given(k).is_some()) { + if let Some(k) = ["stop", "relative", "name", "description", "shortcut"].into_iter().find(|k| given(k).is_some()) { return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored"))); } return Ok(()); @@ -243,22 +243,50 @@ fn check_record_params(recording: bool, p: &Json) -> Result<()> { "a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved", )); } - if recording || stop == Some(true) { - if given("relative").is_some() { - return Err(bad("macro.record", "`relative` is chosen when recording starts")); - } - } else if let Some(k) = ["name", "description"].into_iter().find(|k| given(k).is_some()) { - return Err(bad("macro.record", format!("`{k}` names the macro when recording stops, not when it starts"))); + if (recording || stop == Some(true)) + && let Some(k) = ["relative", "shortcut"].into_iter().find(|k| given(k).is_some()) + { + return Err(bad("macro.record", format!("`{k}` is chosen when recording starts"))); } Ok(()) } -fn start_recording(s: &mut Session, relative: bool) -> Result { - let start = RecordingStart::new(s.doc()?, relative); +/// Starts recording. `name`, `shortcut` and `description` (from the Record Macro dialog) are +/// checked now, so a bad name or a taken shortcut is refused before anything is recorded. +fn start_recording(s: &mut Session, p: &Json, relative: bool) -> Result { + let d = s.doc()?; + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => Some(crate::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?), + None => None, + }; + let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) { + Some(k) => { + let letter = crate::macros::parse_shortcut(k) + .ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?; + if let Some(why) = crate::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) { + return Err(bad("macro.record", why)); + } + Some(crate::macros::shortcut_text(letter)) + } + None => None, + }; + let description = match str_param(p, "description") { + Some(t) if t.chars().count() > gridcraft_model::MAX_MACRO_TEXT => { + return Err(bad("macro.record", format!("`description` can be at most {} characters long", gridcraft_model::MAX_MACRO_TEXT))); + } + Some(t) => Some(t.to_string()), + None => None, + }; + let mut start = RecordingStart::new(d, relative); + (start.name, start.shortcut, start.description) = (name.clone(), shortcut, description); let input = start.selection.current().a1(); s.start_recording(); s.macro_recording = Some(start); - Ok(if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) }) + let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) }; + if let Some(n) = name { + out["name"] = json!(n); + } + Ok(out) } /// Stops recording and saves what was recorded as a macro, in the workbook where recording @@ -295,8 +323,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star // input from the workbook as it is now. let start = start.unwrap_or_else(|| RecordingStart::new(d, true)); let (doc, wb) = (d.uid, d.wb.clone()); - // A name another macro has gets a suffix, so stopping never replaces a macro. - let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + // A name given when stopping wins over the one given when starting; either one, if another + // macro has it, gets a suffix, so the save never replaces a macro. + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) { Some(n) => crate::macros::unique_name(&wb, n), None => crate::macros::next_name(&wb), }; @@ -305,10 +334,16 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star // out and counted with the ones the recorder dropped. let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS)); converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS); - if let Some(desc) = str_param(p, "description") { + if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) { converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect(); } cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac)); + // The shortcut, unless another macro took it while recording. + if let Some(k) = &start.shortcut + && crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) + { + converted.mac.shortcut = Some(k.clone()); + } let steps = converted.mac.steps.len(); let inputs: Vec = converted.mac.inputs.iter().map(|i| i.name.clone()).collect(); // Saved into that workbook even when another one is active now. diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 75d96f0df..f500b9815 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -420,6 +420,67 @@ impl Taken { } } +/// Ctrl+Shift (Cmd+Shift on a Mac) letters that GridCraft already uses, on any platform, which a +/// macro cannot take. +pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[ + ('F', "Format Cells (Font) or Filter"), + ('G', "Workbook Statistics"), + ('H', "Replace"), + ('J', "Ungroup"), + ('K', "Group"), + ('L', "Filter"), + ('R', "Sort"), + ('S', "Save As"), + ('T', "AutoSum"), + ('Z', "Redo"), +]; + +/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"⌘⇧M"` and `"m"` all give `'M'`. `None` +/// when it is not one letter from A to Z. +pub fn parse_shortcut(s: &str) -> Option { + let last = s.trim().rsplit(['+', '⇧', '⌘']).next()?.trim(); + let mut chars = last.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()), + _ => None, + } +} + +/// How a shortcut is stored and shown: `Ctrl+Shift+M`. +pub fn shortcut_text(letter: char) -> String { + format!("Ctrl+Shift+{}", letter.to_ascii_uppercase()) +} + +/// Why macro `name` cannot use Ctrl+Shift+`letter`: GridCraft uses it, or another macro has it. +pub fn shortcut_conflict(wb: &Workbook, name: &str, letter: char) -> Option { + let letter = letter.to_ascii_uppercase(); + if let Some((_, what)) = RESERVED_SHORTCUTS.iter().find(|(c, _)| *c == letter) { + return Some(format!("{} is {what}. Choose another letter.", shortcut_text(letter))); + } + wb.macros + .iter() + .find(|m| !m.name.eq_ignore_ascii_case(name.trim()) && m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)) + .map(|m| format!("{} already runs macro “{}”. Choose another letter.", shortcut_text(letter), m.name)) +} + +/// `name` trimmed, if it can name a new macro in `wb`; otherwise why not: it is empty, too long, +/// holds line breaks or another macro has it. +pub fn check_new_name(wb: &Workbook, name: &str) -> Result { + let name = name.trim(); + let probe = Macro { name: name.to_string(), ..Macro::default() }; + probe.check_limits()?; + if name.chars().any(char::is_control) { + return Err("A macro name cannot hold line breaks or other control characters.".into()); + } + if find(wb, name).is_some() { + return Err(format!("A macro called “{name}” already exists. Choose another name.")); + } + if let Some(kept) = kept_name(wb, name) { + return Err(format!("A macro called “{kept}” is kept from a newer version of GridCraft. Choose another name.")); + } + Ok(name.to_string()) +} + /// The first free `Macro1`, `Macro2`… name, also free among the kept macros ([`kept_name`]). pub fn next_name(wb: &Workbook) -> String { (1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none() && kept_name(wb, n).is_none()).unwrap_or_else(|| "Macro".into()) diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index 53b8edc30..98249cb84 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -32,7 +32,8 @@ use crate::record::RecordedStep; const NEAR_ROWS: u32 = 50; const NEAR_COLS: u32 = 26; -/// Where recording started, kept beside the buffer. +/// Where recording started, kept beside the buffer, with the name, shortcut and description the +/// macro is to be saved with (from the Record Macro dialog), applied when recording stops. #[derive(Clone, Debug)] pub struct RecordingStart { pub doc: u64, @@ -44,6 +45,10 @@ pub struct RecordingStart { /// started: the recorded steps may change its values (text made into numbers), and the /// macro is for data like the data it was recorded on. pub input: Option, + /// The name, shortcut and description given when recording started (Record Macro). + pub name: Option, + pub shortcut: Option, + pub description: Option, } impl RecordingStart { @@ -57,6 +62,9 @@ impl RecordingStart { selection: d.selection.clone(), relative, input: relative.then(|| infer_input(&d.wb, sheet, d.selection.current())), + name: None, + shortcut: None, + description: None, } } } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 459faa406..868356c41 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2077,6 +2077,9 @@ fn kept_macros_count_toward_the_limit_and_keep_their_names() { let err = s.execute(cmd, p).unwrap_err().to_string(); assert!(err.contains("newer version"), "{cmd}: {err}"); } + // Record Macro refuses a kept macro's name when recording starts. + let err = s.execute("macro.record", json!({"name": "future"})).unwrap_err().to_string(); + assert!(err.contains("newer version") && !s.is_recording(), "{err}"); // A recording takes the next free name, around the kept one, and fills the last place. s.execute("macro.record", json!({"relative": false})).unwrap(); set(&mut s, "A1", "typed"); @@ -2318,12 +2321,13 @@ fn a_recording_past_the_macro_size_is_cut_and_counted() { #[test] fn record_refuses_parameters_it_would_ignore() { let mut s = s(); - for p in [json!({"name": "Early"}), json!({"description": "d"}), json!({"relative": "no"}), json!({"stop": 1})] { + // `name`, `description` and `shortcut` count when starting (Record Macro); wrong kinds don't. + for p in [json!({"name": 5}), json!({"shortcut": true}), json!({"relative": "no"}), json!({"stop": 1})] { assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); assert!(!s.is_recording(), "{p}"); } s.execute("macro.record", json!({"relative": false, "name": null})).unwrap(); - for p in [json!({"relative": true}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] { + for p in [json!({"relative": true}), json!({"shortcut": "K"}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] { assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); assert!(s.is_recording(), "{p}"); } @@ -2354,3 +2358,58 @@ fn undo_while_recording_keeps_the_undone_step_as_excel_does() { s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); assert_eq!(v(&s, "A1").display(), "typed"); } + +#[test] +fn record_checks_a_name_shortcut_and_description_when_it_starts() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})).unwrap(); + for (p, needle) in [ + (json!({"name": "taken"}), "already exists"), + (json!({"name": "a\nb"}), "line breaks"), + (json!({"name": "x".repeat(300)}), "at most"), + (json!({"name": "New", "shortcut": "p"}), "already runs macro"), + (json!({"name": "New", "shortcut": "Ctrl+Shift+Z"}), "Redo"), + (json!({"name": "New", "shortcut": "F5"}), "one letter"), + (json!({"name": "New", "description": "d".repeat(100_000)}), "at most"), + ] { + let e = s.execute("macro.record", p.clone()).unwrap_err().to_string(); + assert!(e.contains(needle), "{p}: {e}"); + assert!(!s.is_recording(), "{p}"); + } + let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "j", "description": "Tidies"})); + // J is Ungroup on a Mac, so it is reserved; M is free. + assert!(r.is_err_and(|e| e.to_string().contains("Ungroup"))); + let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "m", "description": "Tidies"})).unwrap(); + assert_eq!(r["name"], json!("Tidy")); + set(&mut s, "A1", "1"); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert_eq!(r["saved"], json!("Tidy")); + let m = s.execute("macro.get", json!({"name": "Tidy"})).unwrap(); + assert_eq!((m["shortcut"].clone(), m["description"].clone()), (json!("Ctrl+Shift+M"), json!("Tidies"))); + // Empty params record under the next MacroN, as before, and never open a dialog. + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "A2", "2"); + let r = s.execute("macro.record", json!({})).unwrap(); + assert!(r["saved"].as_str().is_some_and(|n| n.starts_with("Macro")), "{r}"); + assert!(s.take_ui_requests().is_empty(), "no dialog"); +} + +#[test] +fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() { + use crate::macros::{parse_shortcut, shortcut_conflict, shortcut_text}; + assert_eq!(parse_shortcut("Ctrl+Shift+M"), Some('M')); + assert_eq!(parse_shortcut("⌘⇧k"), Some('K')); + assert_eq!(parse_shortcut(" q "), Some('Q')); + for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é"] { + assert_eq!(parse_shortcut(bad), None, "{bad}"); + } + assert_eq!(shortcut_text('m'), "Ctrl+Shift+M"); + let mut wb = gridcraft_model::Workbook::default(); + wb.macros.push(gridcraft_model::Macro { name: "A".into(), shortcut: Some("Ctrl+Shift+M".into()), ..Default::default() }); + wb.macros.push(gridcraft_model::Macro { name: "B".into(), ..Default::default() }); + assert!(shortcut_conflict(&wb, "B", 's').is_some_and(|m| m.contains("Save As"))); + assert!(shortcut_conflict(&wb, "B", 'm').is_some_and(|m| m.contains("“A”"))); + // A macro keeps its own letter. + assert_eq!(shortcut_conflict(&wb, "a", 'M'), None); + assert_eq!(shortcut_conflict(&wb, "B", 'Q'), None); +} diff --git a/docs/macros.md b/docs/macros.md index 6ab45053e..f23358511 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands) +> **Last updated:** 2026-10-25 · **Change:** minor (Record Macro takes a name, shortcut and description; recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands) 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 @@ -195,12 +195,17 @@ on its own, never as a step of a script or macro, and is never recorded. ## Recording `macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute -references); stopping takes `name` and `description`. A name that another macro has, a kept one -included, gets a suffix, such as `Tidy (2)`, so stopping never replaces a macro. `cancel: true` -ends a recording without saving it, the way out of one whose macro can't be saved. A parameter -the call would ignore is refused: `name` or `description` when starting, `relative` when -stopping, `stop: false` while recording, `stop`, `relative`, `name` or `description` beside -`cancel: true`, and one of the wrong kind. The macro is saved in the workbook where recording started, even if another one is active; +references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's +name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+K"` or `"K"`) and its description. They are +checked at once, so a name that is taken (a kept one included), too long or holds line breaks, or a +letter that GridCraft or another macro uses, is refused before anything is recorded. Without a +name the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when +stopping wins over the one given when starting; a name that another macro has by then gets a +suffix, such as `Tidy (2)`, so stopping never replaces a macro, and a shortcut another macro took +meanwhile is left off. `cancel: true` ends a recording without saving it, the way out of one whose +macro can't be saved. A parameter the call would ignore is refused: `relative` or `shortcut` when +stopping, `stop: false` while recording, anything beside `cancel: true`, and one of the wrong kind. +The macro is saved in the workbook where recording started, even if another one is active; if that workbook was closed, stopping ends the recording without saving it. When saving the recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged in that workbook), recording goes on, so stopping again keeps what was recorded. The person @@ -231,6 +236,10 @@ recording's 64 MB is left out and listed. Undo while recording works as in Excel: `edit.undo` is left out (a macro never runs it), and the step it undid stays in the recording, so playing the recording back makes that edit again. +GridCraft keeps the Ctrl+Shift letters it uses itself on some platform: F, G, H, J, K, L, R, S, T +and Z (`macros::RESERVED_SHORTCUTS`). A shortcut a file brings with it on one of these letters is +kept but does not run the macro. + ## Other commands and storage `macro.list`, `macro.get`, `macro.save {macro, replace?}`, `macro.delete` and `macro.rename From 5512c1a269c21efc7723e1990d9f803189bf92cd Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:21:35 -0400 Subject: [PATCH 139/166] =?UTF-8?q?UI:=20Automate=20=E2=80=BA=20Macros=20g?= =?UTF-8?q?roup,=20Record=20Macro=20and=20Macros=20dialogs,=20recording=20?= =?UTF-8?q?status,=20macro=20shortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Automate tab gains a Macros group: Macros opens the Macros dialog, Record Macro opens the Record Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References) and turns into Stop Recording while recording, Use Relative References sets how the next recording starts, and Run Macro runs the macro run last; its menu lists the recent macros, then every macro. While recording, the status bar shows a red dot, "Recording · N steps" and Stop. The Macros dialog (Automate › Macros, View › Macros, Alt+F8) lists the workbook's macros with their shortcuts, then its Automate scripts, and says when the file carries a VBA project, which is kept and not run. Run, Delete (which asks first) and Options (shortcut letter and description) act on the selected row. The dialog keeps the result of view.macros, so the scripts and the VBA entry are listed as before. A macro that needs approval asks with Run, and one whose output would land on data asks with Overwrite, Shift Down and Shift Right; each button runs the command again with its answer. Ctrl+Shift+letter (Cmd+Shift on a Mac) runs the macro with that shortcut; the letters GridCraft uses keep their meaning. The View › Macros keytips (WMR, WMV) now open the Record Macro and Macros dialogs. Icons for macros, recording and relative references are drawn in code, the recording dot is a theme token covered by the contrast test, and large and small ribbon buttons and icon buttons carry accessible names. --- ROADMAP.md | 6 +- crates/ui-egui/src/dialogs.rs | 54 +- crates/ui-egui/src/grid.rs | 5 +- crates/ui-egui/src/i18n/cs.rs | 7 + crates/ui-egui/src/i18n/de.rs | 8 + crates/ui-egui/src/i18n/el.rs | 6 + crates/ui-egui/src/i18n/es.rs | 8 + crates/ui-egui/src/i18n/fr.rs | 8 + crates/ui-egui/src/i18n/it.rs | 6 + crates/ui-egui/src/i18n/ja.rs | 6 + crates/ui-egui/src/i18n/ko.rs | 8 + crates/ui-egui/src/i18n/nl.rs | 7 + crates/ui-egui/src/i18n/pl.rs | 6 + crates/ui-egui/src/i18n/pt.rs | 6 + crates/ui-egui/src/i18n/ro.rs | 6 + crates/ui-egui/src/i18n/ru.rs | 8 + crates/ui-egui/src/i18n/sr.rs | 8 + crates/ui-egui/src/i18n/tr.rs | 8 + crates/ui-egui/src/i18n/uk.rs | 6 + crates/ui-egui/src/i18n/vi.rs | 8 + crates/ui-egui/src/i18n/zh.rs | 8 + crates/ui-egui/src/icons.rs | 26 +- .../src/keytips/keytip_table/generated.rs | 4 +- crates/ui-egui/src/keytips/keytips.json | 12 +- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/macro_ui.rs | 601 ++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 63 +- crates/ui-egui/src/tabs.rs | 1 + crates/ui-egui/src/theme.rs | 6 + crates/ui-egui/src/widgets.rs | 32 + crates/ui-egui/tests/it/keytips_generated.rs | 4 +- crates/ui-egui/tests/it/macros_ui.rs | 251 ++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/macros.md | 26 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 9 +- 36 files changed, 1171 insertions(+), 69 deletions(-) create mode 100644 crates/ui-egui/src/macro_ui.rs create mode 100644 crates/ui-egui/tests/it/macros_ui.rs diff --git a/ROADMAP.md b/ROADMAP.md index 995720e80..aac13c874 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (macro engine and `macro.*` commands) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -86,7 +86,7 @@ collaboration, mobile, AI). | Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Automation (recorded scripts; typed macros in the engine, no interface yet; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| Automation (recorded scripts; typed macros with a recorder and a Macros dialog; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | ## Languages @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | @@ -236,6 +237,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7b069e597..ec496d95e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -161,7 +161,7 @@ impl Dialog { } } - fn custom(name: &str, title: &str, defaults: Json) -> Dialog { + pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog { Dialog { name: name.into(), title: title.into(), @@ -906,11 +906,8 @@ impl Dialog { ) } "journal" => Dialog::custom("journal", "Action Journal", json!({})), - "macros" => { - let mut d = Dialog::custom("macros", "Macros", json!({})); - d.result = app.session.run("view.macros", json!({})).ok(); - d - } + "macros" => crate::macro_ui::open_dialog(app, ¶ms), + "recordMacro" => return crate::macro_ui::open_record(app, ¶ms), "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -1262,7 +1259,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let width = match d.name.as_str() { "formatCells" => 560.0, "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "macros" => 480.0, "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, "solver" => 460.0, @@ -1322,7 +1320,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } - "macros" => macros(app, ui, &mut d), + "macros" => crate::macro_ui::macros_dialog(app, ui, &mut d, &mut open), + "recordMacro" => crate::macro_ui::record_dialog(app, ui, &mut d, &mut open), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -1885,45 +1884,6 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { } } -/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the -/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is -/// preserved and not executed. -fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); - ui.add_space(6.0); - let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); - if items.is_empty() { - ui.weak("This workbook has no scripts or macros."); - return; - } - // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { - ui.horizontal(|ui| { - ui.weak("Scripts in this workbook came from a file and are disabled."); - if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { - let _ = app.run("automate.trustScripts", json!({"trusted": true})); - d.result = app.session.run("view.macros", json!({})).ok(); - } - }); - ui.add_space(6.0); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for it in &items { - let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); - let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(name).strong()); - if is_vba { - ui.weak("— VBA project (preserved, not run)"); - } else { - let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); - ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); - } - }); - } - }); -} - fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index c4c3888dc..91b3b571c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3006,7 +3006,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an - // arrow key would move the selection the menu fills from). + // arrow key would move the selection the menu fills from), and a macro's question. if editing || app.dialog.is_some() || other_focus @@ -3014,6 +3014,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: || app.grid.filter_menu.is_some() || app.grid.fill_menu.is_some() || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + || app.macros.question.is_some() { return; } @@ -3127,6 +3128,8 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} + // Ctrl+Shift+letter (⌘⇧letter) runs the macro with that shortcut, unless GridCraft uses it. + _ if cmd && shift && !m.alt && crate::macro_ui::run_shortcut(app, key) => {} _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { crate::ribbon::shortcut(app, key, m) } diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 1e8e1a77a..09a884a55 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -548,6 +548,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "AutoSave" => "Automatické ukládání", "AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)", "All sheets" => "Všechny listy", + "Record Macro" => "Záznam makra", + "Stop Recording" => "Zastavit záznam", + "Use Relative References" => "Použít relativní odkazy", + "Run\nMacro" => "Spustit makro", + "Macros…" => "Makra…", + "Recording" => "Záznam", + "Stop" => "Zastavit", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0a5116757..c61a74df8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -452,6 +452,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Nächstes Blatt", "All sheets" => "Alle Blätter", "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", + "Macros" => "Makros", + "Record Macro" => "Makro aufzeichnen", + "Stop Recording" => "Aufzeichnung beenden", + "Use Relative References" => "Relative Verweise verwenden", + "Run\nMacro" => "Makro ausführen", + "Macros…" => "Makros…", + "Recording" => "Aufzeichnung", + "Stop" => "Beenden", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index ae8320c59..d7cc1eb93 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -978,6 +978,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { "Ένα αρχείο CSV, μια ιστοσελίδα ή ένα PDF είναι αντίγραφο: το βιβλίο εργασίας διατηρεί το δικό του αρχείο." } + "Record Macro" => "Καταγραφή μακροεντολής", + "Stop Recording" => "Διακοπή καταγραφής", + "Use Relative References" => "Χρήση σχετικών αναφορών", + "Run\nMacro" => "Εκτέλεση μακροεντολής", + "Macros…" => "Μακροεντολές…", + "Recording" => "Καταγραφή", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 79352bb9a..262c6bb61 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -468,6 +468,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Hoja siguiente", "All sheets" => "Todas las hojas", "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", + "Macros" => "Macros", + "Record Macro" => "Grabar macro", + "Stop Recording" => "Detener grabación", + "Use Relative References" => "Usar referencias relativas", + "Run\nMacro" => "Ejecutar macro", + "Macros…" => "Macros…", + "Recording" => "Grabando", + "Stop" => "Detener", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index d422fb900..2d57f57bd 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -438,6 +438,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Feuille suivante", "All sheets" => "Toutes les feuilles", "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", + "Macros" => "Macros", + "Record Macro" => "Enregistrer une macro", + "Stop Recording" => "Arrêter l’enregistrement", + "Use Relative References" => "Utiliser les références relatives", + "Run\nMacro" => "Exécuter la macro", + "Macros…" => "Macros…", + "Recording" => "Enregistrement", + "Stop" => "Arrêter", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c881ef1e4..c00178b13 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -981,6 +981,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Layout rapporto", "Compact" => "Compatto", "Tabular" => "Tabulare", + "Record Macro" => "Registra macro", + "Stop Recording" => "Interrompi registrazione", + "Use Relative References" => "Usa riferimenti relativi", + "Run\nMacro" => "Esegui macro", + "Macros…" => "Macro…", + "Recording" => "Registrazione", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 84b3866ad..162d82104 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -415,6 +415,12 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Workbook calculation", "ブックの計算"), ("Enable iterative calculation", "反復計算を行う"), ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), + ("Record Macro", "マクロの記録"), + ("Stop Recording", "記録終了"), + ("Use Relative References", "相対参照で記録"), + ("Run\nMacro", "マクロの実行"), + ("Macros…", "マクロ…"), + ("Recording", "記録中"), ]; /// English label → Japanese for the dialog, message, Backstage and menu strings beyond [`CORE`]. diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index d9611ed11..5f0ffc983 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -436,6 +436,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "다음 시트", "All sheets" => "모든 시트", "New sheet (⇧F11)" => "새 시트 (⇧F11)", + "Macros" => "매크로", + "Record Macro" => "매크로 기록", + "Stop Recording" => "기록 중지", + "Use Relative References" => "상대 참조로 기록", + "Run\nMacro" => "매크로 실행", + "Macros…" => "매크로…", + "Recording" => "기록 중", + "Stop" => "중지", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 13c717828..3afb4cf60 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -522,6 +522,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Zoom In" => "Inzoomen", "Zoom Out" => "Uitzoomen", "Dismiss" => "Negeren", + "Record Macro" => "Macro opnemen", + "Stop Recording" => "Opname stoppen", + "Use Relative References" => "Relatieve verwijzingen gebruiken", + "Run\nMacro" => "Macro uitvoeren", + "Macros…" => "Macro's…", + "Recording" => "Opnemen", + "Stop" => "Stoppen", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index c63206800..0e82acde5 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -972,6 +972,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Układ raportu", "Compact" => "Kompaktowy", "Tabular" => "Tabelaryczny", + "Record Macro" => "Zarejestruj makro", + "Stop Recording" => "Zatrzymaj rejestrowanie", + "Use Relative References" => "Użyj odwołań względnych", + "Run\nMacro" => "Uruchom makro", + "Macros…" => "Makra…", + "Recording" => "Rejestrowanie", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 3d26f2a29..536eeb823 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -973,6 +973,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)" } "All sheets" => "Todas as planilhas", + "Record Macro" => "Gravar Macro", + "Stop Recording" => "Parar Gravação", + "Use Relative References" => "Usar Referências Relativas", + "Run\nMacro" => "Executar Macro", + "Macros…" => "Macros…", + "Recording" => "Gravando", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index f9e732cf8..469da8dca 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1034,6 +1034,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format Chart Area…" => "Formatare zonă diagramă…", "Error checking options" => "Opțiuni de verificare a erorilor", "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate", + "Record Macro" => "Înregistrare macrocomandă", + "Stop Recording" => "Oprire înregistrare", + "Use Relative References" => "Utilizare referințe relative", + "Run\nMacro" => "Rulare macrocomandă", + "Macros…" => "Macrocomenzi…", + "Recording" => "Înregistrare", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 184f53412..e87536886 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -438,6 +438,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следующий лист", "All sheets" => "Все листы", "New sheet (⇧F11)" => "Новый лист (⇧F11)", + "Macros" => "Макросы", + "Record Macro" => "Запись макроса", + "Stop Recording" => "Остановить запись", + "Use Relative References" => "Относительные ссылки", + "Run\nMacro" => "Выполнить макрос", + "Macros…" => "Макросы…", + "Recording" => "Запись", + "Stop" => "Остановить", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 31f62a5ef..e10ab9c2f 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -436,6 +436,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следећи лист", "All sheets" => "Сви листови", "New sheet (⇧F11)" => "Нови лист (⇧F11)", + "Macros" => "Makroi", + "Record Macro" => "Snimi makro", + "Stop Recording" => "Zaustavi snimanje", + "Use Relative References" => "Koristi relativne reference", + "Run\nMacro" => "Pokreni makro", + "Macros…" => "Makroi…", + "Recording" => "Snimanje", + "Stop" => "Zaustavi", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 042ccdb70..daa951fdc 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -410,6 +410,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Koyu", "Sheet Options|View" => "Görünüm", "Print" => "Yazdır", + "Macros" => "Makrolar", + "Record Macro" => "Makro Kaydet", + "Stop Recording" => "Kaydı Durdur", + "Use Relative References" => "Göreli Başvuruları Kullan", + "Run\nMacro" => "Makro Çalıştır", + "Macros…" => "Makrolar…", + "Recording" => "Kaydediliyor", + "Stop" => "Durdur", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 466795365..334bbb6dd 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -976,6 +976,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Report Layout", "Compact" => "Compact", "Tabular" => "Tabular", + "Record Macro" => "Записати макрос", + "Stop Recording" => "Зупинити запис", + "Use Relative References" => "Використовувати відносні посилання", + "Run\nMacro" => "Запустити макрос", + "Macros…" => "Макроси…", + "Recording" => "Запис", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 920e75732..31d825881 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -412,6 +412,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Tính toán sổ làm việc", "Enable iterative calculation" => "Bật tính toán lặp", "Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.", + "Macros" => "Macro", + "Record Macro" => "Ghi Macro", + "Stop Recording" => "Dừng Ghi", + "Use Relative References" => "Dùng Tham chiếu Tương đối", + "Run\nMacro" => "Chạy Macro", + "Macros…" => "Macro…", + "Recording" => "Đang ghi", + "Stop" => "Dừng", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 8ead92a6c..a1b4179cc 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -428,6 +428,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "下一个工作表", "All sheets" => "所有工作表", "New sheet (⇧F11)" => "新建工作表 (⇧F11)", + "Macros" => "宏", + "Record Macro" => "录制宏", + "Stop Recording" => "停止录制", + "Use Relative References" => "使用相对引用", + "Run\nMacro" => "运行宏", + "Macros…" => "宏…", + "Recording" => "正在录制", + "Stop" => "停止", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7667ddd56..43ddf3baa 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -135,6 +135,12 @@ pub enum Icon { OpenFolder, Info, Print, + /// A macro: a script page with a run arrow. + Macro, + /// Record Macro: a ring around the recording dot. + Record, + /// Use Relative References: a cell and an arrow to a cell offset from it. + Relative, } struct G<'a> { @@ -194,11 +200,12 @@ impl G<'_> { struct Palette { paper: Color32, cat: [Color32; 8], + recording: Color32, } impl Palette { fn of(t: &Tokens) -> Palette { - Palette { paper: t.paper, cat: t.cat } + Palette { paper: t.paper, cat: t.cat, recording: t.recording } } fn cat(&self, c: Cat) -> Color32 { @@ -747,6 +754,23 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1); g.circle(12.3, 7.6, 0.6, Some(c), None); } + Icon::Macro => { + g.rect(2.5, 1.5, 9.0, 12.0, Some(paper), Some(c)); + for y in [4.5, 7.0, 9.5] { + g.stroke(&[(4.5, y), (9.5, y)], light); + } + g.poly(&[(9.5, 8.5), (15.0, 11.75), (9.5, 15.0)], green); + } + Icon::Record => { + g.circle(8.0, 8.0, 6.0, None, Some(c)); + g.circle(8.0, 8.0, 3.5, Some(pal.recording), None); + } + Icon::Relative => { + g.rect(1.5, 1.5, 5.5, 5.0, Some(paper), Some(c)); + g.rect(9.0, 9.5, 5.5, 5.0, None, Some(c)); + g.line(&[(5.0, 7.5), (5.0, 12.0), (8.0, 12.0)], c, 1.1); + g.poly(&[(7.5, 10.2), (9.6, 12.0), (7.5, 13.8)], c); + } } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c35a3bbad..e74c5aed3 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -342,8 +342,8 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param) ("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal) ("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros) - ("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu) - ("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros) + ("WMR", Entry::Finish(Action::Dialog("recordMacro"))), // Excel: Record Macro; Record Macro dialog (stops recording while recording) + ("WMV", Entry::Finish(Action::Dialog("macros"))), // Excel: View Macros; containment(Macros): the Macros dialog ("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window ("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View ("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5be64f867..50cc182c4 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2370,17 +2370,17 @@ }, { "seq": "WMR", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "recordMacro", "excel": "Record Macro", - "note": "Record Macro (macros menu)" + "note": "Record Macro dialog (stops recording while recording)" }, { "seq": "WMV", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "macros", "excel": "View Macros", - "note": "containment(Macros)" + "note": "containment(Macros): the Macros dialog" }, { "seq": "WN", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9bea918dc..a4e827de4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; +pub mod macro_ui; pub mod outline; pub mod panes; pub mod paper_view; @@ -301,6 +302,8 @@ pub struct SheetApp { /// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on). /// Enter on that cell goes to the next row in the starting column, as in Excel (#911). tab_run: Option<(u64, usize, u32, CellRef)>, + /// Automate › Macros: relative recording, recent macros, a macro's question. + pub macros: macro_ui::MacroUi, pub started: f64, /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. @@ -349,6 +352,7 @@ impl SheetApp { clipboard_text_at_copy: None, cut_text: None, tab_run: None, + macros: macro_ui::MacroUi::default(), started: now_ms(), allow_close: false, } @@ -1326,6 +1330,7 @@ impl SheetApp { }); } dialogs::show(self, &ctx); + macro_ui::question_box(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); control::issue_screenshots(self, &ctx); @@ -1368,6 +1373,7 @@ impl SheetApp { let enabled = self.editor.is_none() && self.dialog.is_none() && self.message.is_none() + && self.macros.question.is_none() && self.name_box.is_none() && !ctx.text_edit_focused() && self.grid.context_menu.is_none() diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs new file mode 100644 index 000000000..2e650522e --- /dev/null +++ b/crates/ui-egui/src/macro_ui.rs @@ -0,0 +1,601 @@ +//! Automate › Macros in the window: recording (with "Recording · 7 steps" and Stop in the status +//! bar), running a macro and answering its questions (approve it, make room for its output), the +//! Macros dialog (macros, Automate scripts and a file's VBA project), Macro Options, the Record +//! Macro dialog and macro shortcuts (Ctrl+Shift+letter, ⌘⇧letter on a Mac). Everything goes +//! through the `macro.*` and `automate.*` commands. + +use egui::{Sense, Ui, vec2}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::dialogs::Dialog; +use crate::theme::{self, Tokens}; + +/// Shortcut rules live in the engine, so the dialogs and `macro.record` agree. +pub use gridcraft_engine::macros::{RESERVED_SHORTCUTS, parse_shortcut, shortcut_conflict, shortcut_text}; + +/// How many macros the Run Macro menu keeps as recent. +const RECENT: usize = 8; + +/// Macro state that belongs to the window, not the workbook. +#[derive(Clone, Debug)] +pub struct MacroUi { + /// Use Relative References: record steps relative to the selection when recording started. + pub relative: bool, + /// Macros run from this window, most recent first (Run Macro's menu). + pub recent: Vec, + /// A question from a macro command, answered with one of its buttons. + pub question: Option, +} + +impl Default for MacroUi { + fn default() -> Self { + MacroUi { relative: true, recent: vec![], question: None } + } +} + +impl MacroUi { + fn note_recent(&mut self, name: &str) { + self.recent.retain(|n| !n.eq_ignore_ascii_case(name)); + self.recent.insert(0, name.to_string()); + self.recent.truncate(RECENT); + } +} + +/// A question from a command (approve this macro? overwrite the data in the way?). Each choice +/// runs the command again with its parameters added; Cancel drops it. +#[derive(Clone, Debug)] +pub struct Question { + pub title: String, + pub text: String, + pub command: String, + pub params: Json, + pub choices: Vec<(String, Json)>, +} + +/// The macros of the active workbook: (name, description, shortcut). +pub fn macro_list(app: &SheetApp) -> Vec<(String, String, Option)> { + app.session + .active() + .map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone(), m.shortcut.clone())).collect()) + .unwrap_or_default() +} + +/// How many commands the recorder has captured, while recording. +pub fn recorded_steps(app: &SheetApp) -> Option { + app.session.recorder.as_ref().map(|r| r.steps.len().saturating_add(r.dropped)) +} + +/// Record Macro / Stop Recording: opens the Record Macro dialog, or stops and saves what was +/// recorded as a macro. +pub fn toggle_recording(app: &mut SheetApp) { + if app.session.is_recording() { + stop_recording(app); + } else { + app.open_dialog("recordMacro", json!({})); + } +} + +/// Stops recording and says what was saved. +pub fn stop_recording(app: &mut SheetApp) { + match app.run("macro.record", json!({"stop": true})) { + Ok(v) => match v["saved"].as_str() { + Some(name) => { + let steps = v["steps"].as_u64().unwrap_or(0); + app.macros.note_recent(name); + app.toast = Some((format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }), crate::now_ms())); + } + None => app.toast = Some(("Recording stopped".into(), crate::now_ms())), + }, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } +} + +/// Runs macro `name` on the selection; its questions (approve, make room) become a prompt. +pub fn run_macro(app: &mut SheetApp, name: &str) { + call(app, "Run Macro", "macro.run", json!({"name": name})); +} + +/// Runs a macro command. Errors are shown; a question (approve it, overwrite or move the data in +/// the way) becomes a prompt whose buttons run it again. `Some` with the result when it ran. +pub fn call(app: &mut SheetApp, title: &str, id: &str, params: Json) -> Option { + match app.run(id, params.clone()) { + Ok(v) => { + note_result(app, id, ¶ms, &v); + Some(v) + } + Err(e) => { + if !ask(app, title, id, ¶ms, &e) { + app.message = Some((title.into(), crate::clean_error(&e))); + } + None + } + } +} + +/// The question in an engine error that asks something: `approve: …` or `confirm: …`. +fn question_text(e: &str, prefix: &str) -> Option { + let rest = e.split_once(prefix)?.1; + Some(rest.split_once(" (run again with").map(|(q, _)| q).unwrap_or(rest).to_string()) +} + +/// Turns a question from command `id` into a prompt whose buttons run it again: Run for a macro +/// that needs approval; Overwrite, Shift Down or Shift Right for a macro whose output would land +/// on data; OK (`overwrite: true`) for anything else. False when `e` is not a question. +pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> bool { + let (text, choices) = if let Some(q) = question_text(e, "approve: ") { + (q, vec![("Run".to_string(), json!({"approve": true}))]) + } else if let Some(q) = question_text(e, "confirm: ") { + if id.starts_with("macro.") { + // The engine's hint for programs ("run again with onConflict …") becomes the buttons. + let what = q.split_once(" Overwrite them").map(|(a, _)| a).unwrap_or(&q).trim_end_matches(['.', ' ']); + ( + format!("{what}. Overwrite them, or move the data out of the way?"), + vec![ + ("Overwrite".to_string(), json!({"onConflict": "overwrite"})), + ("Shift Down".to_string(), json!({"onConflict": "shiftDown"})), + ("Shift Right".to_string(), json!({"onConflict": "shiftRight"})), + ], + ) + } else { + (q, vec![("OK".to_string(), json!({"overwrite": true}))]) + } + } else { + return false; + }; + app.macros.question = Some(Question { title: title.into(), text, command: id.into(), params: params.clone(), choices }); + true +} + +/// After a macro command ran (directly or from a prompt's button): remember the macro and say +/// what it did. +pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { + if id != "macro.run" { + return; + } + let name = v["macro"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string(); + if name.is_empty() { + return; + } + app.macros.note_recent(&name); + let ran = v["ran"].as_u64().unwrap_or(0); + let text = match v["stopped"].as_str() { + Some(msg) => format!("Macro “{name}” stopped: {msg}"), + None => format!("Ran macro “{name}” ({ran} step{})", if ran == 1 { "" } else { "s" }), + }; + app.toast = Some((text, crate::now_ms())); +} + +/// The prompt for [`MacroUi::question`]: its text, a button for each choice and Cancel. +pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { + if app.message.is_some() { + return; + } + let Some(q) = app.macros.question.clone() else { return }; + let mut answer: Option> = None; + egui::Modal::new(egui::Id::new("gridcraft.macro_question")).show(ctx, |ui| { + ui.set_width(400.0); + ui.label(egui::RichText::new(&q.title).strong()); + ui.add(egui::Label::new(&q.text).wrap()); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + // Right to left: list the choices reversed, so the first one ends up next to Cancel + // and Enter picks it. + for (k, (label, extra)) in q.choices.iter().enumerate().rev() { + if ui.add(egui::Button::new(label.as_str()).min_size(vec2(72.0, 0.0))).clicked() + || (k == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + answer = Some(Some(extra.clone())); + } + } + }); + }); + match answer { + Some(Some(extra)) => { + app.macros.question = None; + let mut params = q.params.clone(); + if let (Some(m), Some(extra)) = (params.as_object_mut(), extra.as_object()) { + m.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + // A second question (approve, then make room) asks again. + call(app, &q.title, &q.command, params); + } + Some(None) => app.macros.question = None, + None => {} + } +} + +/// The recording indicator in the status bar: a red dot, "Recording · 7 steps" and Stop. +pub fn status(app: &mut SheetApp, ui: &mut Ui) { + let Some(n) = recorded_steps(app) else { return }; + let t = Tokens::get(ui.ctx()); + ui.add_space(12.0); + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, t.recording); + let text = format!("{} · {n} step{}", app.ui.language.tr("Recording"), if n == 1 { "" } else { "s" }); + ui.label(egui::RichText::new(text).font(theme::ui_font(12.5)).color(t.text)); + let stop = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Stop")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0))); + if stop.on_hover_text("Stop recording and save the macro").clicked() { + stop_recording(app); + } +} + +// ---------------------------------------------------------------- shortcuts + +/// The macro whose shortcut is Ctrl+Shift+`key` in the active workbook. GridCraft's own +/// shortcuts win over a macro's (a file can carry any letter). +pub fn shortcut_macro(app: &SheetApp, key: egui::Key) -> Option { + let letter = parse_shortcut(key.name())?; + if RESERVED_SHORTCUTS.iter().any(|(c, _)| *c == letter) { + return None; + } + let wb = &app.session.active()?.wb; + wb.macros.iter().find(|m| m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)).map(|m| m.name.clone()) +} + +/// Ctrl+Shift+letter on the grid: runs the macro with that shortcut. True when one ran. +pub fn run_shortcut(app: &mut SheetApp, key: egui::Key) -> bool { + let Some(name) = shortcut_macro(app, key) else { return false }; + run_macro(app, &name); + true +} + +// ---------------------------------------------------------------- Macros dialog + +/// What a row of the Macros dialog is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Macro, + /// An Automate script (`automate.*`). + Script, +} + +impl Kind { + fn key(self) -> &'static str { + match self { + Kind::Macro => "macro", + Kind::Script => "script", + } + } +} + +/// A row of the Macros dialog. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub name: String, + pub kind: Kind, + pub description: String, + pub shortcut: Option, + pub steps: u64, +} + +/// The Macros dialog's rows: the workbook's macros, then its Automate scripts (from +/// `view.macros`, kept in the dialog's result), and whether the file carries a VBA project. +pub fn entries(app: &SheetApp, d: &Dialog) -> (Vec, bool) { + let mut out: Vec = app + .session + .active() + .map(|doc| { + doc.wb + .macros + .iter() + .map(|m| Entry { + name: m.name.clone(), + kind: Kind::Macro, + description: m.description.clone(), + shortcut: m.shortcut.clone(), + steps: m.step_count() as u64, + }) + .collect() + }) + .unwrap_or_default(); + let mut vba = false; + for it in d.result.as_ref().and_then(Json::as_array).into_iter().flatten() { + if it.get("kind").and_then(Json::as_str) == Some("vba") { + vba = true; + continue; + } + let Some(name) = it.get("name").and_then(Json::as_str) else { continue }; + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + out.push(Entry { name: name.to_string(), kind: Kind::Script, description: String::new(), shortcut: None, steps }); + } + (out, vba) +} + +/// The Macros dialog (View › Macros, Automate › Macros, Alt+F8). Its result holds `view.macros` +/// (the Automate scripts and a file's VBA project), as before macros existed. +pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog { + let names = macro_list(app); + let first = app.macros.recent.iter().find(|r| names.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned(); + let name = params["name"].as_str().map(str::to_string).or(first).or_else(|| names.first().map(|m| m.0.clone())).unwrap_or_default(); + let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro"})); + d.result = app.session.run("view.macros", json!({})).ok(); + d +} + +/// The Macros dialog's body: the name box and the list on the left, the buttons on the right, the +/// description under them; Options… switches to the shortcut and description of one macro. +pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + // Scripts can change while the dialog is open (Delete): read them again when the workbook does. + let rev = app.session.active().map(|doc| doc.revision).unwrap_or(0); + if d.values.get("revision").and_then(Json::as_u64) != Some(rev) { + d.result = app.session.run("view.macros", json!({})).ok(); + d.values.insert("revision".into(), json!(rev)); + } + if d.values.get("options").and_then(Json::as_bool) == Some(true) { + options(app, ui, d); + return; + } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + let untrusted = d.result.as_ref().and_then(Json::as_array).is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false))); + if untrusted { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } + let (list, vba) = entries(app, d); + let mut name = d.values.get("name").and_then(Json::as_str).unwrap_or("").to_string(); + let mut kind = if d.values.get("kind").and_then(Json::as_str) == Some("script") { Kind::Script } else { Kind::Macro }; + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(330.0); + ui.label("Macro name:"); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).changed() { + kind = Kind::Macro; + } + egui::Frame::NONE.fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| { + ui.set_min_size(vec2(326.0, 200.0)); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + if list.is_empty() { + ui.label(egui::RichText::new("This workbook has no scripts or macros.").italics().color(t.text_dim)); + } + for e in &list { + let label = match (e.kind, &e.shortcut) { + (Kind::Macro, Some(k)) => format!("{} {k}", e.name), + (Kind::Macro, None) => e.name.clone(), + (Kind::Script, _) => format!("{} (script, {} step{})", e.name, e.steps, if e.steps == 1 { "" } else { "s" }), + }; + let selected = e.kind == kind && e.name.eq_ignore_ascii_case(name.trim()); + let r = ui.add(egui::Button::selectable(selected, label).min_size(vec2(322.0, 20.0))); + if r.clicked() { + name = e.name.clone(); + kind = e.kind; + } + if r.double_clicked() { + *open = false; + run_entry(app, e); + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(18.0); + let w = vec2(100.0, 24.0); + let target = name.trim().to_string(); + let found = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(&target)).cloned(); + let is_macro = found.as_ref().is_some_and(|e| e.kind == Kind::Macro); + if ui.add_enabled(found.is_some(), egui::Button::new("Run").min_size(w)).clicked() + && let Some(e) = &found + { + *open = false; + run_entry(app, e); + } + if ui.add_enabled(found.is_some(), egui::Button::new("Delete").min_size(w)).clicked() + && let Some(e) = &found + { + let (title, command, what) = match e.kind { + Kind::Macro => ("Delete Macro", "macro.delete", "macro"), + Kind::Script => ("Delete Script", "automate.deleteScript", "script"), + }; + app.macros.question = Some(Question { + title: title.into(), + text: format!("Do you want to delete {what} “{}”?", e.name), + command: command.into(), + params: json!({"name": e.name}), + choices: vec![("Delete".into(), json!({}))], + }); + } + if ui.add_enabled(is_macro, egui::Button::new("Options…").min_size(w)).clicked() + && let Some(e) = &found + { + let key = e.shortcut.as_deref().and_then(parse_shortcut).map(String::from).unwrap_or_default(); + d.values.insert("options".into(), json!(true)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(e.description)); + } + ui.add_space(12.0); + if ui.add(egui::Button::new("Cancel").min_size(w)).clicked() { + *open = false; + } + }); + }); + ui.add_space(6.0); + ui.label("Description"); + let desc = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(name.trim())).map(|e| e.description.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(if desc.is_empty() { " ".to_string() } else { desc }).color(t.text_dim)); + if vba { + ui.add_space(6.0); + ui.label( + egui::RichText::new("This workbook also has a VBA project. GridCraft keeps it when you save but does not run it.").color(t.text_dim), + ); + } + d.values.insert("name".into(), json!(name)); + d.values.insert("kind".into(), json!(kind.key())); +} + +/// Runs a row of the Macros dialog: a macro on the selection, or an Automate script. +fn run_entry(app: &mut SheetApp, e: &Entry) { + match e.kind { + Kind::Macro => run_macro(app, &e.name), + Kind::Script => { + call(app, "Run Script", "automate.runScript", json!({"name": e.name})); + } + } +} + +/// Macro Options: the shortcut letter and the description of the selected macro. +fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) { + let name = d.values.get("name").and_then(Json::as_str).unwrap_or("").trim().to_string(); + ui.label(egui::RichText::new(format!("Macro name: {name}")).strong()); + ui.add_space(4.0); + ui.label("Shortcut key:"); + let mut key = d.values.get("shortcut").and_then(Json::as_str).unwrap_or("").to_string(); + shortcut_field(ui, &mut key, "macro_shortcut"); + let letter = parse_shortcut(&key); + let conflict = match (key.trim().is_empty(), letter, app.session.active()) { + (true, ..) => None, + (false, None, _) => Some("Use a letter from A to Z.".to_string()), + (false, Some(c), Some(doc)) => shortcut_conflict(&doc.wb, &name, c), + (false, Some(_), None) => None, + }; + if let Some(c) = &conflict { + ui.label(egui::RichText::new(c).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(4.0); + ui.label("Description:"); + let mut desc = d.values.get("description").and_then(Json::as_str).unwrap_or("").to_string(); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("macro_description")); + ui.add_space(6.0); + let mut done = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + done = true; + } + if ui.add_enabled(conflict.is_none(), egui::Button::new("OK")).clicked() { + done = save_options(app, &name, letter, &desc); + } + }); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); + if done { + d.values.remove("options"); + } +} + +/// The shortcut letter box, after "Ctrl+Shift+" (or "⌘⇧" on a Mac). Keeps one capital letter. +fn shortcut_field(ui: &mut Ui, key: &mut String, id: &str) { + ui.horizontal(|ui| { + ui.label(if ui.ctx().os() == egui::os::OperatingSystem::Mac { "⌘ ⇧ +" } else { "Ctrl+Shift+" }); + ui.add(egui::TextEdit::singleline(key).char_limit(1).desired_width(24.0).id_salt(id)); + }); + *key = key.to_ascii_uppercase(); +} + +/// Saves a macro's shortcut and description. True when saved (otherwise the error is shown). +pub fn save_options(app: &mut SheetApp, name: &str, letter: Option, description: &str) -> bool { + let mut m = match app.run("macro.get", json!({"name": name})) { + Ok(m) if m.is_object() => m, + Ok(_) => return false, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + return false; + } + }; + if let Some(o) = m.as_object_mut() { + match letter { + Some(c) => o.insert("shortcut".into(), json!(shortcut_text(c))), + None => o.remove("shortcut"), + }; + o.insert("description".into(), json!(description)); + } + match app.run("macro.save", json!({"macro": m})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + false + } + } +} + +// ---------------------------------------------------------------- Record Macro dialog + +/// Record Macro: the macro's name, shortcut and description before recording starts. While +/// recording, it stops instead (View › Macros › Record Macro toggles, as in Excel). +pub fn open_record(app: &mut SheetApp, params: &Json) -> Option { + if app.session.is_recording() { + stop_recording(app); + return None; + } + let suggested = app.session.active().map(|d| gridcraft_engine::macros::next_name(&d.wb)).unwrap_or_else(|| "Macro1".into()); + let name = params["name"].as_str().map(str::to_string).filter(|n| !n.trim().is_empty()).unwrap_or(suggested); + Some(Dialog::custom("recordMacro", "Record Macro", json!({"name": name, "shortcut": "", "description": ""}))) +} + +/// What is wrong with the Record Macro dialog's name or shortcut, if anything. +pub fn record_problem(app: &SheetApp, name: &str, key: &str) -> Option { + let doc = app.session.active()?; + if let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, name) { + return Some(e); + } + if key.trim().is_empty() { + return None; + } + match parse_shortcut(key) { + None => Some("Use a letter from A to Z for the shortcut.".into()), + Some(c) => shortcut_conflict(&doc.wb, name, c), + } +} + +/// The Record Macro dialog's body. OK starts recording; it stays off while the name or the +/// shortcut is not usable, and the reason shows under the fields. +pub fn record_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + let get = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let mut name = get(d, "name"); + let mut key = get(d, "shortcut"); + let mut desc = get(d, "description"); + ui.label("Macro name:"); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")); + ui.add_space(4.0); + ui.label("Shortcut key:"); + shortcut_field(ui, &mut key, "record_shortcut"); + ui.add_space(4.0); + ui.label("Description:"); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")); + ui.checkbox(&mut app.macros.relative, "Use Relative References"); + let problem = record_problem(app, &name, &key); + if let Some(p) = &problem { + ui.label(egui::RichText::new(p).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + *open = false; + } + if ui.add_enabled(problem.is_none(), egui::Button::new("OK")).clicked() { + let mut p = json!({"name": name.trim(), "description": desc, "relative": app.macros.relative}); + if !key.trim().is_empty() { + p["shortcut"] = json!(key.trim()); + } + match app.run("macro.record", p) { + Ok(_) => *open = false, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } + } + }); + d.values.insert("name".into(), json!(name)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn questions_are_read_from_the_error() { + let e = "approve: Run macro 'M'? It came with a workbook you opened. (run again with `approve: true`)"; + assert_eq!(question_text(e, "approve: ").as_deref(), Some("Run macro 'M'? It came with a workbook you opened.")); + assert_eq!(question_text("confirm: Replace it? (run again with `overwrite: true`)", "approve: "), None); + assert_eq!(question_text("confirm: Replace it?", "confirm: ").as_deref(), Some("Replace it?")); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ee64fed91..87d0ed176 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -8,7 +8,9 @@ use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; use crate::theme::{self, Cat, Tokens}; -use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; +use crate::widgets::{ + big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, small_toggle_button, split_button, toggle_button, +}; pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; @@ -2565,9 +2567,68 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } fn automate(app: &mut SheetApp, ui: &mut Ui) { + // Macros: the Macros dialog; Record Macro (Stop Recording while recording), Use Relative + // References; Run Macro, whose main part runs the last macro and whose menu lists them all. if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { app.open_dialog("macros", json!({})); } + let recording = app.session.is_recording(); + ui.vertical(|ui| { + let (label, tip) = + if recording { ("Stop Recording", "Stop recording and save the macro") } else { ("Record Macro", "Record your actions as a macro") }; + let r = small_button(ui, Icon::Record, label, tip, false); + control_keytip(app, ui, &r, "macro.record"); + if r.clicked() { + crate::macro_ui::toggle_recording(app); + } + let tip = "Record steps relative to the cells selected when recording starts, so the macro works on other data"; + let rel = app.macros.relative; + let r = ui.add_enabled_ui(!recording, |ui| small_toggle_button(ui, Icon::Relative, "Use Relative References", tip, rel)).inner; + if r.clicked() && !recording { + app.macros.relative = !rel; + } + }); + let macros = crate::macro_ui::macro_list(app); + let recent: Vec = app.macros.recent.iter().filter(|r| macros.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned().collect(); + let (main, arrow) = big_split_button(ui, Icon::Macro, "Run\nMacro", "Run a macro on the selection"); + control_keytip(app, ui, &arrow, "macro.run"); + if main.clicked() { + // The last macro run, or the only one; otherwise choose in the Macros dialog. + match recent.first().or(macros.first().map(|m| &m.0)) { + Some(name) if macros.len() == 1 || !recent.is_empty() => { + let name = name.clone(); + crate::macro_ui::run_macro(app, &name); + } + _ => app.open_dialog("macros", json!({})), + } + } + egui::Popup::menu(&arrow).show(|ui| { + let t = Tokens::get(ui.ctx()); + if !recent.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("Recent")).small().color(t.text_dim)); + for n in &recent { + if ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + } + if macros.is_empty() { + ui.label(egui::RichText::new("No macros in this workbook").italics().color(t.text_dim)); + } + for (n, desc, _) in macros.iter().take(30) { + let r = ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))); + let r = if desc.is_empty() { r } else { r.on_hover_text(desc) }; + if r.clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + if ui.add(egui::Button::new(app.ui.language.tr("Macros…")).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + app.open_dialog("macros", json!({})); + } + }); + sep(ui); let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); control_keytip(app, ui, &__k, "commandSearch"); if __k.clicked() { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 65557ed15..1e359bd52 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -335,6 +335,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { gridcraft_engine::Mode::Point => "Point", }; text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); + crate::macro_ui::status(app, ui); if let Some(d) = app.session.active() { if d.wb.vba_project.is_some() { ui.add_space(12.0); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 74c13c9e2..7267694b9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -81,6 +81,8 @@ pub struct Tokens { pub sheet_control_text: Color32, /// A placeholder fill on the sheet (a picture that has not loaded). pub sheet_track: Color32, + /// The macro recording dot (Record Macro, the status bar). + pub recording: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -182,6 +184,7 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: rgb(0xC42B1C), } } @@ -239,6 +242,7 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: rgb(0xF1705F), } } @@ -1070,6 +1074,8 @@ mod contrast_tests { add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH); add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT); add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH); + add("recording dot on ribbon", t.recording, t.ribbon, GLYPH); + add("recording dot on status bar", t.recording, t.status_bar, GLYPH); v } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 295f0eafd..b33daca05 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -60,6 +60,9 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); + let name = tr(ui, tip); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -90,6 +93,9 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); paint_big_label(ui, rect, &lines, dropdown); + let name = label.replace('\n', " "); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -184,6 +190,32 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b t.text_dim, ); } + // The accessible name is the label, or the tooltip for an icon-only button. + let name = if label.is_empty() { tr(ui, tip) } else { label }; + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name)); + resp.on_hover_text(tip_text(ui, tip)) +} + +/// Small labelled toggle button (Use Relative References): drawn pressed while `on`. Its +/// accessible role is a checkbox, selected while `on`. +pub fn small_toggle_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, on: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let font = theme::ui_font(12.5); + let label = tr(ui, label); + let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0; + let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 23.0), Sense::click()); + if on || resp.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, 4.0, t.pressed); + ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + let color = if ui.is_enabled() { t.text } else { t.text_disabled }; + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, color); + ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, color); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, on, label)); resp.on_hover_text(tip_text(ui, tip)) } diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 2dbcb8f29..10847dae4 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2547,14 +2547,14 @@ fn keytip_wm_() { fn keytip_wmr_() { let mut h = harness(blank()); sequence(&mut h, "WMR", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMR must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMR must open the recordMacro dialog"); } #[test] fn keytip_wmv_() { let mut h = harness(blank()); sequence(&mut h, "WMV", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMV must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMV must open the macros dialog"); } #[test] diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs new file mode 100644 index 000000000..03417dd1c --- /dev/null +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -0,0 +1,251 @@ +//! Automate › Macros in the window: recording with the status bar's Stop, the Macros dialog +//! (macros, Automate scripts and a VBA project), macro shortcuts and the question asked when a +//! macro's output would land on data. + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(tab: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = tab.into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, a: &str) -> Value { + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() +} + +fn save(h: &mut Harness, m: serde_json::Value) { + h.state_mut().run("macro.save", json!({"macro": m})).unwrap(); + h.run_steps(2); +} + +fn dialog_name(h: &Harness) -> Option { + h.state().dialog.as_ref().map(|d| d.name().to_string()) +} + +#[test] +fn record_from_the_ribbon_and_stop_from_the_status_bar() { + let mut h = harness("Automate"); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("recordMacro"), "Record Macro asks for a name first"); + assert!(!h.state().session.is_recording()); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + assert!(h.query_by_label("Stop Recording").is_some(), "the button turns into Stop Recording"); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label_contains("Recording · 1 step").is_some(), "the status bar counts the steps"); + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + let name = wb.macros[0].name.clone(); + assert_eq!(name, "Macro1"); + assert_eq!(h.state().macros.recent, [name]); + assert!(h.query_by_label_contains("Recording ·").is_none()); +} + +#[test] +fn use_relative_references_sets_how_the_next_recording_starts() { + let mut h = harness("Automate"); + assert!(h.state().macros.relative, "relative is the default"); + h.get_by_label("Use Relative References").click(); + h.run_steps(2); + assert!(!h.state().macros.relative); + h.get_by_label("Record Macro").click(); + h.run_steps(5); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.macro_recording.as_ref().is_some_and(|r| !r.relative)); +} + +#[test] +fn the_macros_dialog_runs_the_selected_macro() { + let mut h = harness("Automate"); + save( + &mut h, + json!({"name": "Fill", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "C3", "input": "done"}}]}), + ); + save(&mut h, json!({"name": "Other", "steps": []})); + h.get_by_label("Macros").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("macros")); + h.get_by_label("Fill").click(); + h.run_steps(2); + h.get_by_label("Run").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(value(&h, "C3"), Value::Text("done".into())); + // One undo step undoes the whole run. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(value(&h, "C3"), Value::Empty); +} + +#[test] +fn the_macros_dialog_lists_scripts_and_the_vba_project_beside_macros() { + let mut s = Session::new(); + s.new_workbook(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(std::sync::Arc::new(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]})) + .unwrap(); + s.execute("macro.save", json!({"name": "Mark", "steps": []})).unwrap(); + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + // The dialog keeps view.macros (scripts and VBA) as its result, as before. + let items = h.state().dialog.as_ref().and_then(|d| d.result.clone()).and_then(|r| r.as_array().cloned()).unwrap_or_default(); + assert!(items.iter().any(|i| i["kind"] == "vba"), "{items:?}"); + assert!(h.query_by_label_contains("also has a VBA project").is_some()); + // A script and a macro may share a name; each has its own row. + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values["kind"], json!("script")); + h.get_by_label("Run").click(); + h.run_steps(3); + assert_eq!(value(&h, "D4"), Value::Text("script".into())); + // Deleting asks first, then removes the script and leaves the macro. + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(h.state().macros.question.as_ref().is_some_and(|q| q.text.contains("script “Mark”"))); + // The question's Delete, in front of the dialog's. + h.get_all_by_label("Delete").last().expect("the question's Delete").click(); + h.run_steps(3); + assert!(h.query_by_label("Mark (script, 1 step)").is_none()); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1); +} + +#[test] +fn options_set_a_shortcut_that_runs_the_macro() { + let mut h = harness("Home"); + save( + &mut h, + json!({"name": "Stamp", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "B2", "input": "stamped"}}]}), + ); + assert!(gridcraft_ui_egui::macro_ui::save_options(h.state_mut(), "Stamp", Some('m'), "Stamps B2")); + let m = h.state().session.active().unwrap().wb.macros[0].clone(); + assert_eq!((m.shortcut.as_deref(), m.description.as_str()), (Some("Ctrl+Shift+M"), "Stamps B2")); + let mods = Modifiers::COMMAND | Modifiers::SHIFT; + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: true, repeat: false, modifiers: mods }); + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: false, repeat: false, modifiers: mods }); + h.run_steps(3); + assert_eq!(value(&h, "B2"), Value::Text("stamped".into())); +} + +#[test] +fn a_shortcut_gridcraft_uses_does_not_run_a_macro() { + let mut h = harness("Home"); + // A file can bring any letter; Ctrl+Shift+Z stays Redo. + let m = gridcraft_engine::model::Macro { name: "Z".into(), shortcut: Some("Ctrl+Shift+Z".into()), ..Default::default() }; + std::sync::Arc::make_mut(&mut h.state_mut().session.active_mut().unwrap().wb).macros.push(m); + assert_eq!(gridcraft_ui_egui::macro_ui::shortcut_macro(h.state(), Key::Z), None); +} + +#[test] +fn a_macro_whose_output_lands_on_data_asks_and_can_shift_it_down() { + let mut h = harness("Automate"); + h.state_mut().run("cell.set", json!({"cell": "B1", "input": "old"})).unwrap(); + save(&mut h, json!({"name": "Put", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "new"}}]})); + gridcraft_ui_egui::macro_ui::run_macro(h.state_mut(), "Put"); + h.run_steps(2); + let q = h.state().macros.question.clone().expect("a question"); + assert_eq!(q.choices.iter().map(|c| c.0.as_str()).collect::>(), ["Overwrite", "Shift Down", "Shift Right"]); + assert!(q.text.contains("B1"), "{}", q.text); + assert!(!q.text.contains("onConflict"), "the hint for programs is not shown: {}", q.text); + h.get_by_label("Shift Down").click(); + h.run_steps(3); + assert!(h.state().macros.question.is_none()); + assert_eq!(value(&h, "B1"), Value::Text("new".into())); + assert_eq!(value(&h, "B2"), Value::Text("old".into())); + assert_eq!(h.state().macros.recent, ["Put"]); +} + +#[test] +fn the_record_macro_dialog_names_the_macro_and_checks_name_and_shortcut() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + let set = |h: &mut Harness, k: &str, v: &str| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), json!(v)); + h.run_steps(2); + }; + assert_eq!(h.state().dialog.as_ref().unwrap().values["name"], json!("Macro1"), "the next free name is suggested"); + set(&mut h, "name", "taken"); + assert!(h.query_by_label_contains("already exists").is_some()); + set(&mut h, "name", "Two\nlines"); + assert!(h.query_by_label_contains("line breaks").is_some()); + set(&mut h, "name", "Tidy Up"); + set(&mut h, "shortcut", "P"); + assert!(h.query_by_label_contains("already runs macro “Taken”").is_some()); + set(&mut h, "shortcut", "S"); + assert!(h.query_by_label_contains("Save As").is_some()); + // OK stays off while something is wrong. + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + set(&mut h, "shortcut", "Q"); + set(&mut h, "description", "Tidies the block"); + assert!(h.query_by_label_contains("Choose another").is_none()); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.get_by_label("Stop").click(); + h.run_steps(2); + let m = h.state().session.active().unwrap().wb.macros.iter().find(|m| m.name == "Tidy Up").cloned().expect("saved under its name"); + assert_eq!(m.shortcut.as_deref(), Some("Ctrl+Shift+Q")); + assert_eq!(m.description, "Tidies the block"); +} + +#[test] +fn view_macros_record_macro_toggles_recording() { + let mut h = harness("Home"); + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(4); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + // Opening it again while recording stops and saves, as Excel's Stop Recording does. + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(!h.state().session.is_recording()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 915016971..9c2232fb6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -53,6 +53,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod macros_ui; mod menu_hover; mod merged_cells; mod more_colors; diff --git a/docs/macros.md b/docs/macros.md index f23358511..12019a523 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (Record Macro takes a name, shortcut and description; recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands) +> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, shortcuts) 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 @@ -9,7 +9,7 @@ error (a file it already wrote stays), checks where its results will land before and does not touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the engine and its commands, not a user interface for macros. +This page describes the engine, its commands and the macro interface in the window. ## A macro @@ -265,6 +265,28 @@ a recording gets a suffix. A macro that is damaged or over the limits is left out with one warning, and the rest of the workbook and its other macros open; a macro that was left out is not in the saved file. +## In the window + +- **Automate › Macros group.** Macros opens the Macros dialog. Record Macro opens the Record + Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References); OK + starts recording and stays off while the name or the letter cannot be used, with the reason + under the fields. While recording, the button reads Stop Recording, the status bar shows a red + dot, "Recording · N steps" and Stop, and Use Relative References is locked. Run Macro runs the + macro run last (or the only one); its menu lists the recent macros, then every macro. +- **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros + (with their shortcuts), then its Automate scripts, and says when the file carries a VBA project. + Run runs the selected macro on the selection or the selected script; Delete asks first; Options + sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, + as before macros existed. +- **Questions.** A macro that needs approval offers Run; a macro whose output would land on data + offers Overwrite, Shift Down and Shift Right. Each button runs the command again with + `approve: true` or the chosen `onConflict`. +- **Shortcuts.** Ctrl+Shift+letter (⌘⇧letter on a Mac) on the grid runs the macro with that + letter. The letters GridCraft uses itself keep their own meaning. + +Commands with empty parameters never open these dialogs: `macro.record {}` starts recording +under the next free name, and the dialogs are opened only from the ribbon, the menus and keys. + ## Automate scripts and VBA Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0a602643f..59962538c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (catalog count 276 / 292, as `cargo xtask parity` measures; earlier: automation: typed macros in the engine) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -93,7 +93,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), with no interface yet. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 49251a27c..a8783b499 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (ribbon catalog count 276 / 292; earlier: Paste Name lays out only the names in view; Go To matches names in any case and script; one Page Layout view row, with the header and footer side margin marked unverified; earlier: Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (macro interface on the Automate tab; ribbon catalog count 276 / 292; earlier: Paste Name lays out only the names in view; Go To matches names in any case and script; one Page Layout view row, with the header and footer side margin marked unverified; earlier: Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -141,7 +141,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** | -| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | +| Alt+F8, Ctrl+Shift+letter | Option+F8, Cmd+Shift+letter | Macros dialog, a macro's own shortcut (letters GridCraft uses keep their meaning) | **added** | +| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F11 / F12 | same | group sheets, shape cycling, full screen, window keys, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | On Windows, AltGr arrives as Ctrl+Alt: a Ctrl+Alt key that also types a character is treated as @@ -152,7 +153,8 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing or partial: Options, +Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a +note on a VBA project; Run, Delete, Options) and Record Macro and others exist. Missing or partial: Options, Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). @@ -187,6 +189,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | +| 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | | 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list | | 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` | From 1443ec417cde557881aacd9858da8f7f74ac093b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:29:40 -0400 Subject: [PATCH 140/166] UI: macro step editor with a debug bar and grid overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Macro (Automate › Macros) and Step Into, Edit and Create in the Macros dialog open a resizable step editor. It works on a draft of the macro and saves it with macro.save. The step list shows each step's number, a breakpoint dot in the gutter (click to set or clear), a category icon, a summary, an error marker and an enabled checkbox; Repeat bodies and If branches are indented under their step, and rows drag to reorder within their list. Add Step opens a command picker (control steps first; commands a macro may never run are left out) and fills in the command's required parameters; Duplicate and Delete act on the selected step. The inspector's Details tab edits the selected step. Reference parameters are reference boxes that take a typed reference or a selection on the grid, with a Rel/Abs switch between a token relative to the example input and an absolute reference, and token suggestions. The Inputs tab sets each input's shape, header and column types (Use current selection as example infers them); the Output tab sets the anchor and the conflict rule. The debug bar has Check (a dry run that colours the grid: inputs blue, writes green, writes over data red), Run, Step, Continue, Run to Here and Stop, which asks whether to keep the changes or undo them. The debugger receives the editor's breakpoints with breakpointsOnly, so a dot cleared while paused takes effect at once; the current step is selected, its parameters are shown as they will run, and its target is outlined on the grid. While a macro is debugged, the status bar says so and Show reopens the editor. List drag and drop lives in a small dnd module. The debugger icons are drawn in code, and the breakpoint and sheet overlay colours are theme tokens covered by the contrast test. --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 13 + crates/ui-egui/src/dnd.rs | 71 + crates/ui-egui/src/grid.rs | 5 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/el.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/nl.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ro.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/sr.rs | 2 + crates/ui-egui/src/i18n/tr.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/i18n/vi.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/icons.rs | 40 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/macro_editor.rs | 2269 +++++++++++++++++++++++ crates/ui-egui/src/macro_ui.rs | 45 +- crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/src/theme.rs | 42 + crates/ui-egui/tests/it/interaction.rs | 2 + crates/ui-egui/tests/it/macro_editor.rs | 215 +++ crates/ui-egui/tests/it/main.rs | 1 + docs/macros.md | 26 +- docs/target-app-parity.md | 4 +- docs/ui-parity.md | 4 +- 34 files changed, 2770 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/dnd.rs create mode 100644 crates/ui-egui/src/macro_editor.rs create mode 100644 crates/ui-egui/tests/it/macro_editor.rs diff --git a/ROADMAP.md b/ROADMAP.md index aac13c874..2e9aa5c9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,7 +86,7 @@ collaboration, mobile, AI). | Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Automation (recorded scripts; typed macros with a recorder and a Macros dialog; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | ## Languages @@ -135,7 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right | +| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ec496d95e..3c2b9f1a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -908,6 +908,7 @@ impl Dialog { "journal" => Dialog::custom("journal", "Action Journal", json!({})), "macros" => crate::macro_ui::open_dialog(app, ¶ms), "recordMacro" => return crate::macro_ui::open_record(app, ¶ms), + "macroEditor" => return crate::macro_editor::open(app, ¶ms), "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -1250,6 +1251,18 @@ pub(crate) fn build_params(d: &Dialog) -> Json { pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; + // The macro step editor is a large resizable window of its own. + if d.name == "macroEditor" { + let mut open = true; + crate::macro_editor::window(app, ctx, &mut d, &mut open); + if open { + app.dialog = Some(d); + } else { + app.macros.editor = None; + app.grid.macro_overlay = None; + } + return; + } if d.name == "pasteSpecial" { paste_special_shortcuts(&mut d, ctx); } diff --git a/crates/ui-egui/src/dnd.rs b/crates/ui-egui/src/dnd.rs new file mode 100644 index 000000000..aa9ecfe2f --- /dev/null +++ b/crates/ui-egui/src/dnd.rs @@ -0,0 +1,71 @@ +//! List drag and drop shared by the PivotTable Fields pane and the macro step editor: a drag +//! source that carries a payload, where a drop lands among a list's rows, the line that marks it, +//! and the label that follows the pointer. + +use egui::{DragAndDrop, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; + +use crate::theme::{self, Tokens}; + +/// Where a drop at `y` lands among rows laid out top to bottom. +pub fn insert_index(rows: &[Rect], y: f32) -> usize { + rows.iter().filter(|r| r.center().y < y).count() +} + +/// Starts dragging `payload` when `r` (a widget sensing drags) begins a drag, and shows the grab +/// cursor over it. +pub fn drag_source(ui: &Ui, r: &egui::Response, payload: T) { + if r.drag_started() { + DragAndDrop::set_payload(ui.ctx(), payload); + } + if r.hovered() && !DragAndDrop::has_any_payload(ui.ctx()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } +} + +/// A drag handle under a widget that only senses clicks (a checkbox): it covers where the widget +/// was last frame (stored under `id`) and is added first, so clicks still reach the widget on top. +pub fn drag_under(ui: &mut Ui, id: Id, payload: T) { + let Some(rect) = ui.ctx().data(|d| d.get_temp::(id)) else { return }; + let r = ui.interact(rect, id, Sense::drag()); + drag_source(ui, &r, payload); +} + +/// While dragging: outlines `zone` and draws the line where a drop at `y` would land among `rows`. +pub fn drop_marker(ui: &Ui, zone: Rect, rows: &[Rect], y: f32) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("dnd_drop_marker"))); + painter.rect_stroke(zone, 4.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + let k = insert_index(rows, y); + let y = match (rows.get(k), k.checked_sub(1).and_then(|j| rows.get(j))) { + (Some(r), _) => r.top() - 2.0, + (None, Some(r)) => r.bottom() + 2.0, + (None, None) => zone.top() + 4.0, + }; + painter.line_segment([pos2(zone.left() + 4.0, y), pos2(zone.right() - 4.0, y)], Stroke::new(2.0, t.accent)); +} + +/// The dragged item's label following the pointer. +pub fn ghost(ui: &Ui, text: &str, at: Pos2) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("dnd_ghost"))); + let galley = painter.layout_no_wrap(text.to_string(), theme::ui_font(12.0), t.text); + let rect = Rect::from_min_size(at + vec2(12.0, 8.0), galley.size() + vec2(12.0, 6.0)); + painter.rect_filled(rect, 3.0, t.input_bg); + painter.rect_stroke(rect, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.galley(rect.min + vec2(6.0, 3.0), galley, t.text); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_drop_lands_before_the_first_row_below_it() { + let rows: Vec = (0..3).map(|i| Rect::from_min_size(pos2(0.0, i as f32 * 20.0), vec2(100.0, 20.0))).collect(); + assert_eq!(insert_index(&rows, -5.0), 0); + assert_eq!(insert_index(&rows, 15.0), 1); + assert_eq!(insert_index(&rows, 45.0), 2); + assert_eq!(insert_index(&rows, 500.0), 3); + assert_eq!(insert_index(&[], 10.0), 0); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 91b3b571c..63ecb4b1b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -190,6 +190,8 @@ pub struct GridState { pub wheel_zoom: Option, /// A one-finger touch drag in progress: it pans the sheet instead of selecting. pub touch_pan: bool, + /// What the macro step editor shows on the grid (Check, debugging). + pub macro_overlay: Option, } /// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and @@ -3796,6 +3798,9 @@ pub(crate) fn paint_overlays( ui.label(tip); }); } + if let Some(o) = &app.grid.macro_overlay { + crate::macro_editor::paint_overlay(p, geo, sh, o); + } // Trace precedents / dependents arrows. if let Some(trace) = app.grid.trace.clone() && let Some(items) = trace.as_array() diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 09a884a55..5b1a07991 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -555,6 +555,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Makra…", "Recording" => "Záznam", "Stop" => "Zastavit", + "New Macro" => "Nové makro", + "Show" => "Zobrazit", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c61a74df8..6a16f097c 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -460,6 +460,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Makros…", "Recording" => "Aufzeichnung", "Stop" => "Beenden", + "New Macro" => "Neues Makro", + "Show" => "Anzeigen", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index d7cc1eb93..58a1e73a1 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -984,6 +984,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Run\nMacro" => "Εκτέλεση μακροεντολής", "Macros…" => "Μακροεντολές…", "Recording" => "Καταγραφή", + "New Macro" => "Νέα μακροεντολή", + "Show" => "Εμφάνιση", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 262c6bb61..0bf90ba8a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -476,6 +476,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Macros…", "Recording" => "Grabando", "Stop" => "Detener", + "New Macro" => "Nueva macro", + "Show" => "Mostrar", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 2d57f57bd..cbf1ce721 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -446,6 +446,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Macros…", "Recording" => "Enregistrement", "Stop" => "Arrêter", + "New Macro" => "Nouvelle macro", + "Show" => "Afficher", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c00178b13..e250ea61e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -987,6 +987,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Run\nMacro" => "Esegui macro", "Macros…" => "Macro…", "Recording" => "Registrazione", + "New Macro" => "Nuova macro", + "Show" => "Mostra", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 162d82104..322c70f17 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -421,6 +421,8 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Run\nMacro", "マクロの実行"), ("Macros…", "マクロ…"), ("Recording", "記録中"), + ("New Macro", "新しいマクロ"), + ("Show", "表示"), ]; /// English label → Japanese for the dialog, message, Backstage and menu strings beyond [`CORE`]. diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 5f0ffc983..509c6bdee 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -444,6 +444,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "매크로…", "Recording" => "기록 중", "Stop" => "중지", + "New Macro" => "새 매크로", + "Show" => "표시", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 3afb4cf60..c7bcfe6de 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -529,6 +529,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Macro's…", "Recording" => "Opnemen", "Stop" => "Stoppen", + "New Macro" => "Nieuwe macro", + "Show" => "Weergeven", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 0e82acde5..305bb549a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -978,6 +978,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Run\nMacro" => "Uruchom makro", "Macros…" => "Makra…", "Recording" => "Rejestrowanie", + "New Macro" => "Nowe makro", + "Show" => "Pokaż", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 536eeb823..27bfa74a7 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -979,6 +979,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Run\nMacro" => "Executar Macro", "Macros…" => "Macros…", "Recording" => "Gravando", + "New Macro" => "Nova Macro", + "Show" => "Mostrar", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 469da8dca..b35d34e0d 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1040,6 +1040,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Run\nMacro" => "Rulare macrocomandă", "Macros…" => "Macrocomenzi…", "Recording" => "Înregistrare", + "New Macro" => "Macrocomandă nouă", + "Show" => "Afișare", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e87536886..65ed6d101 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -446,6 +446,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Макросы…", "Recording" => "Запись", "Stop" => "Остановить", + "New Macro" => "Новый макрос", + "Show" => "Показать", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index e10ab9c2f..4722fa3e3 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -444,6 +444,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Makroi…", "Recording" => "Snimanje", "Stop" => "Zaustavi", + "New Macro" => "Novi makro", + "Show" => "Prikaži", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index daa951fdc..6445050a6 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -418,6 +418,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Makrolar…", "Recording" => "Kaydediliyor", "Stop" => "Durdur", + "New Macro" => "Yeni Makro", + "Show" => "Göster", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 334bbb6dd..947ac7052 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -982,6 +982,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Run\nMacro" => "Запустити макрос", "Macros…" => "Макроси…", "Recording" => "Запис", + "New Macro" => "Новий макрос", + "Show" => "Показати", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 31d825881..1a6583b46 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -420,6 +420,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "Macro…", "Recording" => "Đang ghi", "Stop" => "Dừng", + "New Macro" => "Macro Mới", + "Show" => "Hiện", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index a1b4179cc..0fc97b55d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -436,6 +436,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Macros…" => "宏…", "Recording" => "正在录制", "Stop" => "停止", + "New Macro" => "新建宏", + "Show" => "显示", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 43ddf3baa..6c9f8551b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -141,6 +141,19 @@ pub enum Icon { Record, /// Use Relative References: a cell and an arrow to a cell offset from it. Relative, + /// A reference box's button: shrink the window to the box (Collapse) or restore it (Expand). + Collapse, + Expand, + /// The macro debugger's Run: a play triangle. + Play, + /// Step: an arrow down onto a line. + Step, + /// Continue: a bar and a play triangle. + Continue, + /// Run to Here: an arrow to a marked line. + RunTo, + /// Stop: a square. + StopSquare, } struct G<'a> { @@ -771,6 +784,33 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.line(&[(5.0, 7.5), (5.0, 12.0), (8.0, 12.0)], c, 1.1); g.poly(&[(7.5, 10.2), (9.6, 12.0), (7.5, 13.8)], c); } + Icon::Collapse | Icon::Expand => { + // A small sheet with a selected block and an arrow pointing up (collapse) or down. + g.rect(2.0, 6.5, 12.0, 7.5, None, Some(c)); + g.rect(4.0, 9.0, 4.0, 3.0, Some(c), None); + if icon == Icon::Collapse { + g.poly(&[(8.0, 1.0), (11.5, 4.5), (4.5, 4.5)], c); + } else { + g.poly(&[(8.0, 5.5), (11.5, 2.0), (4.5, 2.0)], c); + } + } + Icon::Play => g.poly(&[(4.0, 2.5), (13.0, 8.0), (4.0, 13.5)], green), + Icon::Step => { + g.line(&[(8.0, 1.5), (8.0, 9.0)], c, 1.3); + g.poly(&[(4.8, 7.0), (11.2, 7.0), (8.0, 10.8)], c); + g.line(&[(3.0, 13.5), (13.0, 13.5)], c, 1.3); + } + Icon::Continue => { + g.rect(2.5, 2.5, 2.2, 11.0, Some(c), None); + g.poly(&[(6.5, 2.5), (14.0, 8.0), (6.5, 13.5)], green); + } + Icon::RunTo => { + g.line(&[(2.0, 5.0), (9.0, 5.0)], c, 1.3); + g.poly(&[(8.0, 2.0), (12.0, 5.0), (8.0, 8.0)], c); + g.line(&[(2.0, 11.5), (14.0, 11.5)], c, 1.6); + g.line(&[(13.0, 3.0), (13.0, 9.0)], c, 1.1); + } + Icon::StopSquare => g.rect(3.0, 3.0, 10.0, 10.0, Some(pal.recording), None), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4e827de4..db8e3d19e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod code_editor; pub mod control; pub mod credits; pub mod dialogs; +pub mod dnd; pub mod editor; mod floating_pictures; pub mod fonts; @@ -24,6 +25,7 @@ pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; +pub mod macro_editor; pub mod macro_ui; pub mod outline; pub mod panes; diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs new file mode 100644 index 000000000..69e7463a6 --- /dev/null +++ b/crates/ui-egui/src/macro_editor.rs @@ -0,0 +1,2269 @@ +//! The macro step editor (Automate › New Macro; Macros › Edit, Create, Step Into): a large, +//! resizable window. +//! +//! - **Left, the step list.** Each row has its number, a breakpoint dot in the gutter (click to +//! toggle), a category icon, a one-line summary, an error marker and an enabled checkbox. Rows +//! drag to reorder within their list (the shared [`crate::dnd`] helpers); Add Step opens a +//! Command Palette picker; Duplicate and Delete act on the selected step. +//! - **Right, the inspector.** Step: the selected step's fields, with RefEdit boxes that switch +//! between relative tokens (`Input.col(2)`) and absolute A1, and token suggestions. Inputs: the +//! shape, header and column types each input must have, from an example. Output: the anchor and +//! what to do when results would land on data. +//! - **The debug bar.** Check (a dry run that colours the grid), Run, Step, Continue, Run to Here +//! and Stop (keep the changes or undo them). While debugging, the current step is highlighted +//! and its parameters are shown as they will run. +//! +//! The editor works on a draft and saves it with `macro.save`; checking, running and debugging +//! save first, then go through `macro.check`, `macro.run` and `macro.debug`. + +use std::collections::BTreeMap; + +use egui::{Color32, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use gridcraft_engine::core::RangeRef; +use gridcraft_engine::macros::policy::{Policy, macro_policy}; +use gridcraft_engine::macros::resolve::{self, Env, Place}; +use gridcraft_engine::model::{ColType, CompareOp, InputKind, Macro, NamedInput, OnConflict, RepeatOf, Step, StepKind, Workbook}; +use serde_json::{Map, Value as Json, json}; + +use crate::dialogs::Dialog; +use crate::icons::{self, Icon}; +use crate::theme::{self, Cat, Tokens}; +use crate::widgets::small_button; +use crate::{SheetApp, dnd, macro_ui}; + +/// The inspector's tabs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + Step, + Inputs, + Output, +} + +/// The Add Step picker (or Change, which replaces a step's command). +#[derive(Clone, Debug, Default)] +pub struct Picker { + pub search: String, + /// The step whose command is being changed; `None` adds a step. + pub replace: Option, + pub index: usize, +} + +/// The editor's state while it is open. +#[derive(Clone, Debug)] +pub struct Editor { + pub draft: Macro, + /// The name the macro is saved under (`None` until it is first saved). + pub saved_as: Option, + pub dirty: bool, + /// The selected row: a step path (`3`, `2.1`, `4.then.1`) or an If branch (`4.then`). + pub selected: Option, + pub tab: Tab, + /// The input selected on the Inputs tab. + pub input: usize, + pub picker: Option, + /// The last `macro.check` result (the grid overlays come from it). + pub check: Option, + /// The debugger's state while paused. + pub debug: Option, + /// What the last run, check or debug session ended with. + pub outcome: Option, + /// Errors from the last check or run, by step path. + pub errors: BTreeMap, + /// Asking whether to keep what a stopped debug run did (and then close, if `closing`). + pub ask_stop: bool, + pub closing: bool, + /// Asking whether to save before closing. + pub ask_close: bool, + /// Start debugging on the first frame (Macros › Step Into). + pub start_debug: bool, + /// Check on the first frame (`{"check": true}`). + pub start_check: bool, + /// The example the first input stands for (the selection when the editor opened, or the + /// input's default range): Check, Run and Debug use it, and tokens are shown against it. + pub example: String, + /// The parameter being added to the selected step: (key, value). + pub new_param: (String, String), + /// The reference boxes' picking state. + pub refs: RefPick, +} + +/// Which reference box grid selections write into, and the selection to put back on closing. +#[derive(Clone, Debug, Default)] +pub struct RefPick { + /// The reference box that grid selections currently write into. + pub pick: Option, + /// The selection reference last written by (or seen while) picking. + pub seen: String, + /// Sheet, ranges and active cell from before picking, put back when the editor closes. + pub home: Option<(usize, String, String)>, + /// The reference box the window is collapsed to (only that box shows). + pub collapsed: Option, +} + +// ---------------------------------------------------------------- step paths + +fn child(parent: &str, i: usize) -> String { + if parent.is_empty() { (i + 1).to_string() } else { format!("{parent}.{}", i + 1) } +} + +/// `"4.then.2"` → (`"4.then"`, 1). `None` for a branch key or anything else that isn't a step. +pub fn split_path(path: &str) -> Option<(&str, usize)> { + let (parent, last) = path.rsplit_once('.').unwrap_or(("", path)); + Some((parent, last.parse::().ok()?.checked_sub(1)?)) +} + +/// The step list a parent key names: `""` (the top), `"2"` (a Repeat's body), `"4.then"`. +pub fn list_mut<'a>(mut cur: &'a mut Vec, parent: &str) -> Option<&'a mut Vec> { + let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + while let Some(tok) = toks.get(i) { + let idx = tok.parse::().ok()?.checked_sub(1)?; + let branch = toks.get(i + 1).copied(); + let st = cur.get_mut(idx)?; + cur = match (&mut st.kind, branch) { + (StepKind::If { then, .. }, Some("then")) => { + i += 2; + then + } + (StepKind::If { otherwise, .. }, Some("else")) => { + i += 2; + otherwise + } + (StepKind::Repeat { body, .. }, _) => { + i += 1; + body + } + _ => return None, + }; + } + Some(cur) +} + +pub fn step_mut<'a>(steps: &'a mut Vec, path: &str) -> Option<&'a mut Step> { + let (parent, i) = split_path(path)?; + list_mut(steps, parent)?.get_mut(i) +} + +pub fn step_at(steps: &[Step], path: &str) -> Option { + let mut copy = steps.to_vec(); + step_mut(&mut copy, path).map(|s| s.clone()) +} + +/// The paths of the steps with a breakpoint. +pub fn breakpoints(steps: &[Step], parent: &str, out: &mut Vec) { + for (i, st) in steps.iter().enumerate() { + let path = child(parent, i); + if st.breakpoint { + out.push(path.clone()); + } + match &st.kind { + StepKind::Repeat { body, .. } => breakpoints(body, &path, out), + StepKind::If { then, otherwise, .. } => { + breakpoints(then, &format!("{path}.then"), out); + breakpoints(otherwise, &format!("{path}.else"), out); + } + _ => {} + } + } +} + +/// One row of the step list. +#[derive(Clone, Debug, PartialEq)] +pub struct Row { + /// The step path, or the branch key for an If's Then/Else row. + pub key: String, + /// The list it is in, and its place there. + pub parent: String, + pub index: usize, + pub depth: usize, + /// `Some("Then")` / `Some("Else")` for a branch row. + pub branch: Option<&'static str>, +} + +/// The step list flattened into rows, nested steps indented under their Repeat or If. +pub fn rows(steps: &[Step], parent: &str, depth: usize, out: &mut Vec) { + if depth > 16 { + return; + } + for (i, st) in steps.iter().enumerate() { + let key = child(parent, i); + out.push(Row { key: key.clone(), parent: parent.to_string(), index: i, depth, branch: None }); + match &st.kind { + StepKind::Repeat { body, .. } => rows(body, &key, depth + 1, out), + StepKind::If { then, otherwise, .. } => { + for (label, list, b) in [("Then", then, "then"), ("Else", otherwise, "else")] { + let bk = format!("{key}.{b}"); + out.push(Row { key: bk.clone(), parent: key.clone(), index: 0, depth: depth + 1, branch: Some(label) }); + rows(list, &bk, depth + 2, out); + } + } + _ => {} + } + } +} + +/// Moves the step at `from` in list `parent` so it lands before row `to` (an index among the +/// list's steps as they are now). Returns the step's new path. +pub fn move_step(steps: &mut Vec, parent: &str, from: usize, to: usize) -> Option { + let list = list_mut(steps, parent)?; + if from >= list.len() { + return None; + } + let st = list.remove(from); + let to = if to > from { to - 1 } else { to }.min(list.len()); + list.insert(to, st); + Some(child(parent, to)) +} + +// ---------------------------------------------------------------- summaries + +fn short(v: &Json) -> String { + let text = match v { + Json::String(s) => s.clone(), + other => other.to_string(), + }; + if text.chars().count() > 40 { format!("{}…", text.chars().take(39).collect::()) } else { text } +} + +/// The label of command `id` (`Set Cell`), or the id when it isn't a command. +pub fn command_label(id: &str) -> String { + gridcraft_engine::find_command(id).map(|c| c.label.to_string()).unwrap_or_else(|| id.to_string()) +} + +/// A step in one line: "Set D2 to =B2*C2", "Repeat for each row of Input.data". +pub fn summary(st: &Step) -> String { + match &st.kind { + StepKind::Command { id, params } => { + let p = |k: &str| params.get(k).map(short); + match (id.as_str(), p("cell"), p("input"), p("range")) { + ("cell.set", Some(c), Some(i), _) => format!("Set {c} to {i}"), + ("selection.set", _, _, Some(r)) => format!("Select {r}"), + _ => { + let label = command_label(id); + let args: Vec = + params.as_object().map(|o| o.iter().take(3).map(|(k, v)| format!("{k}: {}", short(v))).collect()).unwrap_or_default(); + if args.is_empty() { label } else { format!("{label} · {}", args.join(", ")) } + } + } + } + StepKind::Repeat { over, of, .. } => { + format!("Repeat for each {} of {}", if *of == RepeatOf::Rows { "row" } else { "column" }, if over.is_empty() { "…" } else { over }) + } + StepKind::If { cell, op, value, .. } => { + let op = match op { + CompareOp::Eq => "=", + CompareOp::Ne => "≠", + CompareOp::Gt => ">", + CompareOp::Ge => "≥", + CompareOp::Lt => "<", + CompareOp::Le => "≤", + CompareOp::Empty => return format!("If {cell} is empty"), + CompareOp::NotEmpty => return format!("If {cell} isn't empty"), + CompareOp::Contains => "contains", + }; + format!("If {cell} {op} {}", short(value)) + } + StepKind::Stop { message, .. } => { + if message.is_empty() { + "Stop".into() + } else { + format!("Stop: {message}") + } + } + } +} + +/// The icon and colour of a step's category. +pub fn category(st: &Step) -> (Icon, Cat) { + match &st.kind { + StepKind::Repeat { .. } => (Icon::Redo, Cat::Purple), + StepKind::If { .. } => (Icon::Validation, Cat::Amber), + StepKind::Stop { .. } => (Icon::StopSquare, Cat::Red), + StepKind::Command { id, .. } => { + let prefix = id.split('.').next().unwrap_or(""); + match prefix { + "cell" | "range" | "edit" => (Icon::Sheet, Cat::Blue), + "selection" => (Icon::Find, Cat::Slate), + "home" => (Icon::Format, Cat::Orange), + "data" | "table" | "pivot" => (Icon::SortFilter, Cat::Green), + "insert" | "chart" | "object" => (Icon::Insert, Cat::Purple), + "formulas" => (Icon::Function, Cat::Teal), + "sheet" | "view" | "pageLayout" => (Icon::Sheet, Cat::Amber), + _ => (Icon::Script, Cat::Slate), + } + } + } +} + +/// Problems the editor can see without running: an unknown command, one a macro may not run, +/// one that needs a permission the macro doesn't list, a Repeat or If with nothing to work on. +pub fn static_error(m: &Macro, st: &Step) -> Option { + match &st.kind { + StepKind::Command { id, params } => { + if gridcraft_engine::find_command(id).is_none() { + return Some(format!("There is no command “{id}”.")); + } + match macro_policy(id, params, gridcraft_engine::Trust::Local) { + Policy::Deny(why) => Some(format!("{id} can't run in a macro: {why}.")), + Policy::NeedsPermission(what) if !m.permissions.iter().any(|p| p == id) => { + Some(format!("{id} {what}: the macro must list it in its permissions.")) + } + _ => None, + } + } + StepKind::Repeat { over, .. } if over.trim().is_empty() => Some("Choose the range to repeat over.".into()), + StepKind::If { cell, .. } if cell.trim().is_empty() => Some("Choose the cell to test.".into()), + _ => None, + } +} + +/// The parameters a new step starts with, from the command's documentation: the required ones, +/// plus an optional `cell` or `range` (`{cell?, input, array?}` → cell, input). +pub fn required_keys(doc: &str) -> Vec { + let Some(start) = doc.find('{') else { return vec![] }; + let mut depth = 0i32; + let mut quoted = false; + let mut part = String::new(); + let mut parts = Vec::new(); + for c in doc.get(start + 1..).unwrap_or("").chars() { + if c == '"' { + quoted = !quoted; + } + if quoted { + continue; + } + match c { + '{' | '[' | '(' => depth += 1, + '}' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + break; + } + '}' | ']' | ')' => depth -= 1, + ',' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + continue; + } + _ => {} + } + if depth == 0 { + part.push(c); + } + } + parts + .iter() + .filter_map(|p| { + let p = p.trim(); + let key: String = p.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); + let rest = p.get(key.len()..).unwrap_or("").trim_start(); + let optional = rest.starts_with('?') || rest.starts_with('|'); + (!key.is_empty() && (!optional || key == "cell" || key == "range")).then_some(key) + }) + .take(8) + .collect() +} + +// ---------------------------------------------------------------- tokens + +/// The token for `target` relative to the input called `name` at `input`: `Input`, `Input.col(2)`, +/// `Input.rows(2..3)`, `Input.cell(1,4)`, `Input.offset(0,4)`, or a cell offset from the input's +/// first cell (`Input.cell(1,1).offset(-1,0)`) with a `resize` when it is larger than one cell. +pub fn token_for(name: &str, input: RangeRef, target: RangeRef) -> String { + let (i, r) = (input, target); + if r == i { + return name.to_string(); + } + let dr = i64::from(r.start.row) - i64::from(i.start.row); + let dc = i64::from(r.start.col) - i64::from(i.start.col); + let (h, w) = (r.height(), r.width()); + if h == i.height() && w == i.width() { + return format!("{name}.offset({dr},{dc})"); + } + let inside = i.contains(r.start) && i.contains(r.end); + if inside { + let (r0, r1) = (dr + 1, dr + i64::from(h)); + let (c0, c1) = (dc + 1, dc + i64::from(w)); + if r.start.row == i.start.row && r.end.row == i.end.row { + return if w == 1 { format!("{name}.col({c0})") } else { format!("{name}.cols({c0}..{c1})") }; + } + if r.start.col == i.start.col && r.end.col == i.end.col { + return if h == 1 { format!("{name}.row({r0})") } else { format!("{name}.rows({r0}..{r1})") }; + } + let cell = format!("{name}.cell({r0},{c0})"); + return if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") }; + } + let cell = format!("{name}.cell(1,1).offset({dr},{dc})"); + if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") } +} + +/// `Sheet1!$B$2:$D$9,C4` → [(Some("Sheet1"), B2:D9), (None, C4)]. +pub fn parse_refs(text: &str) -> Vec<(Option, RangeRef)> { + text.split(',') + .filter_map(|part| { + let part = part.trim(); + let (sheet, body) = match part.rsplit_once('!') { + Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b), + None => (None, part), + }; + RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r)) + }) + .take(500) + .collect() +} + +/// The inputs bound to their examples, for showing tokens as A1 and A1 as tokens. +fn bound_inputs(wb: &Workbook, ed: &Editor) -> Vec<(String, Place, bool)> { + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None }; + ed.draft + .inputs + .iter() + .enumerate() + .filter_map(|(k, i)| { + let example = if k == 0 { Some(ed.example.as_str()) } else { i.default_ref.as_deref() }; + let place = resolve::resolve_ref(example?, &env).ok()?; + Some((i.name.clone(), place, i.header)) + }) + .collect() +} + +/// The suggestions offered beside a reference box. +fn token_suggestions(wb: &Workbook, ed: &Editor, in_repeat: bool) -> Vec { + let mut out = Vec::new(); + for (k, i) in ed.draft.inputs.iter().enumerate() { + let n = &i.name; + out.push(n.clone()); + if i.header { + out.push(format!("{n}.data")); + } + let width = if k == 0 { parse_refs(&ed.example).first().map_or(3, |(_, r)| r.width()) } else { 3 }.clamp(1, 6); + out.extend((1..=width).map(|c| format!("{n}.col({c})"))); + out.push(format!("{n}.rows(2..)")); + out.push(format!("{n}.cell(1,1)")); + out.push(format!("{n}.right")); + out.push(format!("{n}.below")); + } + if ed.draft.output.anchor.is_some() { + out.push("Output".into()); + out.push("Output.offset(1,0)".into()); + } + if in_repeat { + out.push("Row".into()); + out.push("Row.right".into()); + } + out.extend(wb.names.iter().take(20).map(|n| format!("Name:{}", n.name))); + out +} + +// ---------------------------------------------------------------- opening + +/// Opens the editor: `{macro}` edits a saved macro (`debug: true` also starts debugging it, +/// `check: true` checks it), `{new}` starts one with that name, `{}` one with the next free name. +pub fn open(app: &mut SheetApp, params: &Json) -> Option { + let doc = app.session.active()?; + let wb = &doc.wb; + let selection = doc.selection.current().a1(); + let found = params["macro"].as_str().and_then(|n| gridcraft_engine::macros::find(wb, n)).and_then(|i| wb.macros.get(i)).cloned(); + let (draft, saved_as) = match found { + Some(m) => { + let name = m.name.clone(); + (m, Some(name)) + } + None => { + let name = params["new"] + .as_str() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(|n| gridcraft_engine::macros::unique_name(wb, n)) + .unwrap_or_else(|| gridcraft_engine::macros::next_name(wb)); + (Macro { name, inputs: vec![NamedInput::default()], ..Default::default() }, None) + } + }; + let example = draft.inputs.first().and_then(|i| i.default_ref.clone()).unwrap_or(selection); + let start_debug = params["debug"].as_bool() == Some(true) && saved_as.is_some(); + let title = format!("Macro: {}", draft.name); + let selected = (!draft.steps.is_empty()).then(|| "1".to_string()); + app.macros.editor = Some(Editor { + draft, + dirty: saved_as.is_none(), + saved_as, + selected, + tab: Tab::Step, + input: 0, + picker: None, + check: None, + debug: None, + outcome: None, + errors: BTreeMap::new(), + ask_stop: false, + closing: false, + ask_close: false, + start_debug, + start_check: params["check"].as_bool() == Some(true), + example, + new_param: Default::default(), + refs: RefPick::default(), + }); + Some(Dialog::custom("macroEditor", &title, json!({}))) +} + +// ---------------------------------------------------------------- actions + +/// Saves the draft (renaming the saved macro if its name changed). False when it couldn't. +pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { + ed.draft.name = ed.draft.name.trim().to_string(); + if let Err(e) = ed.draft.check_limits() { + app.message = Some(("Save Macro".into(), e)); + return false; + } + if let Some(old) = ed.saved_as.clone() + && !old.eq_ignore_ascii_case(&ed.draft.name) + && let Err(e) = app.run("macro.rename", json!({"name": old, "to": ed.draft.name})) + { + app.message = Some(("Save Macro".into(), crate::clean_error(&e))); + return false; + } + match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft)})) { + Ok(_) => { + ed.saved_as = Some(ed.draft.name.clone()); + ed.dirty = false; + true + } + Err(e) => { + app.message = Some(("Save Macro".into(), crate::clean_error(&e))); + false + } + } +} + +fn ensure_saved(app: &mut SheetApp, ed: &mut Editor) -> bool { + if ed.dirty || ed.saved_as.is_none() { save(app, ed) } else { true } +} + +/// The inputs a run from the editor uses: the first input's example. +fn run_inputs(ed: &Editor) -> Json { + match ed.draft.inputs.first() { + Some(i) if !ed.example.trim().is_empty() => json!({ i.name.clone(): ed.example }), + _ => json!({}), + } +} + +/// The step path an error message names (`Step 3 (cell.set): …` → `3`). +fn error_step(msg: &str) -> Option<(String, String)> { + let rest = msg.get(msg.find("Step ")? + 5..)?; + let end = rest.find([' ', ':']).unwrap_or(rest.len()); + let path = rest.get(..end)?.to_string(); + split_path(&path)?; + Some((path, msg.to_string())) +} + +/// Check: validates the inputs and dry-runs the macro; the grid shows what it would touch. +pub fn check(app: &mut SheetApp, ed: &mut Editor) { + if !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + let name = ed.draft.name.clone(); + match app.run("macro.check", json!({"name": name, "inputs": run_inputs(ed)})) { + Ok(v) => { + for e in v["errors"].as_array().into_iter().flatten().filter_map(Json::as_str) { + if let Some((path, msg)) = error_step(e) { + ed.errors.insert(path, msg); + } + } + if let Some(e) = v["footprint"]["error"].as_object() + && let Some(step) = e.get("step").and_then(Json::as_str) + { + ed.errors.insert(step.to_string(), e.get("message").and_then(Json::as_str).unwrap_or("").to_string()); + } + ed.outcome = Some(check_text(&v)); + ed.check = Some(v); + } + Err(e) => { + ed.check = None; + ed.outcome = Some(crate::clean_error(&e)); + } + } +} + +fn check_text(v: &Json) -> String { + if let Some(errors) = v["errors"].as_array().filter(|e| !e.is_empty()) { + return errors.iter().filter_map(Json::as_str).collect::>().join(" "); + } + if let Some(asks) = v["needsApproval"].as_array() { + return format!("Needs approval before it can run: it {}.", asks.iter().filter_map(Json::as_str).collect::>().join(", ")); + } + let cells: u64 = v["footprint"]["sheets"].as_array().into_iter().flatten().filter_map(|s| s["changed"].as_u64()).sum(); + let conflicts = v["conflicts"].as_u64().unwrap_or(0); + let mut text = format!("Check passed: writes {cells} cell{}", if cells == 1 { "" } else { "s" }); + if conflicts > 0 { + text.push_str(&format!(", {conflicts} of them hold data (red)")); + } else { + text.push_str(", over no data"); + } + if v["footprint"]["partial"].is_object() { + text.push_str(". Later steps can't be previewed."); + } + text +} + +/// Run: runs the saved macro as one undo step. +pub fn run(app: &mut SheetApp, ed: &mut Editor) { + if !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + let name = ed.draft.name.clone(); + match macro_ui::call(app, "Run Macro", "macro.run", json!({"name": name, "inputs": run_inputs(ed)})) { + Some(v) => ed.outcome = Some(format!("Ran {} step{}.", v["ran"].as_u64().unwrap_or(0), if v["ran"] == 1 { "" } else { "s" })), + None => { + if let Some((_, text)) = app.message.as_ref() + && let Some((path, msg)) = error_step(text) + { + ed.errors.insert(path, msg); + } + } + } +} + +/// The breakpoints sent with each debugger action: the draft's (they replace the saved ones, so +/// a dot cleared while paused takes effect at once). +fn bp_params(ed: &Editor) -> Json { + let mut list = Vec::new(); + breakpoints(&ed.draft.steps, "", &mut list); + json!({"breakpoints": list, "breakpointsOnly": true}) +} + +fn with(mut base: Json, extra: Json) -> Json { + if let (Some(b), Some(e)) = (base.as_object_mut(), extra.as_object()) { + for (k, v) in e { + b.insert(k.clone(), v.clone()); + } + } + base +} + +/// Step, Continue or Run to Here: starts debugging if needed (paused before step 1), then acts. +pub fn debug_action(app: &mut SheetApp, ed: &mut Editor, action: &str) { + let debugging = app.session.macro_debug.is_some(); + // Starting selects the first step: take Run to Here's target before that. + let target = ed.selected.clone().filter(|s| split_path(s).is_some()); + if !debugging { + if !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + // A dry run first, so the grid shows the inputs and what the run will write. + check(app, ed); + let name = ed.draft.name.clone(); + let p = with(json!({"name": name, "inputs": run_inputs(ed), "action": "start"}), bp_params(ed)); + let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", p) else { return }; + apply_debug(ed, &v); + if action == "step" || v["state"] != "paused" { + return; + } + } + let p = match action { + "runTo" => match target { + Some(step) => json!({"action": "runTo", "step": step}), + None => return, + }, + other => json!({"action": other}), + }; + if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", with(p, bp_params(ed))) { + apply_debug(ed, &v); + } +} + +/// Stop: ends the debugged run, keeping its changes as one undo step or undoing them. +pub fn stop(app: &mut SheetApp, ed: &mut Editor, keep: bool) { + if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", json!({"action": "stop", "keep": keep})) { + apply_debug(ed, &v); + } +} + +/// Takes in a `macro.debug` result: paused (select the current step), or ended (say how). +pub fn apply_debug(ed: &mut Editor, v: &Json) { + match v["state"].as_str() { + Some("paused") => { + if let Some(step) = v["current"]["step"].as_str() { + ed.selected = Some(step.to_string()); + ed.tab = Tab::Step; + } + ed.outcome = None; + ed.debug = Some(v.clone()); + } + Some(state) => { + ed.debug = None; + let executed = v["executed"].as_u64().unwrap_or(0); + ed.outcome = Some(match state { + "finished" => format!("Finished: {executed} step{} ran, one undo step.", if executed == 1 { "" } else { "s" }), + "failed" => { + if let Some(step) = v["error"]["step"].as_str() { + let msg = v["error"]["message"].as_str().unwrap_or("").to_string(); + ed.errors.insert(step.to_string(), msg); + ed.selected = Some(step.to_string()); + } + v["message"].as_str().unwrap_or("The macro failed; nothing was changed.").to_string() + } + _ if v["kept"] == true => format!("Stopped after {executed} step{}; the changes were kept.", if executed == 1 { "" } else { "s" }), + _ => match v["stopped"].as_str() { + Some(msg) => format!("Stopped: {msg}"), + None => "Stopped; the changes were undone.".into(), + }, + }); + } + None => {} + } +} + +// ---------------------------------------------------------------- the window + +/// Draws the editor window. `open` turns false when it closes. +pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mut bool) { + let Some(mut ed) = app.macros.editor.take() else { + *open = false; + return; + }; + // A prompt's button re-ran a debugger action (making room for the output, approving). + if let Some(v) = app.macros.debug_result.take() { + apply_debug(&mut ed, &v); + } + if ed.start_check { + ed.start_check = false; + check(app, &mut ed); + } + if ed.start_debug { + ed.start_debug = false; + debug_action(app, &mut ed, "step"); + } + // The run ended elsewhere (the status bar, an agent). + if ed.debug.is_some() && app.session.macro_debug.is_none() { + ed.debug = None; + } + let dirty_mark = if ed.dirty { " •" } else { "" }; + let screen = ctx.content_rect(); + let mut win_open = true; + egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) + .id(Id::new("gridcraft.macro_editor")) + .collapsible(false) + .resizable(true) + .default_size(vec2(840.0, 560.0)) + .min_size(vec2(660.0, 380.0)) + // To the right, so the data the overlays mark (usually top left) stays in view. + .default_pos(pos2((screen.right() - 860.0).max(screen.left() + 20.0), screen.top() + 190.0)) + .open(&mut win_open) + .show(ctx, |ui| { + if let Some(key) = ed.refs.collapsed.clone() { + ref_edit(app, ui, d, &mut ed.refs, &key, 340.0); + apply_ref_value(&mut ed, d, &key); + return; + } + body(app, ui, d, &mut ed); + }); + if !win_open { + if app.session.macro_debug.is_some() { + ed.ask_stop = true; + ed.closing = true; + } else if ed.dirty { + ed.ask_close = true; + } else { + *open = false; + } + } + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && ed.picker.is_some() { + ed.picker = None; + } + prompts(app, ctx, &mut ed, open); + app.grid.macro_overlay = if *open { overlay(app, &ed) } else { None }; + if *open { + app.macros.editor = Some(ed); + } else { + restore_home(app, &ed.refs); + } +} + +// ---------------------------------------------------------------- reference boxes + +/// A range as an absolute reference: `$B$2:$D$9`. +fn abs(r: &RangeRef) -> String { + let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); + if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } +} + +/// The grid selection as a reference: absolute, comma-joined areas, with the sheet name when it +/// isn't the sheet picking started on. +fn selection_ref(app: &SheetApp, home_sheet: Option) -> String { + let Some(doc) = app.session.active() else { return String::new() }; + let areas: Vec = doc.selection.ranges.iter().map(abs).collect(); + match (home_sheet, doc.wb.active()) { + (Some(h), Some(sh)) if h != doc.wb.active_sheet => { + let q = gridcraft_engine::formula::quote_sheet(&sh.name); + areas.iter().map(|a| format!("{q}!{a}")).collect::>().join(",") + } + _ => areas.join(","), + } +} + +/// Starts picking into `key`: later grid selections are written into the box. The first pick +/// remembers the selection, so closing the editor can put it back. +fn begin_pick(app: &SheetApp, refs: &mut RefPick, key: &str) { + if refs.home.is_none() + && let Some(doc) = app.session.active() + { + let ranges: Vec = doc.selection.ranges.iter().map(|r| r.a1()).collect(); + refs.home = Some((doc.wb.active_sheet, ranges.join(","), doc.selection.active.a1())); + } + refs.pick = Some(key.to_string()); + refs.seen = selection_ref(app, refs.home.as_ref().map(|h| h.0)); +} + +/// Puts back the sheet and selection from before picking started. +fn restore_home(app: &mut SheetApp, refs: &RefPick) { + if let Some((sheet, ranges, active)) = &refs.home { + let _ = app.session.run("selection.set", json!({"sheet": sheet, "range": ranges, "active": active})); + } +} + +/// A reference box (Excel's RefEdit): a text field that grid selections fill while it is the +/// pick target, with a button that collapses the window to just this box and expands it again. +/// The text is kept in `d.values[key]`. +fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key: &str, width: f32) { + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + let sel = selection_ref(app, refs.home.as_ref().map(|h| h.0)); + let picking = refs.pick.as_deref() == Some(key); + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width - 30.0).id_salt(("refedit", key))); + if r.gained_focus() || (r.clicked() && !picking) { + begin_pick(app, refs, key); + } else if picking && !r.has_focus() && sel != refs.seen { + // A selection made on the grid becomes the reference. + s = sel.clone(); + refs.seen = sel; + } + let collapsed = refs.collapsed.as_deref() == Some(key); + let t = Tokens::get(ui.ctx()); + let (icon, tip) = if collapsed { (Icon::Expand, "Expand the editor") } else { (Icon::Collapse, "Collapse the editor to this box") }; + if crate::widgets::icon_button(ui, icon, t.text_dim, tip, vec2(22.0, 20.0)).clicked() { + refs.collapsed = if collapsed { None } else { Some(key.to_string()) }; + if !collapsed { + begin_pick(app, refs, key); + } + } + }); + d.values.insert(key.into(), json!(s)); +} + +/// Keep changes? (stopping a debugged run) and Save changes? (closing with unsaved edits). +fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut bool) { + if ed.ask_stop { + let mut answer: Option> = None; + egui::Modal::new(Id::new("gridcraft.macro_editor.stop")).show(ctx, |ui| { + ui.set_width(360.0); + ui.label(egui::RichText::new("Stop debugging").strong()); + ui.label("Keep the changes the macro has made so far? They are one undo step either way."); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + if ui.button("Undo Changes").clicked() { + answer = Some(Some(false)); + } + if ui.button("Keep Changes").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(Some(true)); + } + }); + }); + match answer { + Some(Some(keep)) => { + ed.ask_stop = false; + stop(app, ed, keep); + if ed.closing { + if ed.dirty { ed.ask_close = true } else { *open = false } + } + ed.closing = false; + } + Some(None) => { + ed.ask_stop = false; + ed.closing = false; + } + None => {} + } + return; + } + if ed.ask_close { + let mut answer: Option> = None; + egui::Modal::new(Id::new("gridcraft.macro_editor.close")).show(ctx, |ui| { + ui.set_width(360.0); + ui.label(egui::RichText::new(format!("Save changes to macro “{}”?", ed.draft.name)).strong()); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + if ui.button("Don't Save").clicked() { + answer = Some(Some(false)); + } + if ui.button("Save").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(Some(true)); + } + }); + }); + match answer { + Some(Some(true)) => { + ed.ask_close = false; + if save(app, ed) { + *open = false; + } + } + Some(Some(false)) => { + ed.ask_close = false; + *open = false; + } + Some(None) => ed.ask_close = false, + None => {} + } + } +} + +fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + let debugging = app.session.macro_debug.is_some(); + // Name, description, Save. + ui.horizontal(|ui| { + ui.label("Name:"); + let name = ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")); + ui.label("Description:"); + let desc = ui.add_enabled( + !debugging, + egui::TextEdit::singleline(&mut ed.draft.description).desired_width((ui.available_width() - 90.0).max(80.0)).id_salt("me_desc"), + ); + if name.changed() || desc.changed() { + ed.dirty = true; + } + if ui.add_enabled(ed.dirty && !debugging, egui::Button::new("Save")).clicked() { + save(app, ed); + } + }); + ui.add_space(2.0); + toolbar(app, ui, ed, debugging); + status_line(app, ui, ed, &t); + ui.separator(); + let avail = ui.available_size(); + let left_w = (avail.x * 0.52).max(300.0); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(left_w, avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(left_w, avail.y)); + if ed.picker.is_some() { + picker(ui, ed); + } else { + step_list(app, ui, ed, debugging); + } + }); + ui.separator(); + ui.allocate_ui_with_layout(vec2(ui.available_width(), avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { + egui::ScrollArea::vertical().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { + inspector(app, ui, d, ed, debugging); + }); + }); + }); +} + +fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let editing = !debugging; + if ui.add_enabled_ui(editing, |ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).inner.clicked() { + ed.picker = Some(Picker::default()); + } + let has_step = ed.selected.as_deref().and_then(split_path).is_some(); + if ui + .add_enabled_ui(editing && has_step, |ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) + .inner + .clicked() + { + duplicate(ed); + } + if ui.add_enabled_ui(editing && has_step, |ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).inner.clicked() { + delete(ed); + } + sep(ui, &t); + if ui + .add_enabled_ui(editing, |ui| { + small_button(ui, Icon::Check, "Check", "Check the inputs and show on the grid what the macro would change", false) + }) + .inner + .clicked() + { + check(app, ed); + } + if ui.add_enabled_ui(editing, |ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).inner.clicked() { + run(app, ed); + } + sep(ui, &t); + if small_button(ui, Icon::Step, "Step", if debugging { "Run the current step" } else { "Start debugging: stop before the first step" }, false) + .clicked() + { + debug_action(app, ed, "step"); + } + if small_button(ui, Icon::Continue, "Continue", "Run to the next breakpoint or the end", false).clicked() { + debug_action(app, ed, "continue"); + } + let can_run_to = ed.selected.as_deref().and_then(split_path).is_some(); + if ui.add_enabled_ui(can_run_to, |ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).inner.clicked() { + debug_action(app, ed, "runTo"); + } + if ui.add_enabled_ui(debugging, |ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).inner.clicked() { + ed.ask_stop = true; + } + }); +} + +fn sep(ui: &mut Ui, t: &Tokens) { + let (r, _) = ui.allocate_exact_size(vec2(9.0, 20.0), Sense::hover()); + ui.painter().line_segment([pos2(r.center().x, r.top() + 2.0), pos2(r.center().x, r.bottom() - 2.0)], Stroke::new(1.0, t.separator)); +} + +/// Where the run is (while debugging) or what the last check, run or debug session said, with +/// the overlay legend. +fn status_line(app: &SheetApp, ui: &mut Ui, ed: &Editor, t: &Tokens) { + if let Some(st) = &ed.debug { + let cur = &st["current"]; + let step = cur["step"].as_str().unwrap_or(""); + let what = match cur["kind"].as_str() { + Some("command") => command_label(cur["command"].as_str().unwrap_or("")), + Some(k) => k.to_string(), + None => String::new(), + }; + let mut head = format!("Paused before step {step} · {what}"); + if let (Some(at), Some(of)) = (cur["iteration"]["at"].as_u64(), cur["iteration"]["of"].as_u64()) { + head.push_str(&format!(" · pass {at} of {of}")); + } + if st["atBreakpoint"] == true { + head.push_str(" · at a breakpoint"); + } + ui.label(egui::RichText::new(head).strong().color(t.text)); + let resolved = cur.get("params").or(cur.get("over")).or(cur.get("cell")).map(|p| p.to_string()); + if let Some(p) = resolved { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Runs with:").color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(p).monospace().color(t.text)).truncate()); + }); + } else if let Some(e) = cur["paramsError"].as_str() { + ui.label(egui::RichText::new(e).color(t.danger)); + } + let changed: Vec<&str> = st["changed"].as_array().into_iter().flatten().filter_map(Json::as_str).take(6).collect(); + ui.label( + egui::RichText::new(format!("Changed so far: {}", if changed.is_empty() { "nothing".into() } else { changed.join(", ") })) + .color(t.text_dim), + ); + } else if let Some(text) = &ed.outcome { + let bad = !ed.errors.is_empty() || ed.check.as_ref().is_some_and(|c| c["ok"] == false); + ui.add(egui::Label::new(egui::RichText::new(text).color(if bad { t.danger } else { t.text })).wrap()); + } else { + ui.label( + egui::RichText::new("Check shows on the grid what the macro reads and writes. Click a step's gutter to set a breakpoint.") + .color(t.text_dim), + ); + } + if app.grid.macro_overlay.is_some() { + ui.horizontal(|ui| { + for (label, c) in [ + ("Inputs", t.sheet_macro_input), + ("Writes", t.sheet_macro_footprint), + ("Writes over data", t.sheet_macro_conflict), + ("Current step", t.sheet_macro_target), + ] { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, c); + ui.label(egui::RichText::new(label).small().color(t.text_dim)); + ui.add_space(6.0); + } + }); + } +} + +// ---------------------------------------------------------------- the step list + +/// A step being dragged: the list it is in and its place there. +#[derive(Clone, Debug)] +struct StepDrag { + parent: String, + index: usize, + label: String, +} + +fn step_list(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { + let t = Tokens::get(ui.ctx()); + let mut list = Vec::new(); + rows(&ed.draft.steps, "", 0, &mut list); + if list.is_empty() { + ui.add_space(8.0); + ui.label(egui::RichText::new("No steps yet. Add Step picks a command, or record one with Automate › Record Macro.").color(t.text_dim)); + return; + } + let current = ed.debug.as_ref().and_then(|s| s["current"]["step"].as_str().map(str::to_string)); + let dragging = egui::DragAndDrop::payload::(ui.ctx()); + let pointer = ui.ctx().pointer_latest_pos(); + let mut placed: Vec<(String, Rect)> = Vec::new(); + egui::ScrollArea::vertical().id_salt("me_steps").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 1.0; + for row in &list { + let w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click_and_drag()); + placed.push((row.parent.clone(), rect)); + let selected = ed.selected.as_deref() == Some(row.key.as_str()); + let is_current = current.as_deref() == Some(row.key.as_str()); + let p = ui.painter(); + if is_current { + p.rect_filled(rect, 3.0, t.accent_soft); + } else if selected { + p.rect_filled(rect, 3.0, t.pressed); + } else if resp.hovered() { + p.rect_filled(rect, 3.0, t.hover); + } + if resp.clicked() { + ed.selected = Some(row.key.clone()); + ed.tab = Tab::Step; + } + let indent = row.depth as f32 * 16.0; + if let Some(label) = row.branch { + p.text(pos2(rect.left() + 58.0 + indent, rect.center().y), egui::Align2::LEFT_CENTER, label, theme::ui_bold(12.0), t.text_dim); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{label} branch {}", row.parent))); + continue; + } + let Some(st) = step_at(&ed.draft.steps, &row.key) else { continue }; + // Gutter: the breakpoint dot (click to toggle), the current-step arrow. + let gutter = Rect::from_min_size(rect.min, vec2(18.0, rect.height())); + let g = ui.interact(gutter, Id::new(("me_bp", &row.key)), Sense::click()); + if st.breakpoint { + p.circle_filled(gutter.center(), 5.0, t.breakpoint); + } else if g.hovered() { + p.circle_stroke(gutter.center(), 4.5, Stroke::new(1.0, t.breakpoint)); + } + if is_current { + let c = pos2(gutter.right() + 1.0, gutter.center().y); + p.add(egui::Shape::convex_polygon(vec![c + vec2(-6.0, -5.0), c + vec2(1.0, 0.0), c + vec2(-6.0, 5.0)], t.accent, Stroke::NONE)); + } + g.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, st.breakpoint, format!("Breakpoint {}", row.key))); + if g.on_hover_text("Breakpoint: the debugger stops before this step").clicked() + && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) + { + s.breakpoint = !s.breakpoint; + ed.dirty = true; + } + p.text(pos2(rect.left() + 48.0, rect.center().y), egui::Align2::RIGHT_CENTER, &row.key, theme::ui_font(11.5), t.text_dim); + let (icon, cat) = category(&st); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 62.0 + indent, rect.center().y), vec2(16.0, 16.0)), icon, t.cat(cat)); + let text = summary(&st); + let text_color = if st.enabled { t.text } else { t.text_disabled }; + let text_left = rect.left() + 76.0 + indent; + let text_clip = Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 46.0, rect.bottom())); + p.with_clip_rect(text_clip).text(pos2(text_left, rect.center().y), egui::Align2::LEFT_CENTER, &text, theme::ui_font(12.5), text_color); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Step {}: {text}", row.key))); + // Error marker. + let error = ed.errors.get(&row.key).cloned().or_else(|| static_error(&ed.draft, &st)); + if let Some(err) = error { + let c = pos2(rect.right() - 36.0, rect.center().y); + p.circle_filled(c, 7.0, t.danger); + p.text(c, egui::Align2::CENTER_CENTER, "!", theme::ui_bold(11.0), t.window); + ui.interact(Rect::from_center_size(c, vec2(16.0, 16.0)), Id::new(("me_err", &row.key)), Sense::hover()).on_hover_text(err); + } + // Enabled. + let mut on = st.enabled; + let cb = + ui.put(Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(18.0, 18.0)), egui::Checkbox::without_text(&mut on)); + if cb.on_hover_text("Run this step (a disabled step is skipped)").changed() + && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) + { + s.enabled = on; + ed.dirty = true; + } + if !debugging { + dnd::drag_source(ui, &resp, StepDrag { parent: row.parent.clone(), index: row.index, label: text.clone() }); + } + } + }); + // Dropping a dragged step among its own list's rows. + if let (Some(drag), Some(p)) = (dragging, pointer) { + let group: Vec = + list.iter().zip(&placed).filter(|(r, _)| r.branch.is_none() && r.parent == drag.parent).map(|(_, (_, rect))| *rect).collect(); + let zone = group.iter().fold(Rect::NOTHING, |a, r| a.union(*r)); + let area = placed.iter().fold(Rect::NOTHING, |a, (_, r)| a.union(*r)).expand(12.0); + dnd::ghost(ui, &drag.label, p); + if area.contains(p) && zone.is_positive() { + dnd::drop_marker(ui, zone, &group, p.y); + } + if ui.input(|i| i.pointer.any_released()) { + let _ = egui::DragAndDrop::take_payload::(ui.ctx()); + if area.contains(p) { + let to = dnd::insert_index(&group, p.y); + if to != drag.index + && to != drag.index + 1 + && let Some(path) = move_step(&mut ed.draft.steps, &drag.parent, drag.index, to) + { + ed.selected = Some(path); + ed.errors.clear(); + ed.dirty = true; + } + } + } + } + let _ = app; +} + +/// Where a new step goes: after the selected step in its list, at the end of a selected branch +/// or Repeat, or at the end of the macro. +fn insertion_point(ed: &Editor) -> (String, usize) { + let Some(sel) = ed.selected.as_deref() else { return (String::new(), ed.draft.steps.len()) }; + let mut steps = ed.draft.steps.clone(); + if split_path(sel).is_none() { + // A branch row: the end of that branch. + let len = list_mut(&mut steps, sel).map_or(0, |l| l.len()); + return (sel.to_string(), len); + } + if let Some(st) = step_mut(&mut steps, sel) + && let StepKind::Repeat { body, .. } = &st.kind + { + return (sel.to_string(), body.len()); + } + match split_path(sel) { + Some((parent, i)) => (parent.to_string(), i + 1), + None => (String::new(), ed.draft.steps.len()), + } +} + +fn insert_step(ed: &mut Editor, step: Step) { + let (parent, at) = insertion_point(ed); + if let Some(list) = list_mut(&mut ed.draft.steps, &parent) { + let at = at.min(list.len()); + list.insert(at, step); + ed.selected = Some(child(&parent, at)); + ed.tab = Tab::Step; + ed.errors.clear(); + ed.dirty = true; + } +} + +fn duplicate(ed: &mut Editor) { + let Some(sel) = ed.selected.clone() else { return }; + let Some((parent, i)) = split_path(&sel) else { return }; + if let Some(list) = list_mut(&mut ed.draft.steps, parent) + && let Some(st) = list.get(i).cloned() + { + list.insert(i + 1, st); + ed.selected = Some(child(parent, i + 1)); + ed.errors.clear(); + ed.dirty = true; + } +} + +fn delete(ed: &mut Editor) { + let Some(sel) = ed.selected.clone() else { return }; + let Some((parent, i)) = split_path(&sel) else { return }; + if let Some(list) = list_mut(&mut ed.draft.steps, parent) + && i < list.len() + { + list.remove(i); + ed.selected = if list.is_empty() { (!parent.is_empty()).then(|| parent.to_string()) } else { Some(child(parent, i.min(list.len() - 1))) }; + ed.errors.clear(); + ed.dirty = true; + } +} + +// ---------------------------------------------------------------- Add Step + +/// The steps the picker offers for `query`: control steps first, then commands a macro may run. +pub fn picker_items(query: &str) -> Vec<(String, String, String)> { + let q = query.trim().to_ascii_lowercase(); + let mut out: Vec<(String, String, String)> = [ + ("@repeat", "Repeat for each row", "Control: run steps once per row of a range"), + ("@if", "If", "Control: run steps when a cell meets a condition"), + ("@stop", "Stop with message", "Control: end the run with a message"), + ] + .iter() + .filter(|(_, l, _)| q.is_empty() || l.to_ascii_lowercase().contains(&q)) + .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())) + .collect(); + for c in gridcraft_engine::command_specs() { + if matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)) || c.id.starts_with("ui.") { + continue; + } + if q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q) { + out.push((c.id.to_string(), c.label.to_string(), c.menu.join(" › "))); + } + if out.len() >= 60 { + break; + } + } + out +} + +/// A new step for a picker item, with the command's required parameters filled in as blanks. +pub fn new_step(item: &str) -> Step { + match item { + "@repeat" => Step::new(StepKind::Repeat { over: "Input".into(), of: RepeatOf::Rows, max: 10_000, body: vec![] }), + "@if" => { + Step::new(StepKind::If { cell: "Input.cell(1,1)".into(), op: CompareOp::NotEmpty, value: Json::Null, then: vec![], otherwise: vec![] }) + } + "@stop" => Step::new(StepKind::Stop { message: String::new(), rollback: false }), + id => { + let doc = gridcraft_engine::find_command(id).map(|c| c.params).unwrap_or(""); + let params: Map = required_keys(doc).into_iter().map(|k| (k, json!(""))).collect(); + Step::command(id, Json::Object(params)) + } + } +} + +fn picker(ui: &mut Ui, ed: &mut Editor) { + let Some(pk) = ed.picker.as_mut() else { return }; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(if pk.replace.is_some() { "Change the command" } else { "Add a step" }).strong()); + if ui.small_button("Cancel").clicked() { + pk.search = "\u{1}".into(); + } + }); + if pk.search == "\u{1}" { + ed.picker = None; + return; + } + let r = ui.add( + egui::TextEdit::singleline(&mut pk.search) + .hint_text("Search commands, e.g. bold, sort, set…") + .desired_width(f32::INFINITY) + .id_salt("me_pick"), + ); + r.request_focus(); + if r.changed() { + pk.index = 0; + } + let items = picker_items(&pk.search); + if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) { + pk.index = (pk.index + 1).min(items.len().saturating_sub(1)); + } + if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) { + pk.index = pk.index.saturating_sub(1); + } + let mut chosen = ui.input(|i| i.key_pressed(egui::Key::Enter)).then_some(pk.index); + egui::ScrollArea::vertical().id_salt("me_pick_list").auto_shrink([false, false]).show(ui, |ui| { + for (i, (id, label, menu)) in items.iter().enumerate() { + if pk.replace.is_some() && id.starts_with('@') { + continue; + } + let r = ui.add(egui::Button::selectable(i == pk.index, label.as_str()).min_size(vec2(ui.available_width(), 20.0))); + if r.clicked() { + chosen = Some(i); + } + r.on_hover_text(format!("{id} {menu}")); + } + if items.is_empty() { + ui.label(egui::RichText::new("No command matches.").color(t.text_dim)); + } + }); + let Some(item) = chosen.and_then(|i| items.get(i)).map(|x| x.0.clone()) else { return }; + let replace = pk.replace.clone(); + ed.picker = None; + match replace { + Some(path) => { + let fresh = new_step(&item); + if let Some(st) = step_mut(&mut ed.draft.steps, &path) { + st.kind = fresh.kind; + ed.dirty = true; + } + } + None => insert_step(ed, new_step(&item)), + } +} + +// ---------------------------------------------------------------- the inspector + +fn inspector(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, debugging: bool) { + ui.horizontal(|ui| { + for (tab, label) in [(Tab::Step, "Details"), (Tab::Inputs, "Inputs"), (Tab::Output, "Output")] { + if ui.selectable_label(ed.tab == tab, label).clicked() { + ed.tab = tab; + } + } + }); + ui.separator(); + ui.add_enabled_ui(!debugging, |ui| match ed.tab { + Tab::Step => step_tab(app, ui, d, ed), + Tab::Inputs => inputs_tab(app, ui, d, ed), + Tab::Output => output_tab(app, ui, d, ed), + }); +} + +/// The dialog value key of a reference box. +fn ref_key(path: &str, field: &str) -> String { + format!("ref:{path}:{field}") +} + +/// After a RefEdit box changed (typed, or picked on the grid): writes it into the draft. +fn apply_ref_value(ed: &mut Editor, d: &Dialog, key: &str) { + let Some(v) = d.values.get(key).and_then(Json::as_str).map(str::to_string) else { return }; + let mut parts = key.splitn(3, ':'); + let (_, path, field) = (parts.next(), parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if set_ref(ed, path, field, &v) { + ed.dirty = true; + } +} + +/// Writes reference text into the field a ref box edits. True when it changed. +fn set_ref(ed: &mut Editor, path: &str, field: &str, v: &str) -> bool { + match path { + "@output" => { + let new = (!v.trim().is_empty()).then(|| v.trim().to_string()); + let changed = ed.draft.output.anchor != new; + ed.draft.output.anchor = new; + changed + } + "@example" => { + let changed = ed.example != v; + ed.example = v.to_string(); + if let Some(i) = ed.draft.inputs.first_mut() + && i.default_ref.is_some() + { + i.default_ref = Some(v.to_string()); + } + changed + } + _ => { + let Some(st) = step_mut(&mut ed.draft.steps, path) else { return false }; + let slot: Option<&mut String> = match (&mut st.kind, field) { + (StepKind::Repeat { over, .. }, "over") => Some(over), + (StepKind::If { cell, .. }, "cell") => Some(cell), + _ => None, + }; + if let Some(s) = slot { + let changed = s != v; + *s = v.to_string(); + return changed; + } + if let StepKind::Command { params, .. } = &mut st.kind + && let Some(o) = params.as_object_mut() + { + let changed = o.get(field).and_then(Json::as_str) != Some(v); + o.insert(field.to_string(), json!(v)); + return changed; + } + false + } + } +} + +/// A reference box: RefEdit (type, or select on the grid), Relative/Absolute, and suggestions. +fn ref_field(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, path: &str, field: &str, value: &str, in_repeat: bool) { + let key = ref_key(path, field); + if ed.refs.pick.as_deref() != Some(key.as_str()) || d.values.get(&key).is_none() { + d.values.insert(key.clone(), json!(value)); + } + let width = (ui.available_width() - 96.0).clamp(120.0, 320.0); + ui.horizontal(|ui| { + ref_edit(app, ui, d, &mut ed.refs, &key, width); + apply_ref_value(ed, d, &key); + let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); + let Some(doc) = app.session.active() else { return }; + let wb = doc.wb.clone(); + let inputs = bound_inputs(&wb, ed); + let env = Env { wb: &wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; + let relative = !current.trim().is_empty() && resolve::is_token(¤t, &env); + if path != "@example" { + if ui.selectable_label(relative, "Rel").on_hover_text("Relative: a token that follows the input the macro runs on").clicked() && !relative + { + let place = resolve::resolve_ref(¤t, &env).ok(); + if let (Some(p), Some((name, input, _))) = (place, inputs.first()) + && p.sheet == input.sheet + { + let tok = token_for(name, input.range, p.range); + set_ref(ed, path, field, &tok); + d.values.insert(key.clone(), json!(tok)); + ed.dirty = true; + } + } + if ui.selectable_label(!relative && !current.trim().is_empty(), "Abs").on_hover_text("Absolute: always these cells").clicked() + && relative + && let Ok(p) = resolve::resolve_ref(¤t, &env) + { + let a = resolve::a1(p, &env); + set_ref(ed, path, field, &a); + d.values.insert(key.clone(), json!(a)); + ed.dirty = true; + } + let menu = ui.button("▾").on_hover_text("Tokens"); + egui::Popup::menu(&menu).show(|ui| { + ui.set_max_height(300.0); + egui::ScrollArea::vertical().show(ui, |ui| { + for tok in token_suggestions(&wb, ed, in_repeat) { + let shown = + resolve::resolve_ref(&tok, &env).map(|p| format!("{tok} {}", resolve::a1(p, &env))).unwrap_or_else(|_| tok.clone()); + if ui.button(shown).clicked() { + set_ref(ed, path, field, &tok); + d.values.insert(key.clone(), json!(tok)); + ed.dirty = true; + } + } + }); + }); + } + }); + // What a token stands for on the example. + let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); + if let Some(doc) = app.session.active() { + let wb = &doc.wb; + let inputs = bound_inputs(wb, ed); + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; + if !current.trim().is_empty() && resolve::is_token(¤t, &env) && !current.starts_with("Row") && !current.starts_with("Output") { + let t = Tokens::get(ui.ctx()); + match resolve::resolve_ref(¤t, &env) { + Ok(p) => ui.label(egui::RichText::new(format!("= {} on the example", resolve::a1(p, &env))).small().color(t.text_dim)), + Err(e) => ui.label(egui::RichText::new(e.message()).small().color(t.danger)), + }; + } + } +} + +fn step_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + let Some(path) = ed.selected.clone() else { + ui.label(egui::RichText::new("Select a step to see its settings.").color(t.text_dim)); + return; + }; + let Some(st) = step_at(&ed.draft.steps, &path) else { + ui.label( + egui::RichText::new(format!( + "Steps here run when the If is {}. Add Step puts a step in this branch.", + if path.ends_with("then") { "true" } else { "false" } + )) + .color(t.text_dim), + ); + return; + }; + let in_repeat = path.contains('.'); + let (icon, cat) = category(&st); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.cat(cat)); + let title = match &st.kind { + StepKind::Command { id, .. } => command_label(id), + StepKind::Repeat { .. } => "Repeat".into(), + StepKind::If { .. } => "If".into(), + StepKind::Stop { .. } => "Stop".into(), + }; + ui.label(egui::RichText::new(format!("Step {path} · {title}")).strong()); + if let StepKind::Command { id, .. } = &st.kind { + ui.label(egui::RichText::new(id).monospace().color(t.text_dim)); + if ui.small_button("Change…").clicked() { + ed.picker = Some(Picker { replace: Some(path.clone()), ..Default::default() }); + } + } + }); + let mut enabled = st.enabled; + let mut bp = st.breakpoint; + ui.horizontal(|ui| { + let a = ui.checkbox(&mut enabled, "Enabled").changed(); + let b = ui.checkbox(&mut bp, "Breakpoint").changed(); + if (a || b) + && let Some(s) = step_mut(&mut ed.draft.steps, &path) + { + s.enabled = enabled; + s.breakpoint = bp; + ed.dirty = true; + } + }); + if let Some(e) = ed.errors.get(&path).cloned().or_else(|| static_error(&ed.draft, &st)) { + ui.label(egui::RichText::new(e).color(t.danger)); + } + ui.add_space(4.0); + match st.kind.clone() { + StepKind::Command { id, params } => command_fields(app, ui, d, ed, &path, &id, ¶ms, in_repeat), + StepKind::Repeat { over, of, max, .. } => { + ui.label("For each"); + let mut of2 = of; + ui.horizontal(|ui| { + ui.radio_value(&mut of2, RepeatOf::Rows, "row"); + ui.radio_value(&mut of2, RepeatOf::Columns, "column"); + ui.label("of:"); + }); + ref_field(app, ui, d, ed, &path, "over", &over, in_repeat); + let mut max2 = max; + ui.horizontal(|ui| { + ui.label("At most"); + ui.add(egui::DragValue::new(&mut max2).range(1..=100_000)); + ui.label("times"); + }); + if (of2 != of || max2 != max) + && let Some(StepKind::Repeat { of, max, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *of = of2; + *max = max2; + ed.dirty = true; + } + ui.label(egui::RichText::new("Inside, Row (or Column) stands for the current one, e.g. Row.right.").small().color(t.text_dim)); + } + StepKind::If { cell, op, value, .. } => { + ui.label("If cell"); + ref_field(app, ui, d, ed, &path, "cell", &cell, in_repeat); + let mut op2 = op; + let ops = [ + (CompareOp::Eq, "equals"), + (CompareOp::Ne, "doesn't equal"), + (CompareOp::Gt, "is greater than"), + (CompareOp::Ge, "is at least"), + (CompareOp::Lt, "is less than"), + (CompareOp::Le, "is at most"), + (CompareOp::Empty, "is empty"), + (CompareOp::NotEmpty, "isn't empty"), + (CompareOp::Contains, "contains"), + ]; + let label = ops.iter().find(|(o, _)| *o == op).map_or("", |x| x.1); + egui::ComboBox::from_id_salt(("me_op", &path)).selected_text(label).show_ui(ui, |ui| { + for (o, l) in ops { + ui.selectable_value(&mut op2, o, l); + } + }); + let mut text = match &value { + Json::String(s) => s.clone(), + Json::Null => String::new(), + other => other.to_string(), + }; + let changed_value = if matches!(op2, CompareOp::Empty | CompareOp::NotEmpty) { + false + } else { + ui.add(egui::TextEdit::singleline(&mut text).hint_text("value").id_salt(("me_ifv", &path))).changed() + }; + if (op2 != op || changed_value) + && let Some(StepKind::If { op, value, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *op = op2; + if changed_value { + *value = scalar(&text); + } + ed.dirty = true; + } + } + StepKind::Stop { message, rollback } => { + let (mut m, mut rb) = (message.clone(), rollback); + ui.label("Message:"); + let a = ui.add(egui::TextEdit::singleline(&mut m).desired_width(f32::INFINITY).id_salt(("me_stop", &path))).changed(); + let b = ui.checkbox(&mut rb, "Undo what the macro did before stopping").changed(); + if (a || b) + && let Some(StepKind::Stop { message, rollback }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *message = m; + *rollback = rb; + ed.dirty = true; + } + } + } + ui.add_space(6.0); + ui.label("Note:"); + let mut note = st.note.clone(); + if ui.add(egui::TextEdit::multiline(&mut note).desired_rows(2).desired_width(f32::INFINITY).id_salt(("me_note", &path))).changed() + && let Some(s) = step_mut(&mut ed.draft.steps, &path) + { + s.note = note; + ed.dirty = true; + } +} + +/// Typed text as a JSON value: a number, TRUE/FALSE, or text. +fn scalar(text: &str) -> Json { + let t = text.trim(); + if let Ok(n) = t.parse::() + && n.is_finite() + { + return json!(n); + } + match t.to_ascii_lowercase().as_str() { + "true" => json!(true), + "false" => json!(false), + _ => json!(text), + } +} + +#[allow(clippy::too_many_arguments)] +fn command_fields(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, path: &str, id: &str, params: &Json, in_repeat: bool) { + let t = Tokens::get(ui.ctx()); + let fields: Vec<(String, Json)> = params.as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default(); + let mut remove: Option = None; + let mut set: Option<(String, Json)> = None; + egui::Grid::new(("me_params", path)).num_columns(3).spacing(vec2(8.0, 4.0)).show(ui, |ui| { + for (k, v) in &fields { + ui.label(k.as_str()); + match v { + Json::String(s) if resolve::is_ref_key(k) => { + ui.vertical(|ui| ref_field(app, ui, d, ed, path, k, s, in_repeat)); + } + Json::String(s) => { + let mut text = s.clone(); + if ui.add(egui::TextEdit::singleline(&mut text).desired_width(220.0).id_salt(("me_p", path, k))).changed() { + set = Some((k.clone(), json!(text))); + } + } + Json::Bool(b) => { + let mut on = *b; + if ui.checkbox(&mut on, "").changed() { + set = Some((k.clone(), json!(on))); + } + } + Json::Number(n) => { + let mut x = n.as_f64().unwrap_or(0.0); + if ui.add(egui::DragValue::new(&mut x).speed(0.1)).changed() { + set = Some((k.clone(), json!(x))); + } + } + other => { + let jk = format!("json:{path}:{k}"); + let mut text = d.values.get(&jk).and_then(Json::as_str).map(str::to_string).unwrap_or_else(|| other.to_string()); + let ok = serde_json::from_str::(&text).is_ok(); + let r = ui.add( + egui::TextEdit::singleline(&mut text) + .desired_width(220.0) + .font(egui::TextStyle::Monospace) + .text_color(if ok { t.text } else { t.danger }) + .id_salt(("me_pj", path, k)), + ); + if r.changed() { + if let Ok(v) = serde_json::from_str::(&text) { + set = Some((k.clone(), v)); + } + d.values.insert(jk, json!(text)); + } + } + } + if crate::widgets::icon_button(ui, Icon::Close, t.text_dim, "Remove this parameter", vec2(18.0, 18.0)).clicked() { + remove = Some(k.clone()); + } + ui.end_row(); + } + }); + if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) { + if !params.is_object() { + *params = json!({}); + } + if let Some(o) = params.as_object_mut() { + if let Some((k, v)) = set { + o.insert(k, v); + ed.dirty = true; + } + if let Some(k) = remove { + o.remove(&k); + ed.dirty = true; + } + } + } + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut ed.new_param.0).hint_text("parameter").desired_width(90.0).id_salt("me_np_k")); + ui.add(egui::TextEdit::singleline(&mut ed.new_param.1).hint_text("value").desired_width(140.0).id_salt("me_np_v")); + let key = ed.new_param.0.trim().to_string(); + if ui.add_enabled(!key.is_empty(), egui::Button::new("Add")).clicked() { + let raw = ed.new_param.1.clone(); + let v = serde_json::from_str::(&raw).unwrap_or(Json::String(raw)); + if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) + && let Some(o) = params.as_object_mut() + { + o.insert(key, v); + ed.dirty = true; + } + ed.new_param = Default::default(); + } + }); + if let Some(spec) = gridcraft_engine::find_command(id) + && !spec.params.is_empty() + { + ui.add_space(4.0); + ui.add(egui::Label::new(egui::RichText::new(spec.params).small().color(t.text_dim)).wrap()); + } +} + +fn inputs_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("The ranges the macro works on. Each is checked before the macro runs.").small().color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + for (k, i) in ed.draft.inputs.iter().enumerate() { + if ui.selectable_label(ed.input == k, i.name.as_str()).clicked() { + ed.input = k; + } + } + if ui.small_button("Add").clicked() && ed.draft.inputs.len() < 64 { + let n = (2..).map(|k| format!("Input{k}")).find(|n| !ed.draft.inputs.iter().any(|i| i.name.eq_ignore_ascii_case(n))).unwrap_or_default(); + ed.draft.inputs.push(NamedInput { name: n, ..Default::default() }); + ed.input = ed.draft.inputs.len() - 1; + ed.dirty = true; + } + if ui.add_enabled(!ed.draft.inputs.is_empty(), egui::Button::new("Remove").small()).clicked() && ed.input < ed.draft.inputs.len() { + ed.draft.inputs.remove(ed.input); + ed.input = ed.input.saturating_sub(1); + ed.dirty = true; + } + }); + let k = ed.input; + let Some(mut i) = ed.draft.inputs.get(k).cloned() else { + ui.label(egui::RichText::new("No inputs: the macro works on fixed cells only.").color(t.text_dim)); + return; + }; + let before = i.clone(); + ui.separator(); + egui::Grid::new("me_input").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut i.name).desired_width(140.0).id_salt("me_in_name")); + ui.end_row(); + ui.label("Shape:"); + ui.horizontal(|ui| { + ui.radio_value(&mut i.kind, InputKind::Range, "range"); + ui.radio_value(&mut i.kind, InputKind::Cell, "single cell"); + }); + ui.end_row(); + if i.kind == InputKind::Range { + ui.label("Data rows:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut i.rows[0]).range(0..=1_048_576).prefix("at least ")); + ui.add( + egui::DragValue::new(&mut i.rows[1]) + .range(0..=1_048_576) + .prefix("at most ") + .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), + ); + }); + ui.end_row(); + ui.label("Columns:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut i.cols[0]).range(0..=16_384).prefix("at least ")); + ui.add( + egui::DragValue::new(&mut i.cols[1]) + .range(0..=16_384) + .prefix("at most ") + .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), + ); + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut i.header, "First row holds column names"); + ui.end_row(); + } + ui.label(""); + ui.checkbox(&mut i.non_empty, "Every cell must hold something"); + ui.end_row(); + }); + // Column types, one per column of the example (or of the list so far). + let ncols = if k == 0 { parse_refs(&ed.example).first().map_or(0, |(_, r)| r.width() as usize) } else { 0 }.max(i.col_types.len()).min(16); + if i.kind == InputKind::Range && ncols > 0 { + ui.label("Each column must hold:"); + let headers = if k == 0 { example_headers(app, &ed.example, i.header) } else { vec![] }; + egui::Grid::new("me_types").num_columns(2).show(ui, |ui| { + for c in 0..ncols { + let label = headers.get(c).filter(|h| !h.is_empty()).cloned().unwrap_or_else(|| format!("Column {}", c + 1)); + ui.label(label); + let mut ty = i.col_types.get(c).copied().unwrap_or(ColType::Any); + let before_ty = ty; + egui::ComboBox::from_id_salt(("me_ty", c)).selected_text(type_name(ty)).show_ui(ui, |ui| { + for x in [ColType::Any, ColType::Number, ColType::Text, ColType::Date, ColType::Bool] { + ui.selectable_value(&mut ty, x, type_name(x)); + } + }); + if ty != before_ty { + while i.col_types.len() <= c { + i.col_types.push(ColType::Any); + } + if let Some(slot) = i.col_types.get_mut(c) { + *slot = ty; + } + } + ui.end_row(); + } + }); + } + if i != before { + if let Some(slot) = ed.draft.inputs.get_mut(k) { + *slot = i; + } + ed.dirty = true; + } + if k == 0 { + ui.separator(); + ui.label("Example (what Check, Run and Debug use here):"); + let example = ed.example.clone(); + ref_field(app, ui, d, ed, "@example", "example", &example, false); + ui.horizontal(|ui| { + if ui.button("Use current selection as example").clicked() { + use_selection_as_example(app, ed); + } + let mut fixed = ed.draft.inputs.first().is_some_and(|i| i.default_ref.is_some()); + if ui.checkbox(&mut fixed, "Always run on this range").on_hover_text("Otherwise the macro runs on the selection").changed() + && let Some(first) = ed.draft.inputs.first_mut() + { + first.default_ref = fixed.then(|| ed.example.clone()); + ed.dirty = true; + } + }); + } +} + +fn type_name(t: ColType) -> &'static str { + match t { + ColType::Any => "anything", + ColType::Number => "numbers", + ColType::Text => "text", + ColType::Date => "dates", + ColType::Bool => "TRUE or FALSE", + } +} + +/// The header texts of the example's first row (when it has a header). +fn example_headers(app: &SheetApp, example: &str, header: bool) -> Vec { + let (Some(doc), true) = (app.session.active(), header) else { return vec![] }; + let wb = &doc.wb; + let Some((sheet, r)) = parse_refs(example).into_iter().next() else { return vec![] }; + let si = sheet.and_then(|s| wb.sheet_index(&s)).unwrap_or(wb.active_sheet); + let Some(sh) = wb.sheet(si) else { return vec![] }; + (r.start.col..=r.end.col.min(r.start.col.saturating_add(15))) + .map(|c| gridcraft_engine::display::cell_text(wb, sh, gridcraft_engine::core::CellRef::new(r.start.row, c))) + .collect() +} + +/// "Use current selection as example": the selection becomes the first input's example, and its +/// shape, header and column types are read from it. +pub fn use_selection_as_example(app: &SheetApp, ed: &mut Editor) { + let Some(doc) = app.session.active() else { return }; + let r = doc.selection.current(); + let Some(sh) = doc.wb.active() else { return }; + ed.example = r.a1(); + let Some(first) = ed.draft.inputs.first_mut() else { return }; + let inferred = infer_input(sh, r); + first.kind = if r.is_single() { InputKind::Cell } else { InputKind::Range }; + first.header = inferred.0; + first.col_types = inferred.1; + first.cols = [r.width(), r.width()]; + first.rows = [1, 0]; + if first.default_ref.is_some() { + first.default_ref = Some(ed.example.clone()); + } + ed.dirty = true; +} + +/// Whether the first row of `r` looks like a header (all text over a row that isn't), and what +/// each column holds below it. +pub fn infer_input(sh: &gridcraft_engine::model::Sheet, r: RangeRef) -> (bool, Vec) { + use gridcraft_engine::core::{CellRef, Value}; + let kind = |v: &Value| match v { + Value::Number(_) => Some(ColType::Number), + Value::Text(_) => Some(ColType::Text), + Value::Bool(_) => Some(ColType::Bool), + _ => None, + }; + let rows = r.height().min(2_000); + let cols = r.width().min(16); + let first: Vec> = (0..cols).map(|c| kind(&sh.value(CellRef::new(r.start.row, r.start.col + c)))).collect(); + let header = rows > 1 + && first.iter().all(|k| *k == Some(ColType::Text)) + && (0..cols).any(|c| matches!(kind(&sh.value(CellRef::new(r.start.row + 1, r.start.col + c))), Some(ColType::Number | ColType::Bool))); + let start = r.start.row + u32::from(header); + let types = (0..cols) + .map(|c| { + let mut seen: Option = None; + for row in start..r.start.row + rows { + match (kind(&sh.value(CellRef::new(row, r.start.col + c))), seen) { + (None, _) => {} + (Some(k), None) => seen = Some(k), + (Some(k), Some(s)) if k == s => {} + _ => return ColType::Any, + } + } + seen.unwrap_or(ColType::Any) + }) + .collect(); + (header, types) +} + +fn output_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + ui.label("Output anchor (what the Output token stands for):"); + let anchor = ed.draft.output.anchor.clone().unwrap_or_default(); + ref_field(app, ui, d, ed, "@output", "anchor", &anchor, false); + ui.label( + egui::RichText::new("Leave it empty when no step uses Output. A token such as Input.right keeps it beside the data.") + .small() + .color(t.text_dim), + ); + ui.add_space(6.0); + ui.label("When results would land on cells that hold data:"); + let mut rule = ed.draft.output.on_conflict; + for (r, label) in [ + (OnConflict::Ask, "Ask (overwrite, shift down or shift right)"), + (OnConflict::Fail, "Don't run"), + (OnConflict::Overwrite, "Overwrite the data"), + (OnConflict::ShiftDown, "Shift the data down"), + (OnConflict::ShiftRight, "Shift the data right"), + ] { + ui.radio_value(&mut rule, r, label); + } + if rule != ed.draft.output.on_conflict { + ed.draft.output.on_conflict = rule; + ed.dirty = true; + } + ui.label(egui::RichText::new("Agents and other programs that run the macro get an error instead of the question.").small().color(t.text_dim)); + if !ed.draft.permissions.is_empty() { + ui.add_space(6.0); + ui.label(format!("May use files through: {}", ed.draft.permissions.join(", "))); + } +} + +// ---------------------------------------------------------------- grid overlays + +/// What the grid shows while the editor is open: the inputs (blue), the cells the macro writes +/// (green), the ones among them that hold data (red), and the current step's target (outlined). +/// Each range is on the named sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MacroOverlay { + pub inputs: Vec<(String, RangeRef)>, + pub footprint: Vec<(String, RangeRef)>, + pub conflicts: Vec<(String, RangeRef)>, + pub target: Vec<(String, RangeRef)>, +} + +impl MacroOverlay { + fn is_empty(&self) -> bool { + self.inputs.is_empty() && self.footprint.is_empty() && self.conflicts.is_empty() && self.target.is_empty() + } +} + +fn named_refs(text: &str, default_sheet: &str) -> Vec<(String, RangeRef)> { + parse_refs(text).into_iter().map(|(s, r)| (s.unwrap_or_else(|| default_sheet.to_string()), r)).collect() +} + +/// The overlay for the editor's last check and debugger state. +pub fn overlay(app: &SheetApp, ed: &Editor) -> Option { + let sheet = app.session.active()?.wb.active()?.name.clone(); + let mut o = MacroOverlay::default(); + if let Some(c) = &ed.check { + for v in c["inputs"].as_object().into_iter().flat_map(|m| m.values()).filter_map(Json::as_str) { + o.inputs.extend(named_refs(v, &sheet)); + } + for s in c["footprint"]["sheets"].as_array().into_iter().flatten() { + let name = s["sheet"].as_str().unwrap_or(&sheet).to_string(); + for v in s["cells"].as_array().into_iter().flatten().filter_map(Json::as_str) { + o.footprint.extend(named_refs(v, &name)); + } + for v in s["conflicts"].as_array().into_iter().flatten().filter_map(Json::as_str) { + o.conflicts.extend(named_refs(v, &name)); + } + } + } + if let Some(st) = &ed.debug { + let cur = &st["current"]; + if let Some(p) = cur["params"].as_object() { + for (k, v) in p { + if resolve::is_ref_key(k) + && let Some(s) = v.as_str() + { + o.target.extend(named_refs(s, &sheet)); + } + } + } + for k in ["over", "cell"] { + if let Some(s) = cur[k].as_str() { + o.target.extend(named_refs(s, &sheet)); + } + } + } + (!o.is_empty()).then_some(o) +} + +/// Paints the overlay's ranges on `sh` (called from the grid's overlay pass). +pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_engine::model::Sheet, o: &MacroOverlay) { + let t = Tokens::get(p.ctx()); + let wash = |c: Color32, a: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a); + let layers: [(&Vec<(String, RangeRef)>, Color32, u8, f32); 3] = [ + (&o.footprint, t.sheet_macro_footprint, 30, 1.5), + (&o.inputs, t.sheet_macro_input, 26, 2.0), + (&o.conflicts, t.sheet_macro_conflict, 60, 2.0), + ]; + for (list, c, alpha, width) in layers { + for (name, r) in list.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { + let rect = geo.range_rect(sh, *r); + if !p.clip_rect().intersects(rect) { + continue; + } + p.rect_filled(rect, 0.0, wash(c, alpha)); + p.rect_stroke(rect, 0.0, Stroke::new(width, c), StrokeKind::Inside); + let _ = name; + } + } + for (_, r) in o.target.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { + let rect = geo.range_rect(sh, *r).expand(1.0); + p.rect_stroke(rect, 1.0, Stroke::new(2.5, t.sheet_macro_target), StrokeKind::Outside); + for corner in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + p.rect_filled(Rect::from_center_size(corner, vec2(5.0, 5.0)), 0.0, t.sheet_macro_target); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn steps() -> Vec { + vec![ + Step::command("cell.set", json!({"cell": "A1", "input": "x"})), + Step::new(StepKind::Repeat { + over: "Input".into(), + of: RepeatOf::Rows, + max: 10, + body: vec![Step::command("cell.set", json!({"cell": "Row.right", "input": "a"})), Step::command("home.bold", json!({}))], + }), + Step::new(StepKind::If { + cell: "B1".into(), + op: CompareOp::Empty, + value: Json::Null, + then: vec![Step::new(StepKind::Stop { message: "empty".into(), rollback: false })], + otherwise: vec![], + }), + ] + } + + #[test] + fn paths_reach_nested_steps() { + let mut s = steps(); + assert_eq!(split_path("3.then.1"), Some(("3.then", 0))); + assert_eq!(split_path("3.then"), None); + assert_eq!(split_path("0"), None); + assert!(matches!(step_at(&s, "2.2").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); + assert!(matches!(step_at(&s, "3.then.1").map(|s| s.kind), Some(StepKind::Stop { .. }))); + assert!(step_at(&s, "3.else.1").is_none()); + assert!(step_at(&s, "1.1").is_none(), "a command has no body"); + assert!(step_at(&s, "9").is_none()); + assert!(list_mut(&mut s, "3.else").is_some_and(|l| l.is_empty())); + } + + #[test] + fn rows_flatten_with_branches() { + let mut out = Vec::new(); + rows(&steps(), "", 0, &mut out); + let keys: Vec<&str> = out.iter().map(|r| r.key.as_str()).collect(); + assert_eq!(keys, ["1", "2", "2.1", "2.2", "3", "3.then", "3.then.1", "3.else"]); + assert_eq!(out.iter().find(|r| r.key == "2.2").map(|r| (r.parent.as_str(), r.index, r.depth)), Some(("2", 1, 1))); + assert_eq!(out.iter().find(|r| r.key == "3.then").and_then(|r| r.branch), Some("Then")); + } + + #[test] + fn moving_a_step_within_its_list() { + let mut s = steps(); + // Step 1 dropped after step 2 (before row 2 of 0..3). + assert_eq!(move_step(&mut s, "", 0, 2).as_deref(), Some("2")); + assert!(matches!(&s[0].kind, StepKind::Repeat { .. })); + // Inside the Repeat: the second step to the top. + assert_eq!(move_step(&mut s, "1", 1, 0).as_deref(), Some("1.1")); + assert!(matches!(step_at(&s, "1.1").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); + assert_eq!(move_step(&mut s, "", 7, 0), None); + // Past the end lands last. + assert_eq!(move_step(&mut s, "", 0, 99).as_deref(), Some("3")); + } + + #[test] + fn breakpoints_are_listed_by_path() { + let mut s = steps(); + for p in ["1", "2.2", "3.then.1"] { + if let Some(st) = step_mut(&mut s, p) { + st.breakpoint = true; + } + } + let mut out = Vec::new(); + breakpoints(&s, "", &mut out); + assert_eq!(out, ["1", "2.2", "3.then.1"]); + } + + #[test] + fn summaries_and_categories() { + let s = steps(); + assert_eq!(summary(&s[0]), "Set A1 to x"); + assert_eq!(summary(&s[1]), "Repeat for each row of Input"); + assert_eq!(summary(&s[2]), "If B1 is empty"); + assert_eq!(category(&s[0]), (Icon::Sheet, Cat::Blue)); + assert_eq!(category(&s[1]).1, Cat::Purple); + let long = Step::command("home.bold", json!({"range": "A1:B2"})); + assert!(summary(&long).contains("range: A1:B2"), "{}", summary(&long)); + } + + #[test] + fn static_errors_flag_unknown_and_denied_commands() { + let m = Macro::default(); + assert!(static_error(&m, &Step::command("no.such", json!({}))).is_some_and(|e| e.contains("no command"))); + assert!(static_error(&m, &Step::command("file.save", json!({}))).is_some_and(|e| e.contains("can't run in a macro"))); + assert!(static_error(&m, &Step::command("data.getData", json!({"path": "/tmp/x.csv"}))).is_some_and(|e| e.contains("permissions"))); + assert_eq!(static_error(&m, &Step::command("cell.set", json!({"cell": "A1"}))), None); + } + + #[test] + fn required_keys_come_from_the_params_doc() { + assert_eq!(required_keys("{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}"), ["cell", "input"]); + assert_eq!(required_keys("{range?, options: {a, b}, on?: bool}"), ["range", "options"]); + assert!(required_keys("no braces").is_empty()); + assert!(required_keys("{").is_empty()); + } + + #[test] + fn the_picker_offers_control_steps_and_allowed_commands_only() { + let all = picker_items(""); + assert_eq!(all.first().map(|x| x.0.as_str()), Some("@repeat")); + // Denied commands aren't offered (exports without a path are allowed). + let all = picker_items("e"); + assert!(!all.iter().any(|x| x.0 == "file.save" || x.0 == "edit.undo" || x.0.starts_with("macro."))); + let bold = picker_items("bold"); + assert!(bold.iter().any(|x| x.0 == "home.bold")); + assert!(matches!(new_step("@if").kind, StepKind::If { .. })); + assert!(matches!(new_step("cell.set").kind, StepKind::Command { params, .. } if params.get("cell").is_some())); + } + + #[test] + fn tokens_for_ranges_resolve_back_to_them() { + let wb = Workbook::default(); + let input = RangeRef::parse("B2:D9").unwrap_or(RangeRef::cell(gridcraft_engine::core::CellRef::new(0, 0))); + let inputs = vec![("Input".to_string(), Place { sheet: 0, range: input }, false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for (target, want) in [ + ("B2:D9", "Input"), + ("C2:C9", "Input.col(2)"), + ("B2:C9", "Input.cols(1..2)"), + ("B3:D9", "Input.rows(2..8)"), + ("B4:D4", "Input.row(3)"), + ("C3", "Input.cell(2,2)"), + ("C3:D4", "Input.cell(2,2).resize(2,2)"), + ("F2:H9", "Input.offset(0,4)"), + ("E2", "Input.cell(1,1).offset(0,3)"), + ("E2:E9", "Input.cell(1,1).offset(0,3).resize(8,1)"), + ] { + let r = RangeRef::parse(target).unwrap_or(input); + let tok = token_for("Input", input, r); + assert_eq!(tok, want, "{target}"); + assert_eq!(resolve::resolve_ref(&tok, &env).map(|p| p.range), Ok(r), "{tok}"); + } + } + + #[test] + fn references_parse_with_sheets_and_dollars() { + let refs = parse_refs("'My Sheet'!$B$2:$C$3, D4"); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].0.as_deref(), Some("My Sheet")); + assert_eq!(refs[0].1.a1(), "B2:C3"); + assert_eq!(refs[1], (None, RangeRef::parse("D4").unwrap_or(refs[0].1))); + assert!(parse_refs("not a ref").is_empty()); + } + + #[test] + fn inputs_are_inferred_from_an_example() { + use gridcraft_engine::core::{CellRef, Value}; + let mut sh = gridcraft_engine::model::Sheet::new("S"); + for (c, h) in ["Item", "Qty"].iter().enumerate() { + sh.set_value(CellRef::new(0, c as u32), Value::Text((*h).into())); + } + for r in 1..4 { + sh.set_value(CellRef::new(r, 0), Value::Text(format!("x{r}").into())); + sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r))); + } + let (header, types) = infer_input(&sh, RangeRef::parse("A1:B4").unwrap_or(RangeRef::cell(CellRef::new(0, 0)))); + assert!(header); + assert_eq!(types, [ColType::Text, ColType::Number]); + } +} diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 2e650522e..833973659 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -26,11 +26,15 @@ pub struct MacroUi { pub recent: Vec, /// A question from a macro command, answered with one of its buttons. pub question: Option, + /// The step editor, while it is open. + pub editor: Option, + /// A debugger result from a question's button, for the editor to take in. + pub debug_result: Option, } impl Default for MacroUi { fn default() -> Self { - MacroUi { relative: true, recent: vec![], question: None } + MacroUi { relative: true, recent: vec![], question: None, editor: None, debug_result: None } } } @@ -150,6 +154,10 @@ pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> /// After a macro command ran (directly or from a prompt's button): remember the macro and say /// what it did. pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { + if id == "macro.debug" { + app.macros.debug_result = Some(v.clone()); + return; + } if id != "macro.run" { return; } @@ -208,10 +216,24 @@ pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { } } -/// The recording indicator in the status bar: a red dot, "Recording · 7 steps" and Stop. +/// The status bar while a macro is debugged ("Debugging “Name”", with Show when the editor is +/// closed) and while recording (a red dot, "Recording · 7 steps" and Stop). pub fn status(app: &mut SheetApp, ui: &mut Ui) { - let Some(n) = recorded_steps(app) else { return }; let t = Tokens::get(ui.ctx()); + if let Some(name) = app.session.macro_debug.as_ref().map(|d| d.name.clone()) { + ui.add_space(12.0); + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, t.breakpoint); + ui.label(egui::RichText::new(format!("Debugging “{name}”")).font(theme::ui_font(12.5)).color(t.text)); + if app.macros.editor.is_none() { + let show = + ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Show")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0))); + if show.on_hover_text("Open the macro in the step editor").clicked() { + app.open_dialog("macroEditor", json!({"macro": name})); + } + } + } + let Some(n) = recorded_steps(app) else { return }; ui.add_space(12.0); let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); ui.painter().circle_filled(r.center(), 4.5, t.recording); @@ -390,6 +412,23 @@ pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut *open = false; run_entry(app, e); } + if ui + .add_enabled(is_macro, egui::Button::new("Step Into").min_size(w)) + .on_hover_text("Open the macro in the step editor and stop before its first step") + .clicked() + { + *open = false; + app.open_dialog("macroEditor", json!({"macro": target, "debug": true})); + } + if ui.add_enabled(is_macro, egui::Button::new("Edit").min_size(w)).clicked() { + *open = false; + app.open_dialog("macroEditor", json!({"macro": target})); + } + let free = !target.is_empty() && !list.iter().any(|e| e.kind == Kind::Macro && e.name.eq_ignore_ascii_case(&target)); + if ui.add_enabled(free, egui::Button::new("Create").min_size(w)).on_hover_text("Write a new macro with this name").clicked() { + *open = false; + app.open_dialog("macroEditor", json!({"new": target})); + } if ui.add_enabled(found.is_some(), egui::Button::new("Delete").min_size(w)).clicked() && let Some(e) = &found { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 87d0ed176..5c0beeba8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2587,6 +2587,9 @@ fn automate(app: &mut SheetApp, ui: &mut Ui) { if r.clicked() && !recording { app.macros.relative = !rel; } + if small_button(ui, Icon::Macro, "New Macro", "Write a macro step by step", false).clicked() { + app.open_dialog("macroEditor", json!({})); + } }); let macros = crate::macro_ui::macro_list(app); let recent: Vec = app.macros.recent.iter().filter(|r| macros.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned().collect(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7267694b9..f6d4efcad 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,14 @@ pub struct Tokens { pub sheet_track: Color32, /// The macro recording dot (Record Macro, the status bar). pub recording: Color32, + /// A breakpoint dot in the macro step editor's gutter. + pub breakpoint: Color32, + /// Macro overlays on the sheet (Check and debugging): the inputs, the cells the macro writes, + /// the cells it would write over, and the current step's target. Sheet colours. + pub sheet_macro_input: Color32, + pub sheet_macro_footprint: Color32, + pub sheet_macro_conflict: Color32, + pub sheet_macro_target: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -110,6 +118,12 @@ const fn rgb(hex: u32) -> Color32 { /// theme). The light theme's formula bar is white too, so it uses them as well. const SHEET_REF_COLORS: [Color32; 8] = [rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)]; +/// Macro overlays on the sheet, the same in both themes (the sheet stays white). +const SHEET_MACRO_INPUT: Color32 = rgb(0x2E6FD8); +const SHEET_MACRO_FOOTPRINT: Color32 = rgb(0x23803D); +const SHEET_MACRO_CONFLICT: Color32 = rgb(0xC42B1C); +const SHEET_MACRO_TARGET: Color32 = rgb(0x6B3FC0); + /// Code colours for one background; plain text uses the surface's own text colour. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Syntax { @@ -185,6 +199,11 @@ impl Tokens { sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, recording: rgb(0xC42B1C), + breakpoint: rgb(0xD13438), + sheet_macro_input: SHEET_MACRO_INPUT, + sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, + sheet_macro_conflict: SHEET_MACRO_CONFLICT, + sheet_macro_target: SHEET_MACRO_TARGET, } } @@ -243,6 +262,11 @@ impl Tokens { sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, recording: rgb(0xF1705F), + breakpoint: rgb(0xF1707A), + sheet_macro_input: SHEET_MACRO_INPUT, + sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, + sheet_macro_conflict: SHEET_MACRO_CONFLICT, + sheet_macro_target: SHEET_MACRO_TARGET, } } @@ -1076,6 +1100,20 @@ mod contrast_tests { add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH); add("recording dot on ribbon", t.recording, t.ribbon, GLYPH); add("recording dot on status bar", t.recording, t.status_bar, GLYPH); + for (name, c) in [ + ("macro input", t.sheet_macro_input), + ("macro footprint", t.sheet_macro_footprint), + ("macro conflict", t.sheet_macro_conflict), + ("macro target", t.sheet_macro_target), + ] { + add(&format!("{name} outline on sheet"), c, t.grid_bg, GLYPH); + } + // The macro step editor: breakpoint dots in the gutter, error markers, the current step. + add("breakpoint on window", t.breakpoint, t.window, GLYPH); + add("breakpoint on current step", t.breakpoint, t.accent_soft, GLYPH); + add("text on current step", t.text, t.accent_soft, TEXT); + add("current step arrow", t.accent, t.accent_soft, GLYPH); + add("error marker on window", t.danger, t.window, GLYPH); v } @@ -1110,6 +1148,10 @@ mod contrast_tests { (l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track), (d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track) ); + assert_eq!( + (l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target), + (d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target) + ); for c in Cat::ALL { assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own"); } diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 431be6d72..f65621d4d 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -665,6 +665,8 @@ fn dialogs_open_and_close() { "pivotCalculatedField", "about", "macros", + "recordMacro", + "macroEditor", "analysisToolPak", "solver", ] { diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs new file mode 100644 index 000000000..3f9cda896 --- /dev/null +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -0,0 +1,215 @@ +//! The macro step editor: editing, Add Step, breakpoints, dragging steps, and the debugger +//! (step, continue, run to the end as one undo step, stop with keep or undo). + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::StepKind; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, a: &str) -> Value { + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() +} + +/// Three steps that write A1, A2, A3. +fn three(h: &mut Harness) { + let steps: Vec<_> = ["A1", "A2", "A3"].iter().map(|c| json!({"command": "cell.set", "params": {"cell": c, "input": c}})).collect(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Three", "output": {"onConflict": "overwrite"}, "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"})); + h.run_steps(3); +} + +fn draft_cells(h: &Harness) -> Vec { + let ed = h.state().macros.editor.as_ref().unwrap(); + ed.draft + .steps + .iter() + .map(|s| match &s.kind { + StepKind::Command { params, .. } => params["cell"].as_str().unwrap_or("").to_string(), + _ => String::new(), + }) + .collect() +} + +fn click(h: &mut Harness, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +#[test] +fn new_macro_add_step_through_the_picker_and_save() { + let mut h = harness(); + click(&mut h, "New Macro"); + assert!(h.state().macros.editor.is_some()); + click(&mut h, "Add Step"); + h.input_mut().events.push(Event::Text("bold".into())); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(matches!(&ed.draft.steps[..], [s] if matches!(&s.kind, StepKind::Command { id, .. } if id == "home.bold"))); + assert_eq!(ed.selected.as_deref(), Some("1")); + click(&mut h, "Save"); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!(wb.macros[0].name, ed.draft.name); + assert!(!h.state().macros.editor.as_ref().unwrap().dirty); +} + +#[test] +fn the_gutter_toggles_breakpoints() { + let mut h = harness(); + three(&mut h); + click(&mut h, "Breakpoint 2"); + assert!(h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint); + click(&mut h, "Breakpoint 2"); + assert!(!h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint); +} + +#[test] +fn dragging_a_step_reorders_the_list() { + let mut h = harness(); + three(&mut h); + let from = h.get_by_label("Step 1: Set A1 to A1").rect().center(); + let to = h.get_by_label("Step 3: Set A3 to A3").rect().center() + egui::vec2(0.0, 8.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + for k in 1..=6 { + let p = from + (to - from) * (k as f32 / 6.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(draft_cells(&h), ["A2", "A3", "A1"]); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(ed.dirty); + assert_eq!(ed.selected.as_deref(), Some("3"), "the moved step stays selected"); +} + +#[test] +fn step_continue_and_finish_as_one_undo_step() { + let mut h = harness(); + three(&mut h); + click(&mut h, "Breakpoint 3"); + let undo_before = h.state().session.active().unwrap().undo.len(); + // Continue starts the run (saving the breakpoint first) and stops before step 3. + click(&mut h, "Continue"); + let ed = h.state().macros.editor.clone().unwrap(); + assert_eq!(ed.debug.as_ref().and_then(|d| d["current"]["step"].as_str()), Some("3")); + assert_eq!(ed.selected.as_deref(), Some("3"), "the current step is selected"); + assert!(h.query_by_label_contains("Paused before step 3").is_some()); + assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Text("A1".into()), Value::Empty)); + assert!(h.state().grid.macro_overlay.as_ref().is_some_and(|o| !o.target.is_empty()), "the target is outlined"); + // Clearing the breakpoint while paused, then Step runs step 3 and the run finishes. + click(&mut h, "Breakpoint 3"); + click(&mut h, "Step"); + assert!(h.state().session.macro_debug.is_none()); + assert_eq!(value(&h, "A3"), Value::Text("A3".into())); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(ed.outcome.as_deref().is_some_and(|o| o.starts_with("Finished")), "{:?}", ed.outcome); + let doc = h.state().session.active().unwrap(); + // The breakpoint save is one entry, the run another. + assert_eq!(doc.undo.last().map(|e| e.label.as_str()), Some("Run Macro: Three")); + assert!(doc.undo.len() <= undo_before + 2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Empty, Value::Empty)); +} + +#[test] +fn run_to_here_then_stop_asks_whether_to_keep_the_changes() { + for keep in [false, true] { + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + click(&mut h, "Run to Here"); + assert_eq!( + h.state().macros.editor.as_ref().unwrap().debug.as_ref().and_then(|d| d["current"]["step"].as_str()), + Some("3"), + "{:?} {:?}", + h.state().message, + h.state().macros.editor.as_ref().unwrap().outcome + ); + click(&mut h, "Stop"); + assert!(h.state().macros.editor.as_ref().unwrap().ask_stop); + click(&mut h, if keep { "Keep Changes" } else { "Undo Changes" }); + assert!(h.state().session.macro_debug.is_none()); + let want = if keep { Value::Text("A2".into()) } else { Value::Empty }; + assert_eq!(value(&h, "A2"), want, "keep = {keep}"); + assert_eq!(value(&h, "A3"), Value::Empty); + } +} + +#[test] +fn step_into_from_the_macros_dialog_pauses_before_the_first_step() { + let mut h = harness(); + three(&mut h); + h.state_mut().dialog = None; + h.state_mut().macros.editor = None; + h.state_mut().open_dialog("macros", json!({"name": "Three"})); + h.run_steps(2); + click(&mut h, "Step Into"); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + assert!(h.state().session.macro_debug.is_some()); + assert!(h.query_by_label_contains("Debugging “Three”").is_some(), "the status bar says so"); +} + +#[test] +fn a_recorded_macro_opens_in_the_editor() { + let mut h = harness(); + click(&mut h, "Record Macro"); + // The Record Macro dialog comes first. + h.run_steps(2); + click(&mut h, "OK"); + assert!(h.state().session.is_recording()); + h.state_mut().run("cell.set", json!({"cell": "B2", "input": "5"})).unwrap(); + h.state_mut().run("home.bold", json!({})).unwrap(); + h.run_steps(2); + click(&mut h, "Stop"); + let name = h.state().session.active().unwrap().wb.macros[0].name.clone(); + h.state_mut().open_dialog("macroEditor", json!({"macro": name})); + h.run_steps(3); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(!ed.draft.steps.is_empty()); + assert!(!ed.dirty); +} + +#[test] +fn run_from_the_editor_asks_before_writing_over_data() { + let mut h = harness(); + h.state_mut().run("cell.set", json!({"cell": "A2", "input": "keep me"})).unwrap(); + let steps = vec![json!({"command": "cell.set", "params": {"cell": "A2", "input": "new"}})]; + h.state_mut().run("macro.save", json!({"macro": {"name": "Over", "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Over", "check": true})); + h.run_steps(3); + // Check marks the cell it would write over. + let o = h.state().grid.macro_overlay.clone().unwrap(); + assert_eq!(o.conflicts.iter().map(|(_, r)| r.a1()).collect::>(), ["A2"]); + click(&mut h, "Run"); + assert!(h.state().macros.question.as_ref().is_some_and(|q| q.choices.len() == 3)); + click(&mut h, "Overwrite"); + assert_eq!(value(&h, "A2"), Value::Text("new".into())); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 9c2232fb6..bbda217f8 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -53,6 +53,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod macro_editor; mod macros_ui; mod menu_hover; mod merged_cells; diff --git a/docs/macros.md b/docs/macros.md index 12019a523..d6f9abc66 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, shortcuts) +> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) 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 @@ -272,12 +272,30 @@ other macros open; a macro that was left out is not in the saved file. starts recording and stays off while the name or the letter cannot be used, with the reason under the fields. While recording, the button reads Stop Recording, the status bar shows a red dot, "Recording · N steps" and Stop, and Use Relative References is locked. Run Macro runs the - macro run last (or the only one); its menu lists the recent macros, then every macro. + macro run last (or the only one); its menu lists the recent macros, then every macro. New + Macro opens the step editor on a new macro. - **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros (with their shortcuts), then its Automate scripts, and says when the file carries a VBA project. - Run runs the selected macro on the selection or the selected script; Delete asks first; Options - sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, + Run runs the selected macro on the selection or the selected script; Step Into, Edit and Create + open the step editor; Delete asks first; Options sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, as before macros existed. +- **Step editor.** A resizable window that works on a draft and saves it with `macro.save`. The + step list shows each step's number, a breakpoint dot in the gutter (click to set or clear), a + category icon, a summary, an error marker and an enabled checkbox; Repeat bodies and If branches + are indented under their step, and rows drag to reorder within their list. Add Step opens a + command picker (control steps first; commands a macro may never run are left out); Duplicate and + Delete act on the selected step. The inspector's Details tab edits the selected step, with + reference boxes that take a typed reference or a selection on the grid and switch between a + token relative to the example input (Rel) and an absolute reference (Abs); the Inputs tab sets + each input's shape, header and column types, and Use current selection as example infers them; + the Output tab sets the anchor and what to do when results would land on data. +- **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes + green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and + Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and + `breakpointsOnly: true`, so a dot cleared while paused takes effect at once); the current step is + selected, its parameters are shown as they will run, and its target is outlined on the grid. + Stop asks whether to keep the changes so far or undo them. While a macro is debugged the status + bar says "Debugging" with the macro's name, and Show reopens the editor. - **Questions.** A macro that needs approval offers Run; a macro whose output would land on data offers Overwrite, Shift Down and Shift Right. Each button runs the command again with `approve: true` or the chosen `onConflict`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 59962538c..015bf69f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -93,7 +93,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -334,7 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| -| 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, shortcuts) (no score change) | +| 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a8783b499..880ab38e8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -154,7 +154,8 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a -note on a VBA project; Run, Delete, Options) and Record Macro and others exist. Missing or partial: Options, +note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step +editor (Excel's VBA editor has no counterpart here) and others exist. Missing or partial: Options, Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). @@ -189,6 +190,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | +| 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays | | 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | | 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list | From bca9a10f8a589c14e15363077fc53dcd5b4ebbab Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:04:15 -0400 Subject: [PATCH 141/166] UI: saving a new macro never replaces one with the same name A new draft's name can be edited to that of a macro already in the workbook. Save, and Check and Run (which save first), then sent macro.save with its default replace: true and the draft took the other macro's place. A new draft now saves with replace: false, so the clash is reported and the existing macro stays as it was. --- crates/ui-egui/src/macro_editor.rs | 4 +++- crates/ui-egui/tests/it/macro_editor.rs | 29 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 69e7463a6..c5c30e85b 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -522,7 +522,9 @@ pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { app.message = Some(("Save Macro".into(), crate::clean_error(&e))); return false; } - match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft)})) { + // A new draft never replaces a macro that has its name. + let replace = ed.saved_as.is_some(); + match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft), "replace": replace})) { Ok(_) => { ed.saved_as = Some(ed.draft.name.clone()); ed.dirty = false; diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 3f9cda896..8effcfd09 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -213,3 +213,32 @@ fn run_from_the_editor_asks_before_writing_over_data() { click(&mut h, "Overwrite"); assert_eq!(value(&h, "A2"), Value::Text("new".into())); } + +#[test] +fn a_new_macro_never_replaces_one_with_its_name() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor = None; + h.state_mut().dialog = None; + h.run_steps(2); + click(&mut h, "New Macro"); + for button in ["Save", "Check", "Run"] { + { + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "three".into(); + ed.draft.steps = vec![gridcraft_engine::model::Step::command("cell.set", json!({"cell": "B1", "input": "x"}))]; + ed.dirty = true; + } + h.run_steps(2); + click(&mut h, button); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1, "{button}"); + assert_eq!(wb.macros[0].name, "Three", "{button}"); + assert_eq!(wb.macros[0].steps.len(), 3, "{button}: the saved macro is intact"); + assert!(value(&h, "B1").is_empty(), "{button}: nothing ran"); + let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default(); + assert!(msg.contains("A macro called 'three' already exists"), "{button}: {msg}"); + assert!(h.state().macros.editor.as_ref().unwrap().saved_as.is_none()); + h.run_steps(2); + } +} From eb7b2b93f48f5e2f28a403917818f288ea0a4f32 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:20:48 -0400 Subject: [PATCH 142/166] UI: the macro editor remembers a rename even when the save after it fails Save renames the stored macro first and then saves the draft. When the save failed (a draft too large to keep in the workbook, say), saved_as still held the old name, so every later Save, Check, Run and debug start tried to rename a macro that no longer existed. saved_as now takes the new name as soon as the rename succeeds. --- crates/ui-egui/src/macro_editor.rs | 9 ++++++--- crates/ui-egui/tests/it/macro_editor.rs | 27 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index c5c30e85b..40d6118a4 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -517,10 +517,13 @@ pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { } if let Some(old) = ed.saved_as.clone() && !old.eq_ignore_ascii_case(&ed.draft.name) - && let Err(e) = app.run("macro.rename", json!({"name": old, "to": ed.draft.name})) { - app.message = Some(("Save Macro".into(), crate::clean_error(&e))); - return false; + if let Err(e) = app.run("macro.rename", json!({"name": old, "to": ed.draft.name})) { + app.message = Some(("Save Macro".into(), crate::clean_error(&e))); + return false; + } + // The workbook has it under the new name now, even if the save below fails. + ed.saved_as = Some(ed.draft.name.clone()); } // A new draft never replaces a macro that has its name. let replace = ed.saved_as.is_some(); diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 8effcfd09..8e7db5206 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -242,3 +242,30 @@ fn a_new_macro_never_replaces_one_with_its_name() { h.run_steps(2); } } + +#[test] +fn a_rename_that_lands_before_a_failed_save_is_remembered() { + let mut h = harness(); + three(&mut h); + let big = "x".repeat(5 * 1024 * 1024); + { + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "Renamed".into(); + // Too large to keep in the workbook: the rename lands, the save after it fails. + ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A4", "input": big}))); + ed.dirty = true; + } + h.run_steps(2); + click(&mut h, "Save"); + let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default(); + assert!(msg.contains("too large"), "{msg}"); + assert_eq!(h.state().macros.editor.as_ref().unwrap().saved_as.as_deref(), Some("Renamed")); + // Trimmed back, the next Save works. + h.state_mut().macros.editor.as_mut().unwrap().draft.steps.pop(); + h.run_steps(2); + click(&mut h, "Save"); + assert!(h.state_mut().message.take().is_none()); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!((wb.macros[0].name.as_str(), wb.macros[0].steps.len()), ("Renamed", 3)); +} From c523a4c48ad4daa20bc184dbb28f1b21732e2fdc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:21:39 -0400 Subject: [PATCH 143/166] UI: another dialog never replaces the open macro step editor The step editor lives in the app's one dialog slot, so Format Cells, the Macros dialog or New Macro opened while it was up took its place. The draft was lost (New Macro started a fresh one), and a closed-over editor left its grid overlays and its debug run behind. While the editor is open, requests for other dialogs are now ignored; the editor closes itself as before. --- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/it/macro_editor.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db8e3d19e..8e6155bee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -826,6 +826,9 @@ impl SheetApp { let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); self.open_backstage(page); } + // The macro step editor stays until it closes itself (its draft, overlays and debug + // run go with it), so another dialog can't replace it. + _ if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") => {} other => self.dialog = dialogs::Dialog::open(self, other, params), } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 8e7db5206..8c3b07cc1 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -269,3 +269,19 @@ fn a_rename_that_lands_before_a_failed_save_is_remembered() { assert_eq!(wb.macros.len(), 1); assert_eq!((wb.macros[0].name.as_str(), wb.macros[0].steps.len()), ("Renamed", 3)); } + +#[test] +fn another_dialog_never_replaces_the_open_editor() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into(); + h.run_steps(2); + for name in ["formatCells", "macros", "macroEditor"] { + h.state_mut().open_dialog(name, json!({})); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("macroEditor"), "{name}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"), "{name}"); + } + click(&mut h, "New Macro"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved")); +} From b9a8fa304cbb064607941e0b0da0f8ca02416f43 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:22:13 -0400 Subject: [PATCH 144/166] UI: Change in the macro step editor picks only the commands its list shows In Change mode the picker hid the Repeat, If and Stop rows but still indexed the full list, so Enter with an empty search (index 0, nothing highlighted) picked Repeat and turned the command step into a Repeat step; a search for "if" or "stop" did the same. The control rows are now dropped from the list before it is indexed, drawn or chosen. --- crates/ui-egui/src/macro_editor.rs | 9 +++++---- crates/ui-egui/tests/it/macro_editor.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 40d6118a4..8c3cf76db 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -1344,7 +1344,11 @@ fn picker(ui: &mut Ui, ed: &mut Editor) { if r.changed() { pk.index = 0; } - let items = picker_items(&pk.search); + let mut items = picker_items(&pk.search); + // Change swaps a command step's command; it can't turn the step into Repeat, If or Stop. + if pk.replace.is_some() { + items.retain(|(id, ..)| !id.starts_with('@')); + } if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) { pk.index = (pk.index + 1).min(items.len().saturating_sub(1)); } @@ -1354,9 +1358,6 @@ fn picker(ui: &mut Ui, ed: &mut Editor) { let mut chosen = ui.input(|i| i.key_pressed(egui::Key::Enter)).then_some(pk.index); egui::ScrollArea::vertical().id_salt("me_pick_list").auto_shrink([false, false]).show(ui, |ui| { for (i, (id, label, menu)) in items.iter().enumerate() { - if pk.replace.is_some() && id.starts_with('@') { - continue; - } let r = ui.add(egui::Button::selectable(i == pk.index, label.as_str()).min_size(vec2(ui.available_width(), 20.0))); if r.clicked() { chosen = Some(i); diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 8c3b07cc1..c302f9bb4 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -285,3 +285,23 @@ fn another_dialog_never_replaces_the_open_editor() { click(&mut h, "New Macro"); assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved")); } + +#[test] +fn enter_in_change_never_picks_a_control_step() { + let mut h = harness(); + three(&mut h); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for search in ["", "stop"] { + h.state_mut().macros.editor.as_mut().unwrap().selected = Some("1".into()); + h.run_steps(2); + click(&mut h, "Change…"); + if !search.is_empty() { + h.input_mut().events.push(Event::Text(search.into())); + h.run_steps(2); + } + h.input_mut().events.push(enter.clone()); + h.run_steps(3); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(matches!(&ed.draft.steps[0].kind, StepKind::Command { .. }), "{search:?}: {:?}", ed.draft.steps[0].kind); + } +} From 3d6a09ec758e37b0ba684bc9b6062013b29601da Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:04:01 -0400 Subject: [PATCH 145/166] UI: a reference box stops picking when a macro action starts, and debugging never fills one A reference box that had focus kept picking from the grid while Check, Run or a debugger action ran. The selections those make landed in the box and dirtied the draft, and a later Check could save that draft. The pick now ends when an action starts, and while a debug run is active a selection on the grid is never taken as a pick. Also: the parity assessment said an .xlsm's VBA project is dropped on open; it is kept unchanged on save and never run. --- crates/ui-egui/src/macro_editor.rs | 9 +++++-- crates/ui-egui/tests/it/macro_editor.rs | 34 +++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 8c3cf76db..955cb45f6 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -563,6 +563,8 @@ fn error_step(msg: &str) -> Option<(String, String)> { /// Check: validates the inputs and dry-runs the macro; the grid shows what it would touch. pub fn check(app: &mut SheetApp, ed: &mut Editor) { + // A reference box picking from the grid would take the selections the action makes. + ed.refs.pick = None; if !ensure_saved(app, ed) { return; } @@ -613,6 +615,7 @@ fn check_text(v: &Json) -> String { /// Run: runs the saved macro as one undo step. pub fn run(app: &mut SheetApp, ed: &mut Editor) { + ed.refs.pick = None; if !ensure_saved(app, ed) { return; } @@ -649,6 +652,7 @@ fn with(mut base: Json, extra: Json) -> Json { /// Step, Continue or Run to Here: starts debugging if needed (paused before step 1), then acts. pub fn debug_action(app: &mut SheetApp, ed: &mut Editor, action: &str) { + ed.refs.pick = None; let debugging = app.session.macro_debug.is_some(); // Starting selects the first step: take Run to Here's target before that. let target = ed.selected.clone().filter(|s| split_path(s).is_some()); @@ -840,8 +844,9 @@ fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width - 30.0).id_salt(("refedit", key))); if r.gained_focus() || (r.clicked() && !picking) { begin_pick(app, refs, key); - } else if picking && !r.has_focus() && sel != refs.seen { - // A selection made on the grid becomes the reference. + } else if picking && !r.has_focus() && sel != refs.seen && app.session.macro_debug.is_none() { + // A selection made on the grid becomes the reference; while debugging, the selection + // follows the steps and is not a pick. s = sel.clone(); refs.seen = sel; } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index c302f9bb4..52e116f89 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -162,6 +162,40 @@ fn run_to_here_then_stop_asks_whether_to_keep_the_changes() { } } +#[test] +fn debugging_never_writes_the_selection_into_a_reference_box() { + // A reference box that was picking from the grid stays out of it once an action starts, and + // the selections the debugger makes while it runs never land in a box or dirty the draft. + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + // The step's Cell box is picking, with the selection it last saw (A1). + let pick = |h: &mut Harness| { + let refs = &mut h.state_mut().macros.editor.as_mut().unwrap().refs; + refs.pick = Some("ref:3:cell".into()); + refs.seen = "$A$1".into(); + }; + pick(&mut h); + h.run_steps(2); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); + click(&mut h, "Run to Here"); + assert!(h.state().session.macro_debug.is_some()); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(ed.refs.pick.is_none(), "the action ends the pick"); + assert!(!ed.dirty); + // A box focused while debugging does not take the selection either. + pick(&mut h); + h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap(); + h.run_steps(3); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(!ed.dirty, "the draft stays clean"); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); + h.state_mut().macros.editor.as_mut().unwrap().refs.pick = None; + click(&mut h, "Stop"); + click(&mut h, "Undo Changes"); + assert!(h.state().session.macro_debug.is_none()); +} + #[test] fn step_into_from_the_macros_dialog_pauses_before_the_first_step() { let mut h = harness(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 015bf69f0..18286ce52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -93,7 +93,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions From cec19d6af6fde42343bd5f9a7a458baa70509556 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:16:01 -0400 Subject: [PATCH 146/166] UI: a reference box's pick ends when the box leaves the screen Choosing another step or inspector tab hid the picking box but left the pick in place, so a grid selection made later for another reason landed in the box when its step was shown again, and the draft was dirtied with a reference nobody chose. A pick now ends in any frame that does not draw its box. --- crates/ui-egui/src/macro_editor.rs | 10 +++++++ crates/ui-egui/tests/it/macro_editor.rs | 36 +++++++++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 955cb45f6..12e9c1772 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -99,6 +99,9 @@ pub struct RefPick { pub home: Option<(usize, String, String)>, /// The reference box the window is collapsed to (only that box shows). pub collapsed: Option, + /// Whether the picking box was drawn this frame: a pick ends with a box that left the screen + /// (another step or tab was chosen), or a later selection would land in it unseen. + pub drawn: bool, } // ---------------------------------------------------------------- step paths @@ -752,6 +755,7 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu let dirty_mark = if ed.dirty { " •" } else { "" }; let screen = ctx.content_rect(); let mut win_open = true; + ed.refs.drawn = false; egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) .id(Id::new("gridcraft.macro_editor")) .collapsible(false) @@ -769,6 +773,9 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu } body(app, ui, d, &mut ed); }); + if ed.refs.pick.is_some() && !ed.refs.drawn { + ed.refs.pick = None; + } if !win_open { if app.session.macro_debug.is_some() { ed.ask_stop = true; @@ -840,6 +847,9 @@ fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); let sel = selection_ref(app, refs.home.as_ref().map(|h| h.0)); let picking = refs.pick.as_deref() == Some(key); + if picking { + refs.drawn = true; + } ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width - 30.0).id_salt(("refedit", key))); if r.gained_focus() || (r.clicked() && !picking) { diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 52e116f89..5dbf60847 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -162,6 +162,32 @@ fn run_to_here_then_stop_asks_whether_to_keep_the_changes() { } } +/// Step 3's Cell box is picking from the grid, with the selection it last saw (A1). +fn pick_step_3_cell(h: &mut Harness) { + let refs = &mut h.state_mut().macros.editor.as_mut().unwrap().refs; + refs.pick = Some("ref:3:cell".into()); + refs.seen = "$A$1".into(); +} + +#[test] +fn a_pick_ends_when_its_box_leaves_the_screen() { + // Choosing another step hides the picking box; a selection made meanwhile must not land in + // it when the step is shown again. + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + pick_step_3_cell(&mut h); + h.run_steps(2); + click(&mut h, "Step 1: Set A1 to A1"); + assert!(h.state().macros.editor.as_ref().unwrap().refs.pick.is_none(), "the pick ends with its box"); + h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap(); + h.run_steps(2); + click(&mut h, "Step 3: Set A3 to A3"); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(!ed.dirty, "the draft stays clean"); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); +} + #[test] fn debugging_never_writes_the_selection_into_a_reference_box() { // A reference box that was picking from the grid stays out of it once an action starts, and @@ -169,13 +195,7 @@ fn debugging_never_writes_the_selection_into_a_reference_box() { let mut h = harness(); three(&mut h); click(&mut h, "Step 3: Set A3 to A3"); - // The step's Cell box is picking, with the selection it last saw (A1). - let pick = |h: &mut Harness| { - let refs = &mut h.state_mut().macros.editor.as_mut().unwrap().refs; - refs.pick = Some("ref:3:cell".into()); - refs.seen = "$A$1".into(); - }; - pick(&mut h); + pick_step_3_cell(&mut h); h.run_steps(2); assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); click(&mut h, "Run to Here"); @@ -184,7 +204,7 @@ fn debugging_never_writes_the_selection_into_a_reference_box() { assert!(ed.refs.pick.is_none(), "the action ends the pick"); assert!(!ed.dirty); // A box focused while debugging does not take the selection either. - pick(&mut h); + pick_step_3_cell(&mut h); h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap(); h.run_steps(3); let ed = h.state().macros.editor.as_ref().unwrap(); From aa86b0c32734d101d6e2f7ab327c0f731bc584d8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:13:39 -0400 Subject: [PATCH 147/166] UI: merge fallout from the UI kit and the recorder heads Icon buttons no longer carry an accessible name: the tests that came with main since the branch was written (menu hover, popup dismissal, the File menu, More Colors) find Copy, Save and Cancel by label, and the Quick Access Toolbar and formula bar icons gave those labels twice. The macro tests need the names of large, small and toggle ribbon buttons only. The chart menu test takes the menu's Delete, which is drawn after the ribbon's Home > Cells > Delete. The Trust Scripts row ported into the Macros dialog is formatted. --- crates/ui-egui/src/macro_ui.rs | 6 +++++- crates/ui-egui/src/widgets.rs | 3 --- crates/ui-egui/tests/it/chart_resize.rs | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 833973659..acbc77a2c 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -352,7 +352,11 @@ pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut return; } // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - let untrusted = d.result.as_ref().and_then(Json::as_array).is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false))); + let untrusted = d + .result + .as_ref() + .and_then(Json::as_array) + .is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false))); if untrusted { ui.horizontal(|ui| { ui.weak("Scripts in this workbook came from a file and are disabled."); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b33daca05..dea0bcf5e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -60,9 +60,6 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); - let name = tr(ui, tip); - let enabled = ui.is_enabled(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name)); resp.on_hover_text(tip_text(ui, tip)) } diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index fb3e99165..30afcc17c 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -151,7 +151,8 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() { let (mut h, id) = fixture(); let inside = corner(&h, id) - vec2(100.0, 80.0); right_click(&mut h, inside, vec2(0.0, 0.0)); - h.get_by_label("Delete").click(); + // Home › Cells › Delete has the same accessible name; the menu is drawn over the ribbon, last. + h.get_all_by_label("Delete").last().unwrap().click(); h.run_steps(3); assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty()); assert!(h.state().grid.object_menu.is_none()); From 73b0edd0859eb8e91c565d23ad8ef6fabe309e5c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:13:39 -0400 Subject: [PATCH 148/166] UI: the Macros dialog offers Trust Scripts for scripts from a file The Macros dialog that replaces the scripts-only list keeps its Trust Scripts row (#2178): scripts that came with an opened file are listed as disabled until the row's button trusts them for the session, and Run works after it. A test covers the row, the trust and the run, and the macro page describes it. --- crates/ui-egui/tests/it/macros_ui.rs | 34 ++++++++++++++++++++++++++++ docs/macros.md | 2 ++ 2 files changed, 36 insertions(+) diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index 03417dd1c..d7635d572 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -249,3 +249,37 @@ fn view_macros_record_macro_toggles_recording() { assert!(h.state().dialog.is_none()); assert!(!h.state().session.is_recording()); } + +#[test] +fn the_macros_dialog_offers_trust_for_scripts_that_came_with_a_file() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]})) + .unwrap(); + // As when the workbook was opened with its scripts (#2178). + s.active_mut().unwrap().scripts_trusted = false; + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + assert!(h.query_by_label_contains("came from a file and are disabled").is_some()); + h.get_by_label("Trust Scripts").click(); + // The dialog shrinks without the row: let it settle before the next click. + h.run_steps(4); + assert!(h.state().session.doc().unwrap().scripts_trusted); + assert!(h.query_by_label("Trust Scripts").is_none(), "the row goes once the scripts are trusted"); + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + h.get_by_label("Run").click(); + h.run_steps(3); + assert_eq!(value(&h, "D4"), Value::Text("script".into())); +} diff --git a/docs/macros.md b/docs/macros.md index d6f9abc66..e0cbb4b97 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -276,6 +276,8 @@ other macros open; a macro that was left out is not in the saved file. Macro opens the step editor on a new macro. - **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros (with their shortcuts), then its Automate scripts, and says when the file carries a VBA project. + Scripts that came with an opened file stay disabled until Trust Scripts (`automate.trustScripts`) + trusts them for the session. Run runs the selected macro on the selection or the selected script; Step Into, Edit and Create open the step editor; Delete asks first; Options sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, as before macros existed. From a206178209acbc170fd1f6dd06309af83ca6e2bc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:19:16 -0400 Subject: [PATCH 149/166] UI: the macro step editor works only on the workbook it opened in The macro.* commands act on the active workbook, and the editor stays open while another one becomes active (File > New, Switch Window, an agent). A new draft saved then went into that other workbook, and Check, Run and Debug ran there. The editor now keeps the workbook it opened in: while another one is active it says so, its controls are disabled, Save, Check, Run and starting Debug refuse with a message that names the workbook, the grid overlays are hidden and closing does not put the selection back on the wrong sheet. Closing the workbook closes the editor. --- crates/ui-egui/src/macro_editor.rs | 60 ++++++++++++++++++++----- crates/ui-egui/tests/it/macro_editor.rs | 43 ++++++++++++++++++ docs/macros.md | 5 ++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 12e9c1772..f3e934c6a 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -52,6 +52,8 @@ pub struct Picker { /// The editor's state while it is open. #[derive(Clone, Debug)] pub struct Editor { + /// The workbook (`DocState::uid`) the macro is in: the editor saves, checks and runs there only. + pub doc: u64, pub draft: Macro, /// The name the macro is saved under (`None` until it is first saved). pub saved_as: Option, @@ -485,7 +487,9 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { let start_debug = params["debug"].as_bool() == Some(true) && saved_as.is_some(); let title = format!("Macro: {}", draft.name); let selected = (!draft.steps.is_empty()).then(|| "1".to_string()); + let doc = doc.uid; app.macros.editor = Some(Editor { + doc, draft, dirty: saved_as.is_none(), saved_as, @@ -511,8 +515,27 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { // ---------------------------------------------------------------- actions +/// Whether the editor's workbook is the active one, which the `macro.*` commands act on. When +/// another workbook is active (File › New, Switch Window, an agent), says so and returns false. +fn at_home(app: &mut SheetApp, ed: &Editor, title: &str) -> bool { + if app.session.active().is_some_and(|d| d.uid == ed.doc) { + return true; + } + let book = home_title(app, ed); + app.message = Some((title.into(), format!("The macro “{}” is in “{book}”. Switch back to it to save, check or run the macro.", ed.draft.name))); + false +} + +/// The title of the editor's workbook. +fn home_title(app: &SheetApp, ed: &Editor) -> String { + app.session.documents().iter().find(|d| d.uid == ed.doc).map(|d| d.display_title()).unwrap_or_default() +} + /// Saves the draft (renaming the saved macro if its name changed). False when it couldn't. pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { + if !at_home(app, ed, "Save Macro") { + return false; + } ed.draft.name = ed.draft.name.trim().to_string(); if let Err(e) = ed.draft.check_limits() { app.message = Some(("Save Macro".into(), e)); @@ -568,7 +591,7 @@ fn error_step(msg: &str) -> Option<(String, String)> { pub fn check(app: &mut SheetApp, ed: &mut Editor) { // A reference box picking from the grid would take the selections the action makes. ed.refs.pick = None; - if !ensure_saved(app, ed) { + if !at_home(app, ed, "Check Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); @@ -619,7 +642,7 @@ fn check_text(v: &Json) -> String { /// Run: runs the saved macro as one undo step. pub fn run(app: &mut SheetApp, ed: &mut Editor) { ed.refs.pick = None; - if !ensure_saved(app, ed) { + if !at_home(app, ed, "Run Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); @@ -660,7 +683,7 @@ pub fn debug_action(app: &mut SheetApp, ed: &mut Editor, action: &str) { // Starting selects the first step: take Run to Here's target before that. let target = ed.selected.clone().filter(|s| split_path(s).is_some()); if !debugging { - if !ensure_saved(app, ed) { + if !at_home(app, ed, "Debug Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); @@ -752,6 +775,13 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu if ed.debug.is_some() && app.session.macro_debug.is_none() { ed.debug = None; } + // Its workbook was closed: the macro went with it. + if !app.session.documents().iter().any(|d| d.uid == ed.doc) { + *open = false; + return; + } + let home = app.session.active().is_some_and(|d| d.uid == ed.doc); + let home_title = home_title(app, &ed); let dirty_mark = if ed.dirty { " •" } else { "" }; let screen = ctx.content_rect(); let mut win_open = true; @@ -766,12 +796,21 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu .default_pos(pos2((screen.right() - 860.0).max(screen.left() + 20.0), screen.top() + 190.0)) .open(&mut win_open) .show(ctx, |ui| { - if let Some(key) = ed.refs.collapsed.clone() { - ref_edit(app, ui, d, &mut ed.refs, &key, 340.0); - apply_ref_value(&mut ed, d, &key); - return; + if !home { + let t = Tokens::get(ui.ctx()); + ui.label( + egui::RichText::new(format!("This macro is in “{home_title}”. Switch back to it to edit, check or run the macro.")) + .color(t.danger), + ); } - body(app, ui, d, &mut ed); + ui.add_enabled_ui(home, |ui| { + if let Some(key) = ed.refs.collapsed.clone() { + ref_edit(app, ui, d, &mut ed.refs, &key, 340.0); + apply_ref_value(&mut ed, d, &key); + return; + } + body(app, ui, d, &mut ed); + }); }); if ed.refs.pick.is_some() && !ed.refs.drawn { ed.refs.pick = None; @@ -790,10 +829,11 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu ed.picker = None; } prompts(app, ctx, &mut ed, open); - app.grid.macro_overlay = if *open { overlay(app, &ed) } else { None }; + // The overlays mark the editor's workbook's cells, so they show only while it is active. + app.grid.macro_overlay = if *open && home { overlay(app, &ed) } else { None }; if *open { app.macros.editor = Some(ed); - } else { + } else if home { restore_home(app, &ed.refs); } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 5dbf60847..0e986da75 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -359,3 +359,46 @@ fn enter_in_change_never_picks_a_control_step() { assert!(matches!(&ed.draft.steps[0].kind, StepKind::Command { .. }), "{search:?}: {:?}", ed.draft.steps[0].kind); } } + +#[test] +fn the_editor_works_only_on_the_workbook_it_opened_in() { + let mut h = harness(); + // A new macro whose one step writes A1, not saved yet. + h.state_mut().open_dialog("macroEditor", json!({"new": "Fresh"})); + h.run_steps(3); + let step = gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "ran"})); + h.state_mut().macros.editor.as_mut().unwrap().draft.steps = vec![step]; + let book1 = h.state().session.active().unwrap().uid; + // Another workbook becomes active while the editor is open (File › New, Switch Window, an agent). + h.state_mut().session.new_workbook(); + h.run_steps(3); + let mut ed = h.state_mut().macros.editor.take().unwrap(); + let app = h.state_mut(); + // Save, Check, Run and Debug never act on the other workbook. + assert!(!gridcraft_ui_egui::macro_editor::save(app, &mut ed)); + gridcraft_ui_egui::macro_editor::check(app, &mut ed); + gridcraft_ui_egui::macro_editor::run(app, &mut ed); + gridcraft_ui_egui::macro_editor::debug_action(app, &mut ed, "step"); + assert!(app.session.macro_debug.is_none()); + assert!(app.session.active().unwrap().wb.macros.is_empty(), "nothing was saved into the other workbook"); + assert_eq!(value(&h, "A1"), Value::Empty, "nothing ran there"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message); + h.state_mut().message = None; + h.state_mut().macros.editor = Some(ed); + h.run_steps(3); + assert!(h.query_by_label_contains("Switch back to it").is_some(), "the editor says whose macro it is"); + // Back in its own workbook, the editor saves there. + let mut ed = h.state_mut().macros.editor.take().unwrap(); + let app = h.state_mut(); + let i = app.session.documents().iter().position(|d| d.uid == book1).unwrap(); + app.session.set_active(i); + assert!(gridcraft_ui_egui::macro_editor::save(app, &mut ed)); + assert_eq!(app.session.active().unwrap().wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Fresh"]); + app.macros.editor = Some(ed); + h.run_steps(3); + // Closing the workbook (without saving it) closes its editor. + h.state_mut().run("file.close", json!({"force": true})).unwrap(); + h.run_steps(3); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); +} diff --git a/docs/macros.md b/docs/macros.md index e0cbb4b97..d79efcc38 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -290,7 +290,10 @@ other macros open; a macro that was left out is not in the saved file. reference boxes that take a typed reference or a selection on the grid and switch between a token relative to the example input (Rel) and an absolute reference (Abs); the Inputs tab sets each input's shape, header and column types, and Use current selection as example infers them; - the Output tab sets the anchor and what to do when results would land on data. + the Output tab sets the anchor and what to do when results would land on data. The editor + belongs to the workbook it opened in: while another workbook is active it says so and Save, + Check, Run and Debug wait until that workbook is active again, and closing the workbook closes + the editor. - **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and From a18ecc81aa38275a947afb74f72dc93dffa77404 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:21:22 -0400 Subject: [PATCH 150/166] UI: the control channel's dialog cancel closes the macro step editor as its own close does ui.dialog.cancel emptied the dialog slot, so with the step editor open it went without its cleanup: the draft stayed behind, the Check overlays stayed on the grid, the selection the reference boxes moved was not put back and a debug run went on with no controls. The cancel now closes the editor the way its close button does, and is refused, with the reason, while the editor holds unsaved steps or a debug run, which only its window asks about. --- crates/ui-egui/src/control.rs | 7 +++++++ crates/ui-egui/src/macro_editor.rs | 20 ++++++++++++++++++ crates/ui-egui/tests/it/macro_editor.rs | 27 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8e29bccb4..b283472f3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -320,6 +320,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - None => err("no dialog is open"), }, "ui.dialog.cancel" => { + // The macro step editor's draft, overlays and debug run go with its own close. + if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + return match crate::macro_editor::close_now(app) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + }; + } app.dialog = None; ok(Json::Null) } diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index f3e934c6a..51450470f 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -838,6 +838,26 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu } } +/// Closes the step editor at once for a caller outside its window (the control channel's +/// `ui.dialog.cancel`), with the cleanup its own close does. Refused while it holds a debug run +/// or unsaved steps, which only its window asks about. +pub fn close_now(app: &mut SheetApp) -> Result<(), String> { + if app.session.macro_debug.is_some() { + return Err("A macro is being debugged: stop it first (macro.debug {\"action\": \"stop\"}).".into()); + } + if app.macros.editor.as_ref().is_some_and(|ed| ed.dirty) { + return Err("The macro step editor has unsaved steps: save them or close the editor in its window.".into()); + } + if let Some(ed) = app.macros.editor.take() + && app.session.active().is_some_and(|d| d.uid == ed.doc) + { + restore_home(app, &ed.refs); + } + app.grid.macro_overlay = None; + app.dialog = None; + Ok(()) +} + // ---------------------------------------------------------------- reference boxes /// A range as an absolute reference: `$B$2:$D$9`. diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 0e986da75..169e4d480 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -402,3 +402,30 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().macros.editor.is_none()); assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } + +#[test] +fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() { + let mut h = harness(); + three(&mut h); + let ctx = h.ctx.clone(); + let reply = |o: gridcraft_ui_egui::control::Outcome| match o { + gridcraft_ui_egui::control::Outcome::Done(v) => v, + _ => json!(null), + }; + // Unsaved steps are only the editor's to ask about: cancel is refused and keeps them. + h.state_mut().macros.editor.as_mut().unwrap().dirty = true; + let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({}))); + assert!(r.get("error").is_some(), "{r}"); + assert!(h.state().macros.editor.is_some()); + // A saved editor closes, and takes its overlays with it. + h.state_mut().macros.editor.as_mut().unwrap().dirty = false; + h.get_by_label("Check").click(); + h.run_steps(3); + assert!(h.state().grid.macro_overlay.is_some()); + let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({}))); + assert!(r.get("error").is_none(), "{r}"); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().grid.macro_overlay.is_none()); +} From 12072c55297c3190de684e634b6e425dfe039c28 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:21:34 -0400 Subject: [PATCH 151/166] UI: a dialog asked for over the macro step editor says why it did not open The step editor keeps its window until it closes itself, so a dialog asked for while it is open is not shown. That was silent: File > Close on a changed workbook asked for its Save Changes question and nothing happened. The request now shows a message that says to close the editor first. --- crates/ui-egui/src/lib.rs | 7 ++++++- crates/ui-egui/tests/it/macro_editor.rs | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e6155bee..849426179 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -828,7 +828,12 @@ impl SheetApp { } // The macro step editor stays until it closes itself (its draft, overlays and debug // run go with it), so another dialog can't replace it. - _ if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") => {} + _ if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") => { + self.message = Some(( + "Macro".into(), + "Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(), + )); + } other => self.dialog = dialogs::Dialog::open(self, other, params), } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 169e4d480..f716fcce6 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -403,6 +403,18 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } +#[test] +fn a_dialog_asked_for_over_the_editor_says_why_it_did_not_open() { + let mut h = harness(); + three(&mut h); + // File › Close on a changed workbook asks about saving it, which can't replace the editor. + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Close the macro step editor")), "{:?}", h.state().message); +} + #[test] fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() { let mut h = harness(); From f73e7b7e194c6520d63998ebbe3bc578673f0cf5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:21:49 -0400 Subject: [PATCH 152/166] UI: closing the window asks about the macro step editor first A window close (the close button, Alt+F4, quitting) let the app go when every workbook was saved, losing the editor's unsaved steps, and with an unsaved workbook it made that workbook active under the editor and asked for a Save Changes question the editor's window would not let open, so nothing happened. The close now waits for the editor: it asks about its debug run and unsaved steps as its own close button does, Cancel keeps the app open, and once the editor has closed the window close goes on and asks about unsaved workbooks as before. --- crates/ui-egui/src/lib.rs | 7 +++ crates/ui-egui/src/macro_editor.rs | 33 +++++++++++-- crates/ui-egui/tests/it/macro_editor.rs | 61 +++++++++++++++++++++++++ docs/macros.md | 4 +- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 849426179..e4c7a6436 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1144,6 +1144,13 @@ impl SheetApp { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); return; } + // The macro step editor asks about its own unsaved steps and debug run first; once it has + // closed, the window close goes on. + if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + macro_editor::request_close(self, true); + return; + } if !self.session.documents().iter().any(|d| d.is_dirty()) { return; } diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 51450470f..9af815675 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -75,6 +75,10 @@ pub struct Editor { /// Asking whether to keep what a stopped debug run did (and then close, if `closing`). pub ask_stop: bool, pub closing: bool, + /// Close as the title bar's close button does (asking first) on the next frame. + pub close_requested: bool, + /// The window is quitting: once the editor has closed, the window close goes on. + pub quit: bool, /// Asking whether to save before closing. pub ask_close: bool, /// Start debugging on the first frame (Macros › Step Into). @@ -503,6 +507,8 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { errors: BTreeMap::new(), ask_stop: false, closing: false, + close_requested: false, + quit: false, ask_close: false, start_debug, start_check: params["check"].as_bool() == Some(true), @@ -815,7 +821,7 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu if ed.refs.pick.is_some() && !ed.refs.drawn { ed.refs.pick = None; } - if !win_open { + if !win_open || std::mem::take(&mut ed.close_requested) { if app.session.macro_debug.is_some() { ed.ask_stop = true; ed.closing = true; @@ -833,8 +839,23 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu app.grid.macro_overlay = if *open && home { overlay(app, &ed) } else { None }; if *open { app.macros.editor = Some(ed); - } else if home { - restore_home(app, &ed.refs); + } else { + if home { + restore_home(app, &ed.refs); + } + if ed.quit { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +/// Asks the open step editor to close, as its close button does: it asks about a debug run or +/// unsaved steps first. With `quit` (the window is closing), the window close goes on once the +/// editor has closed; a Cancel in its questions keeps both open. +pub fn request_close(app: &mut SheetApp, quit: bool) { + if let Some(ed) = app.macros.editor.as_mut() { + ed.close_requested = true; + ed.quit = quit; } } @@ -966,6 +987,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut Some(None) => { ed.ask_stop = false; ed.closing = false; + ed.quit = false; } None => {} } @@ -1000,7 +1022,10 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_close = false; *open = false; } - Some(None) => ed.ask_close = false, + Some(None) => { + ed.ask_close = false; + ed.quit = false; + } None => {} } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index f716fcce6..418d36293 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -403,6 +403,67 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } +/// Asks the window to close (the close button, Alt+F4, quitting) and returns the commands the +/// app answered with over the next frames. +fn request_window_close(h: &mut Harness) -> Vec { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + step_commands(h) +} + +fn step_commands(h: &mut Harness) -> Vec { + let mut out = Vec::new(); + for _ in 0..4 { + h.step(); + out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()); + } + out +} + +#[test] +fn quitting_asks_about_the_editors_unsaved_steps_first() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into(); + h.state_mut().macros.editor.as_mut().unwrap().dirty = true; + // As if the workbook had just been saved. + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + h.run_steps(2); + // Every workbook is saved, but the draft is not: the window stays open and the editor asks. + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(h.query_by_label_contains("Save changes to macro “Unsaved”").is_some()); + // Cancel keeps the app and the draft. + h.get_by_label("Cancel").click(); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved")); + // Asked again, Don't Save closes the editor and then the window. + request_window_close(&mut h); + h.get_by_label("Don't Save").click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn quitting_with_the_editor_open_still_asks_about_unsaved_workbooks() { + let mut h = harness(); + three(&mut h); + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.run_steps(2); + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + // The saved, unchanged editor closes and the window close goes on (the window then asks to + // close again, which a test harness has to do by hand); the workbook is asked about as + // without the editor. + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + request_window_close(&mut h); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); +} + #[test] fn a_dialog_asked_for_over_the_editor_says_why_it_did_not_open() { let mut h = harness(); diff --git a/docs/macros.md b/docs/macros.md index d79efcc38..a31ef5cc6 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -293,7 +293,9 @@ other macros open; a macro that was left out is not in the saved file. the Output tab sets the anchor and what to do when results would land on data. The editor belongs to the workbook it opened in: while another workbook is active it says so and Save, Check, Run and Debug wait until that workbook is active again, and closing the workbook closes - the editor. + the editor. It keeps its window until it closes itself: another dialog asked for meanwhile is + refused with a message, and closing the app first asks about the editor's debug run and unsaved + steps, then about unsaved workbooks. - **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and From baf01c421123c3065ef0159d0288cdc6e6d500a2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:26:09 -0400 Subject: [PATCH 153/166] UI: the macro step editor fits a narrow window The editor's window had a minimum size of 660 by 380 and its toolbar was one row of about 680 pixels, so in a window 600 pixels wide Run to Here and Stop were off screen, and at 480 the inspector's tabs were cut off. The window is now never larger than the app's window, the name row, the toolbar and the inspector's tabs wrap (the toolbar's buttons are disabled in place rather than in a scope of their own, which kept them from wrapping), the inspector keeps room for its tabs, and its content scrolls sideways instead of widening the window. Tests check the editor at 600 and 480 pixels, and the Macros and Record Macro dialogs, which already fit. --- crates/ui-egui/src/macro_editor.rs | 36 +++++++++++++------------ crates/ui-egui/tests/it/macro_editor.rs | 28 +++++++++++++++++++ crates/ui-egui/tests/it/macros_ui.rs | 31 +++++++++++++++++++++ docs/macros.md | 3 ++- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 9af815675..24034c76e 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -790,14 +790,16 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu let home_title = home_title(app, &ed); let dirty_mark = if ed.dirty { " •" } else { "" }; let screen = ctx.content_rect(); + let fit = (screen.size() - vec2(16.0, 16.0)).max(vec2(240.0, 200.0)); let mut win_open = true; ed.refs.drawn = false; egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) .id(Id::new("gridcraft.macro_editor")) .collapsible(false) .resizable(true) - .default_size(vec2(840.0, 560.0)) - .min_size(vec2(660.0, 380.0)) + // Never larger than the app's window, so a narrow one keeps every control on screen. + .default_size(vec2(840.0, 560.0).min(fit)) + .min_size(vec2(660.0, 380.0).min(fit)) // To the right, so the data the overlays mark (usually top left) stays in view. .default_pos(pos2((screen.right() - 860.0).max(screen.left() + 20.0), screen.top() + 190.0)) .open(&mut win_open) @@ -1034,8 +1036,8 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { let t = Tokens::get(ui.ctx()); let debugging = app.session.macro_debug.is_some(); - // Name, description, Save. - ui.horizontal(|ui| { + // Name, description, Save; they wrap in a narrow window. + ui.horizontal_wrapped(|ui| { ui.label("Name:"); let name = ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")); ui.label("Description:"); @@ -1055,7 +1057,8 @@ fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { status_line(app, ui, ed, &t); ui.separator(); let avail = ui.available_size(); - let left_w = (avail.x * 0.52).max(300.0); + // The step list takes about half; in a narrow window the inspector keeps room for its tabs. + let left_w = (avail.x * 0.52).max(300.0).min((avail.x - 190.0).max(160.0)); ui.horizontal_top(|ui| { ui.allocate_ui_with_layout(vec2(left_w, avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { ui.set_min_size(vec2(left_w, avail.y)); @@ -1067,7 +1070,7 @@ fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { }); ui.separator(); ui.allocate_ui_with_layout(vec2(ui.available_width(), avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { - egui::ScrollArea::vertical().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { + egui::ScrollArea::both().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { inspector(app, ui, d, ed, debugging); }); }); @@ -1076,34 +1079,33 @@ fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { + // The buttons wrap onto a second row in a narrow window. + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let editing = !debugging; - if ui.add_enabled_ui(editing, |ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).inner.clicked() { + if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).clicked() { ed.picker = Some(Picker::default()); } let has_step = ed.selected.as_deref().and_then(split_path).is_some(); if ui - .add_enabled_ui(editing && has_step, |ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) - .inner + .add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) .clicked() { duplicate(ed); } - if ui.add_enabled_ui(editing && has_step, |ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).inner.clicked() { + if ui.add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).clicked() { delete(ed); } sep(ui, &t); if ui - .add_enabled_ui(editing, |ui| { + .add_enabled(editing, |ui: &mut Ui| { small_button(ui, Icon::Check, "Check", "Check the inputs and show on the grid what the macro would change", false) }) - .inner .clicked() { check(app, ed); } - if ui.add_enabled_ui(editing, |ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).inner.clicked() { + if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).clicked() { run(app, ed); } sep(ui, &t); @@ -1116,10 +1118,10 @@ fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { debug_action(app, ed, "continue"); } let can_run_to = ed.selected.as_deref().and_then(split_path).is_some(); - if ui.add_enabled_ui(can_run_to, |ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).inner.clicked() { + if ui.add_enabled(can_run_to, |ui: &mut Ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).clicked() { debug_action(app, ed, "runTo"); } - if ui.add_enabled_ui(debugging, |ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).inner.clicked() { + if ui.add_enabled(debugging, |ui: &mut Ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).clicked() { ed.ask_stop = true; } }); @@ -1486,7 +1488,7 @@ fn picker(ui: &mut Ui, ed: &mut Editor) { // ---------------------------------------------------------------- the inspector fn inspector(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, debugging: bool) { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { for (tab, label) in [(Tab::Step, "Details"), (Tab::Inputs, "Inputs"), (Tab::Output, "Output")] { if ui.selectable_label(ed.tab == tab, label).clicked() { ed.tab = tab; diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 418d36293..241c652b8 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -502,3 +502,31 @@ fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() { assert!(h.state().macros.editor.is_none()); assert!(h.state().grid.macro_overlay.is_none()); } + +#[test] +fn the_editor_fits_a_narrow_window() { + for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] { + let mut session = Session::new(); + session.new_workbook(); + let app = SheetApp::new(session, Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + three(&mut h); + h.run_steps(4); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let window = h.get_by_label_contains("Macro: Three").rect(); + assert!(screen.contains_rect(window), "the editor at {window:?} is outside the {size:?} window"); + // The editor's own controls (Delete is left out: the ribbon has one too). + for label in ["Add Step", "Duplicate", "Check", "Run", "Step", "Continue", "Run to Here", "Stop", "Details", "Inputs", "Output", "Save"] { + let r = h.get_by_label(label).rect(); + assert!(screen.contains_rect(r), "{label} at {r:?} is outside the {size:?} window"); + } + } +} diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index d7635d572..7c18b08ff 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -283,3 +283,34 @@ fn the_macros_dialog_offers_trust_for_scripts_that_came_with_a_file() { h.run_steps(3); assert_eq!(value(&h, "D4"), Value::Text("script".into())); } + +#[test] +fn the_macros_and_record_macro_dialogs_fit_a_narrow_window() { + for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("macro.save", json!({"name": "Tidy", "steps": []})).unwrap(); + let app = SheetApp::new(s, Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for (dialog, labels) in [("macros", &["Run", "Delete", "Options…", "Cancel"][..]), ("recordMacro", &["OK", "Cancel"][..])] { + h.state_mut().open_dialog(dialog, json!({})); + h.run_steps(4); + for label in labels { + // The dialog's own button: the last one drawn (the ribbon has a Delete too). + let r = h.get_all_by_label(label).last().map(|n| n.rect()).unwrap_or(egui::Rect::NOTHING); + assert!(screen.contains_rect(r), "{dialog}: {label} at {r:?} is outside the {size:?} window"); + } + h.state_mut().dialog = None; + h.run_steps(2); + } + } +} diff --git a/docs/macros.md b/docs/macros.md index a31ef5cc6..b315795ee 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -281,7 +281,8 @@ other macros open; a macro that was left out is not in the saved file. Run runs the selected macro on the selection or the selected script; Step Into, Edit and Create open the step editor; Delete asks first; Options sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, as before macros existed. -- **Step editor.** A resizable window that works on a draft and saves it with `macro.save`. The +- **Step editor.** A resizable window that works on a draft and saves it with `macro.save`; in a + narrow app window it is no larger than the window and its toolbar wraps onto a second row. The step list shows each step's number, a breakpoint dot in the gutter (click to set or clear), a category icon, a summary, an error marker and an enabled checkbox; Repeat bodies and If branches are indented under their step, and rows drag to reorder within their list. Add Step opens a From 0fdb4d03e4089acfd0df055d9ca651b30c6f4864 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:27:09 -0400 Subject: [PATCH 154/166] UI: a macro question is answered only in the workbook that asked it The Macros dialog's Delete question and the questions a macro run asks (approve it, overwrite or move the data in the way) keep the command and run it again with the answer. When another workbook became active before the answer (an agent can switch workbooks under the prompt), the answer ran there: Delete removed the other workbook's macro of the same name. A question now keeps its workbook, and an answer given while another one is active does nothing and says why. --- crates/ui-egui/src/macro_ui.rs | 13 +++++++++++- crates/ui-egui/tests/it/macros_ui.rs | 30 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index acbc77a2c..78fee3317 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -50,6 +50,8 @@ impl MacroUi { /// runs the command again with its parameters added; Cancel drops it. #[derive(Clone, Debug)] pub struct Question { + /// The workbook (`DocState::uid`) the question is about: the answer runs only there. + pub doc: u64, pub title: String, pub text: String, pub command: String, @@ -147,7 +149,8 @@ pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> } else { return false; }; - app.macros.question = Some(Question { title: title.into(), text, command: id.into(), params: params.clone(), choices }); + let doc = app.session.active().map_or(0, |d| d.uid); + app.macros.question = Some(Question { doc, title: title.into(), text, command: id.into(), params: params.clone(), choices }); true } @@ -202,6 +205,13 @@ pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { }); }); match answer { + // Another workbook became active meanwhile (an agent): the answer would run there. + Some(Some(_)) if app.session.active().is_none_or(|d| d.uid != q.doc) => { + app.macros.question = None; + let book = app.session.documents().iter().find(|d| d.uid == q.doc).map(|d| d.display_title()).unwrap_or_default(); + app.message = + Some((q.title.clone(), format!("The question was about “{book}”, which is no longer the active workbook, so nothing was done."))); + } Some(Some(extra)) => { app.macros.question = None; let mut params = q.params.clone(); @@ -441,6 +451,7 @@ pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut Kind::Script => ("Delete Script", "automate.deleteScript", "script"), }; app.macros.question = Some(Question { + doc: app.session.active().map_or(0, |d| d.uid), title: title.into(), text: format!("Do you want to delete {what} “{}”?", e.name), command: command.into(), diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index 7c18b08ff..c8e6ba490 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -314,3 +314,33 @@ fn the_macros_and_record_macro_dialogs_fit_a_narrow_window() { } } } + +#[test] +fn a_question_is_answered_in_the_workbook_that_asked_it() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(h.state().macros.question.is_some()); + // Another workbook with a macro of that name becomes active before the answer (an agent). + h.state_mut().dialog = None; + h.state_mut().session.new_workbook(); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.get_by_label("Delete").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1, "the other workbook's macro is kept"); + assert!(h.state().macros.question.is_none()); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message); + // Asked and answered in the same workbook, it deletes there. + h.state_mut().message = None; + h.run_steps(2); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + h.get_all_by_label("Delete").last().unwrap().click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().wb.macros.is_empty()); +} From a0fd599fe674f1ce7b8f3cbf12eac34a78670492 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:34:01 -0400 Subject: [PATCH 155/166] UI: the macro step editor reads each step in place step_at copied the whole step list to look up one step, and the step list calls it for every row on every frame, so a macro at the 10,000 step limit (which a recording can reach, or a file can carry) took about ten seconds a frame. The lookup now walks the list without copying it: five frames of that macro take well under a second. A test opens a macro at the limit with a generous time bound. --- crates/ui-egui/src/macro_editor.rs | 33 +++++++++++++++++++++++-- crates/ui-egui/tests/it/macro_editor.rs | 17 +++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 24034c76e..a225eee06 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -154,9 +154,38 @@ pub fn step_mut<'a>(steps: &'a mut Vec, path: &str) -> Option<&'a mut Step list_mut(steps, parent)?.get_mut(i) } +/// The step list a parent key names, read-only (see [`list_mut`]). +pub fn list_at<'a>(mut cur: &'a [Step], parent: &str) -> Option<&'a [Step]> { + let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + while let Some(tok) = toks.get(i) { + let idx = tok.parse::().ok()?.checked_sub(1)?; + let branch = toks.get(i + 1).copied(); + let st = cur.get(idx)?; + cur = match (&st.kind, branch) { + (StepKind::If { then, .. }, Some("then")) => { + i += 2; + then + } + (StepKind::If { otherwise, .. }, Some("else")) => { + i += 2; + otherwise + } + (StepKind::Repeat { body, .. }, _) => { + i += 1; + body + } + _ => return None, + }; + } + Some(cur) +} + +/// A copy of the step at `path`. Reads the path in place: copying the whole list for every row +/// made a large macro's step list quadratic. pub fn step_at(steps: &[Step], path: &str) -> Option { - let mut copy = steps.to_vec(); - step_mut(&mut copy, path).map(|s| s.clone()) + let (parent, i) = split_path(path)?; + list_at(steps, parent)?.get(i).cloned() } /// The paths of the steps with a breakpoint. diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 241c652b8..d08a8d641 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -530,3 +530,20 @@ fn the_editor_fits_a_narrow_window() { } } } + +#[test] +fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() { + let mut h = harness(); + let steps: Vec<_> = (0..gridcraft_engine::model::MAX_MACRO_STEPS) + .map(|i| json!({"command": "cell.set", "params": {"cell": format!("A{}", i + 1), "input": "x"}})) + .collect(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Big", "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Big"})); + h.run_steps(2); + // Each row used to copy the whole list: five frames took about a minute. Now well under a + // second; the bound leaves room for a slow machine. + let t = std::time::Instant::now(); + h.run_steps(5); + assert!(t.elapsed() < std::time::Duration::from_secs(10), "five frames took {:?}", t.elapsed()); + assert!(h.query_by_label_contains("Step 1: ").is_some()); +} From a414b608d7bafa3554a65b7106877b37e8564eea Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:34:50 -0400 Subject: [PATCH 156/166] Docs: the macro page says where a question's answer runs, and its long lines wrap The Questions entry says that an answer runs in the workbook that asked and does nothing while another one is active. The lines the restack joined in the recording paragraph and the Macros dialog entry are wrapped again. --- docs/macros.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index b315795ee..4f2051a0b 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -202,8 +202,8 @@ letter that GridCraft or another macro uses, is refused before anything is recor name the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when stopping wins over the one given when starting; a name that another macro has by then gets a suffix, such as `Tidy (2)`, so stopping never replaces a macro, and a shortcut another macro took -meanwhile is left off. `cancel: true` ends a recording without saving it, the way out of one whose -macro can't be saved. A parameter the call would ignore is refused: `relative` or `shortcut` when +meanwhile is left off, which the result says in `shortcutDropped`. `cancel: true` ends a recording +without saving it, the way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative` or `shortcut` when stopping, `stop: false` while recording, anything beside `cancel: true`, and one of the wrong kind. The macro is saved in the workbook where recording started, even if another one is active; if that workbook was closed, stopping ends the recording without saving it. When saving the @@ -277,10 +277,10 @@ other macros open; a macro that was left out is not in the saved file. - **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros (with their shortcuts), then its Automate scripts, and says when the file carries a VBA project. Scripts that came with an opened file stay disabled until Trust Scripts (`automate.trustScripts`) - trusts them for the session. - Run runs the selected macro on the selection or the selected script; Step Into, Edit and Create - open the step editor; Delete asks first; Options sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, - as before macros existed. + trusts them for the session. Run runs the selected macro on the selection or the selected + script; Step Into, Edit and Create open the step editor; Delete asks first; Options sets a + macro's shortcut letter and description. The dialog keeps the result of `view.macros`, as before + macros existed. - **Step editor.** A resizable window that works on a draft and saves it with `macro.save`; in a narrow app window it is no larger than the window and its toolbar wraps onto a second row. The step list shows each step's number, a breakpoint dot in the gutter (click to set or clear), a @@ -306,7 +306,8 @@ other macros open; a macro that was left out is not in the saved file. bar says "Debugging" with the macro's name, and Show reopens the editor. - **Questions.** A macro that needs approval offers Run; a macro whose output would land on data offers Overwrite, Shift Down and Shift Right. Each button runs the command again with - `approve: true` or the chosen `onConflict`. + `approve: true` or the chosen `onConflict`, in the workbook that asked: an answer given while + another workbook is active does nothing and says why. - **Shortcuts.** Ctrl+Shift+letter (⌘⇧letter on a Mac) on the grid runs the macro with that letter. The letters GridCraft uses itself keep their own meaning. From 60c20095e6c78525983ca58c3c9fdb7d88f6f72f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:35:31 -0400 Subject: [PATCH 157/166] UI: the Macros dialog closes when another workbook becomes active The dialog's rows and its Options pane hold the macros of the workbook it opened on, but Run, Delete and Options act on the active workbook. When an agent made another workbook active, the dialog stayed open over it, and OK in Options would write the first macro's shortcut and description onto the other workbook's macro of the same name. The dialog now keeps its workbook and closes when another one becomes active. --- crates/ui-egui/src/macro_ui.rs | 10 +++++++++- crates/ui-egui/tests/it/macros_ui.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 78fee3317..dcf691f65 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -343,7 +343,8 @@ pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog { let names = macro_list(app); let first = app.macros.recent.iter().find(|r| names.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned(); let name = params["name"].as_str().map(str::to_string).or(first).or_else(|| names.first().map(|m| m.0.clone())).unwrap_or_default(); - let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro"})); + let doc = app.session.active().map_or(0, |d| d.uid); + let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro", "doc": doc})); d.result = app.session.run("view.macros", json!({})).ok(); d } @@ -351,6 +352,13 @@ pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog { /// The Macros dialog's body: the name box and the list on the left, the buttons on the right, the /// description under them; Options… switches to the shortcut and description of one macro. pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + // Its rows and the Options pane are the workbook's it opened on: another workbook becoming + // active (an agent) closes it rather than run, delete or change a macro of the same name there. + let active = app.session.active().map(|doc| doc.uid); + if d.values.get("doc").and_then(Json::as_u64).is_some_and(|doc| Some(doc) != active) { + *open = false; + return; + } // Scripts can change while the dialog is open (Delete): read them again when the workbook does. let rev = app.session.active().map(|doc| doc.revision).unwrap_or(0); if d.values.get("revision").and_then(Json::as_u64) != Some(rev) { diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index c8e6ba490..171821cb7 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -344,3 +344,19 @@ fn a_question_is_answered_in_the_workbook_that_asked_it() { h.run_steps(3); assert!(h.state().session.active().unwrap().wb.macros.is_empty()); } + +#[test] +fn the_macros_dialog_closes_when_another_workbook_becomes_active() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Options…").click(); + h.run_steps(2); + // Its rows and the Options pane hold the first workbook's macros; an agent switches workbooks. + h.state_mut().session.new_workbook(); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "the dialog closed instead of acting on the other workbook"); + assert!(h.state().session.active().unwrap().wb.macros[0].shortcut.is_none()); +} From 1e2492b4d6474ac3cbe9e96382f9e003413b80cb Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:36:54 -0400 Subject: [PATCH 158/166] Tests: the large-macro editor test reads the UI clock Clippy refuses std::time::Instant::now, which traps on wasm; the test that bounds the step list's frame time reads gridcraft_ui_egui::now_ms instead. --- crates/ui-egui/tests/it/macro_editor.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index d08a8d641..91abbd397 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -542,8 +542,9 @@ fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() { h.run_steps(2); // Each row used to copy the whole list: five frames took about a minute. Now well under a // second; the bound leaves room for a slow machine. - let t = std::time::Instant::now(); + let t = gridcraft_ui_egui::now_ms(); h.run_steps(5); - assert!(t.elapsed() < std::time::Duration::from_secs(10), "five frames took {:?}", t.elapsed()); + let ms = gridcraft_ui_egui::now_ms() - t; + assert!(ms < 10_000.0, "five frames took {ms} ms"); assert!(h.query_by_label_contains("Step 1: ").is_some()); } From c142c70dbe38df105ef27dffc45acd37704ea347 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:55:55 -0400 Subject: [PATCH 159/166] UI: the macro editor keeps an input to a shape a macro may declare The format now refuses a single-cell input with a header, several column types or more than one row or column, a least above its most, and more column types than columns; the editor could make each of these, and the save then failed. - Switching an input to "single cell" sets one row and one column, takes the header off and keeps at most one column type; "Use current selection as example" does the same for one cell. - Setting "at least" above "at most" moves "at most" up (and the other way round), and lowering the columns' "at most" drops the column types past it. - Test: `an_edited_input_keeps_a_shape_a_macro_may_declare` (each result passes `check_limits`). --- crates/ui-egui/src/macro_editor.rs | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index a225eee06..621caae22 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -2013,6 +2013,7 @@ fn inputs_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) }); } if i != before { + tidy_input(&mut i, &before); if let Some(slot) = ed.draft.inputs.get_mut(k) { *slot = i; } @@ -2060,6 +2061,30 @@ fn example_headers(app: &SheetApp, example: &str, header: bool) -> Vec { .collect() } +/// Keeps an input the person is editing to the shapes a macro may declare, as the edit is made: +/// a single cell is one row and one column with no header and at most one column type; a least +/// above its most moves the other end along (the one just set wins); and a range lists no more +/// column types than it allows columns. +pub fn tidy_input(i: &mut NamedInput, before: &NamedInput) { + if i.kind == InputKind::Cell { + (i.rows, i.cols, i.header) = ([1, 1], [1, 1], false); + i.col_types.truncate(1); + return; + } + for (now, was) in [(&mut i.rows, before.rows), (&mut i.cols, before.cols)] { + if now[1] != 0 && now[0] > now[1] { + if now[0] != was[0] { + now[1] = now[0]; + } else { + now[0] = now[1]; + } + } + } + if i.cols[1] != 0 { + i.col_types.truncate(i.cols[1] as usize); + } +} + /// "Use current selection as example": the selection becomes the first input's example, and its /// shape, header and column types are read from it. pub fn use_selection_as_example(app: &SheetApp, ed: &mut Editor) { @@ -2069,11 +2094,13 @@ pub fn use_selection_as_example(app: &SheetApp, ed: &mut Editor) { ed.example = r.a1(); let Some(first) = ed.draft.inputs.first_mut() else { return }; let inferred = infer_input(sh, r); + let before = first.clone(); first.kind = if r.is_single() { InputKind::Cell } else { InputKind::Range }; first.header = inferred.0; first.col_types = inferred.1; first.cols = [r.width(), r.width()]; first.rows = [1, 0]; + tidy_input(first, &before); if first.default_ref.is_some() { first.default_ref = Some(ed.example.clone()); } @@ -2241,6 +2268,31 @@ pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_e mod tests { use super::*; + #[test] + fn an_edited_input_keeps_a_shape_a_macro_may_declare() { + let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() }; + // Switching a range to a single cell drops what a cell can't have. + let mut cell = NamedInput { kind: InputKind::Cell, ..range.clone() }; + tidy_input(&mut cell, &range); + assert_eq!((cell.rows, cell.cols, cell.header, cell.col_types.len()), ([1, 1], [1, 1], false, 1)); + // A least set above the most moves the most up; a most set below the least moves the least. + let mut i = NamedInput { rows: [12, 10], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!(i.rows, [12, 12]); + let mut i = NamedInput { rows: [2, 1], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!(i.rows, [1, 1]); + // At most 0 is no limit; fewer columns drop the column types past them. + let mut i = NamedInput { rows: [12, 0], cols: [1, 2], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!((i.rows, i.cols, i.col_types.len()), ([12, 0], [1, 2], 2)); + // Each passes the format's own check. + for input in [cell, i] { + let m = Macro { name: "M".into(), inputs: vec![input], ..Macro::default() }; + assert_eq!(m.check_limits(), Ok(()), "{m:?}"); + } + } + fn steps() -> Vec { vec![ Step::command("cell.set", json!({"cell": "A1", "input": "x"})), From c442da92212b76ea15efd1e427954c6c3da0a478 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:29:00 -0400 Subject: [PATCH 160/166] Engine: stopping a recording says when it left the chosen shortcut off Record Macro checks the Ctrl+Shift letter when recording starts, but another macro can take it before recording stops; the save then leaves the shortcut off, and nothing said so. The result now names it in shortcutDropped, the toast after Stop says the macro was saved without it, and the macro page describes it. --- crates/engine/src/cmd/macros.rs | 24 +++++++++++++++--------- crates/engine/src/tests/macros.rs | 19 +++++++++++++++++++ crates/ui-egui/src/macro_ui.rs | 6 +++++- docs/macros.md | 26 +++++++++++++------------- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index de1edd367..f75cee6ef 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Record Macro", ["View", "Macros"], None, - "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+K\" | \"K\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", + "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+K\" | \"K\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}", has_doc, record ), @@ -338,11 +338,15 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect(); } cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac)); - // The shortcut, unless another macro took it while recording. - if let Some(k) = &start.shortcut - && crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) - { - converted.mac.shortcut = Some(k.clone()); + // The shortcut, unless another macro took it while recording; then it is left off and the + // result says so. + let mut shortcut_dropped = None; + if let Some(k) = &start.shortcut { + if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) { + converted.mac.shortcut = Some(k.clone()); + } else { + shortcut_dropped = Some(k.clone()); + } } let steps = converted.mac.steps.len(); let inputs: Vec = converted.mac.inputs.iter().map(|i| i.name.clone()).collect(); @@ -360,7 +364,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star let key = (doc, crate::macros::fingerprint(m)); s.macro_approvals.insert(key); } - Ok( - json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}), - ) + let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}); + if let Some(k) = shortcut_dropped { + out["shortcutDropped"] = json!(k); + } + Ok(out) } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 868356c41..19a23a1ac 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2413,3 +2413,22 @@ fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() { assert_eq!(shortcut_conflict(&wb, "a", 'M'), None); assert_eq!(shortcut_conflict(&wb, "B", 'Q'), None); } + +#[test] +fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() { + let mut s = s(); + s.execute("macro.record", json!({"name": "Tidy", "shortcut": "m"})).unwrap(); + set(&mut s, "A1", "1"); + // Another macro takes Ctrl+Shift+M before recording stops. + s.execute("macro.save", json!({"name": "Other", "shortcut": "Ctrl+Shift+M", "steps": []})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert_eq!(r["saved"], "Tidy"); + assert_eq!(r["shortcutDropped"], "Ctrl+Shift+M", "{r}"); + assert!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["shortcut"].is_null()); + // A free one is kept and not reported. + s.execute("macro.record", json!({"name": "Keep", "shortcut": "q"})).unwrap(); + set(&mut s, "A2", "2"); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert!(r.get("shortcutDropped").is_none(), "{r}"); + assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q"); +} diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index dcf691f65..d80e4c800 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -89,7 +89,11 @@ pub fn stop_recording(app: &mut SheetApp) { Some(name) => { let steps = v["steps"].as_u64().unwrap_or(0); app.macros.note_recent(name); - app.toast = Some((format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }), crate::now_ms())); + let mut text = format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }); + if let Some(k) = v["shortcutDropped"].as_str() { + text.push_str(&format!(" without {k}: another macro took it while recording")); + } + app.toast = Some((text, crate::now_ms())); } None => app.toast = Some(("Recording stopped".into(), crate::now_ms())), }, diff --git a/docs/macros.md b/docs/macros.md index 4f2051a0b..5018975d6 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -198,19 +198,19 @@ on its own, never as a step of a script or macro, and is never recorded. references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+K"` or `"K"`) and its description. They are checked at once, so a name that is taken (a kept one included), too long or holds line breaks, or a -letter that GridCraft or another macro uses, is refused before anything is recorded. Without a -name the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when -stopping wins over the one given when starting; a name that another macro has by then gets a -suffix, such as `Tidy (2)`, so stopping never replaces a macro, and a shortcut another macro took -meanwhile is left off, which the result says in `shortcutDropped`. `cancel: true` ends a recording -without saving it, the way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative` or `shortcut` when -stopping, `stop: false` while recording, anything beside `cancel: true`, and one of the wrong kind. -The macro is saved in the workbook where recording started, even if another one is active; -if that workbook was closed, stopping ends the recording without saving it. When saving the -recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged -in that workbook), recording goes on, so stopping again keeps what was recorded. The person -recorded the macro, so in a workbook opened from a file it runs without the approval a macro that -came with the file needs, until it changes. +letter that GridCraft or another macro uses, is refused before anything is recorded. Without a name +the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when stopping wins +over the one given when starting; a name that another macro has by then gets a suffix, such as `Tidy +(2)`, so stopping never replaces a macro, and a shortcut another macro took meanwhile is left off, +which the result says in `shortcutDropped`. `cancel: true` ends a recording without saving it, the +way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative` +or `shortcut` when stopping, `stop: false` while recording, anything beside `cancel: true`, and one +of the wrong kind. The macro is saved in the workbook where recording started, even if another one +is active; if that workbook was closed, stopping ends the recording without saving it. When saving +the recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged +in that workbook), recording goes on, so stopping again keeps what was recorded. The person recorded +the macro, so in a workbook opened from a file it runs without the approval a macro that came with +the file needs, until it changes. A recording keeps up to 100,000 commands and 64 MB of them as JSON, and a macro holds up to 10,000 steps and 4,194,304 characters of JSON. The steps past any of these limits, and steps From ea5eb2ae3c7a163d77174efc29277c1e92cce6e4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:27:00 -0400 Subject: [PATCH 161/166] UI: Macro Options saves over the macro it changes Saving a macro over one with the same name now needs `replace: true`, so Macro Options, which changes the shortcut and description of a macro that exists, failed to save. It passes `replace: true`; `options_set_a_shortcut_that_runs_the_macro` covers it. --- crates/ui-egui/src/macro_ui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index d80e4c800..ff303f586 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -574,7 +574,8 @@ pub fn save_options(app: &mut SheetApp, name: &str, letter: Option, descri }; o.insert("description".into(), json!(description)); } - match app.run("macro.save", json!({"macro": m})) { + // Macro Options changes a macro that exists, so the save replaces it. + match app.run("macro.save", json!({"macro": m, "replace": true})) { Ok(_) => true, Err(e) => { app.message = Some(("Macro Options".into(), crate::clean_error(&e))); From 59d85ca138f32393da9e014603c15b82539f16c0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:30:45 -0400 Subject: [PATCH 162/166] UI: the macro editor refuses a taken name in the person's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving over a macro now needs `replace: true`, and the engine's refusal says so in terms for programs. A new draft whose name another macro has (or a kept one from a newer version) is refused by the editor first, with "A macro called “three” already exists. Choose another name." - Test: `a_new_macro_never_replaces_one_with_its_name`. --- crates/ui-egui/src/macro_editor.rs | 9 ++++++++- crates/ui-egui/tests/it/macro_editor.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 621caae22..f140a8beb 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -586,8 +586,15 @@ pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { // The workbook has it under the new name now, even if the save below fails. ed.saved_as = Some(ed.draft.name.clone()); } - // A new draft never replaces a macro that has its name. + // A new draft never replaces a macro that has its name; the person chooses another. let replace = ed.saved_as.is_some(); + if !replace + && let Some(doc) = app.session.active() + && let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, &ed.draft.name) + { + app.message = Some(("Save Macro".into(), e)); + return false; + } match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft), "replace": replace})) { Ok(_) => { ed.saved_as = Some(ed.draft.name.clone()); diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 91abbd397..33d3861e4 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -291,7 +291,7 @@ fn a_new_macro_never_replaces_one_with_its_name() { assert_eq!(wb.macros[0].steps.len(), 3, "{button}: the saved macro is intact"); assert!(value(&h, "B1").is_empty(), "{button}: nothing ran"); let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default(); - assert!(msg.contains("A macro called 'three' already exists"), "{button}: {msg}"); + assert!(msg.contains("A macro called “three” already exists. Choose another name."), "{button}: {msg}"); assert!(h.state().macros.editor.as_ref().unwrap().saved_as.is_none()); h.run_steps(2); } From ffacf7405f949dcbed628aaef5a52c692ffa2acb Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:14:24 -0400 Subject: [PATCH 163/166] Docs: Record Macro's shortcut example uses a letter a macro may take The example was K, which GridCraft uses (Group), so a caller who copied it was refused; it is M now, in the command's description and on the macro page. --- crates/engine/src/cmd/macros.rs | 2 +- docs/macros.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index f75cee6ef..6d1cdf08a 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Record Macro", ["View", "Macros"], None, - "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+K\" | \"K\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}", + "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}", has_doc, record ), diff --git a/docs/macros.md b/docs/macros.md index 5018975d6..3335b5ecc 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -196,7 +196,7 @@ on its own, never as a step of a script or macro, and is never recorded. `macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's -name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+K"` or `"K"`) and its description. They are +name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+M"` or `"M"`) and its description. They are checked at once, so a name that is taken (a kept one included), too long or holds line breaks, or a letter that GridCraft or another macro uses, is refused before anything is recorded. Without a name the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when stopping wins From 8fd6ec124ba8a3b9058833bd384bd2162bca0e43 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:32:53 -0400 Subject: [PATCH 164/166] Engine: a macro shortcut is one letter, alone or after Ctrl+Shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_shortcut` took the last letter of any spelling, so `"Ctrl+Alt+M"` was saved as Ctrl+Shift+M. It now takes a bare letter or one after Ctrl+Shift, Cmd+Shift or ⌘⇧, and refuses other modifiers. - Test: `shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones`. --- crates/engine/src/macros/mod.rs | 20 ++++++++++++-------- crates/engine/src/tests/macros.rs | 4 +++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index f500b9815..ecbe6f674 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -435,15 +435,19 @@ pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[ ('Z', "Redo"), ]; -/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"⌘⇧M"` and `"m"` all give `'M'`. `None` -/// when it is not one letter from A to Z. +/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"Cmd+Shift+M"`, `"⌘⇧M"` and `"m"` all give +/// `'M'`. `None` when it is not one letter from A to Z, alone or after Ctrl+Shift (Cmd+Shift): +/// other modifiers (`"Ctrl+Alt+M"`) are refused rather than read as Ctrl+Shift. pub fn parse_shortcut(s: &str) -> Option { - let last = s.trim().rsplit(['+', '⇧', '⌘']).next()?.trim(); - let mut chars = last.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()), - _ => None, - } + let letter = |t: &str| { + let mut chars = t.trim().chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()), + _ => None, + } + }; + let t = s.trim().to_ascii_lowercase(); + ["ctrl+shift+", "cmd+shift+", "⌘⇧"].iter().find_map(|p| t.strip_prefix(p)).map_or_else(|| letter(&t), letter) } /// How a shortcut is stored and shown: `Ctrl+Shift+M`. diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 19a23a1ac..2903ac79a 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2400,7 +2400,9 @@ fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() { assert_eq!(parse_shortcut("Ctrl+Shift+M"), Some('M')); assert_eq!(parse_shortcut("⌘⇧k"), Some('K')); assert_eq!(parse_shortcut(" q "), Some('Q')); - for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é"] { + assert_eq!(parse_shortcut("cmd+shift+r"), Some('R')); + // Other modifiers are refused, not read as Ctrl+Shift. + for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é", "Ctrl+Alt+M", "Shift+M", "Alt+M", "Ctrl+M"] { assert_eq!(parse_shortcut(bad), None, "{bad}"); } assert_eq!(shortcut_text('m'), "Ctrl+Shift+M"); From 3772f2c61ac329cba318b2626fc78e0e9ef12992 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:21:00 -0400 Subject: [PATCH 165/166] Macros: write the macro names into xlsx again after the main merge The merge with main kept main's xlsx line in save_bytes_as, which dropped macros::with_names, so an xlsx save carried no macros. --- crates/engine/src/io.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index d72a2cd34..7702204f8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -249,8 +249,10 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result