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%)