diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 352797eab..edb0e2d7c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -741,7 +741,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 { @@ -1264,6 +1264,16 @@ 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 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 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::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); + } if cut_move { s.clipboard = None; } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index f248c59f2..009d77f2a 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", @@ -800,7 +809,7 @@ 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; let i = s.add_document(copy); @@ -1087,6 +1096,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/file.rs b/crates/engine/src/cmd/file.rs index a61c6298f..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,7 +83,8 @@ 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); } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5334784fa..7718279b2 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,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; a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is 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} (a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is 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(&s.doc()?.wb, p, n)?; + } let d = s.doc()?; let scope = match str_param(p, "scope") { None | Some("Workbook") => None, @@ -374,8 +385,29 @@ 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. +/// 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 { + // 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<()> { + 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." + ))); + } + 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(&s.doc()?.wb, p, &name)?; let scope = match str_param(p, "scope") { None => None, Some("Workbook") => Some(None), @@ -461,7 +493,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 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 && 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(); edit(s, |cx| { @@ -742,6 +778,57 @@ 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 }); + // 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"); + 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}), + 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", "_sc_old"] { + 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())); + // 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(); + 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/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f373de489..f5b478ccf 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -7,11 +7,11 @@ 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), - 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), ] } @@ -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(), @@ -148,9 +149,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 1f15ddfb8..8a0bb1052 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; @@ -96,12 +97,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 (automate.stopAction)".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()) } @@ -179,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/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 555c2fb79..a453f250b 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -586,11 +586,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/cmd/stash.rs b/crates/engine/src/cmd/stash.rs new file mode 100644 index 000000000..c0e8302ca --- /dev/null +++ b/crates/engine/src/cmd/stash.rs @@ -0,0 +1,606 @@ +//! 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. +//! +//! 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; + +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 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 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 parts = Parts::default(); + for n in wb.names.iter() { + 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(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() { + if *i != want as u64 { + break; + } + out.push_str(&string_literals(f)); + 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)?; + 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, + // 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() { + 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| !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). +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 +/// 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() +} + +/// 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()) +} + +/// 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) { + let (key, k) = rest.rsplit_once('_')?; + // 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))); + } + 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, 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); + 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() { + // 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 += cost; + if escaped { + literal.push(ESCAPE); + let _ = write!(literal, "{:04X}", u32::from(ch)); + } else { + 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")); + // 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\")")); + 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 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 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(); + 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 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(); + 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"]))); + + // 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/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 066a9e559..b4ab60f3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,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; @@ -66,6 +67,51 @@ pub struct HistoryEntry { pub retained_bytes: usize, } +/// 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, +} + +/// 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: Arc, + selection: Selection, + len: usize, + stepped: bool, +} + /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. #[derive(Clone, Debug)] pub struct Clipboard { @@ -84,6 +130,34 @@ 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. 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. + 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. @@ -120,6 +194,11 @@ pub struct DocState { /// user trusts it in this session (`automate.trustScripts`); one with no scripts starts /// trusted, since any script it gains later was written here. pub scripts_trusted: bool, + /// Where the workbook's content came from: whether its active content may run without + /// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the + /// workbook is opened from a file, carried by content pasted from such a workbook, and never + /// changed by a trust decision. + pub trust: Trust, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -160,17 +239,31 @@ fn push_history_with_budget( selection: Selection, byte_budget: usize, ) { + push_entry(history, label, wb, next, selection); + trim_to_budget(history, byte_budget); +} + +/// Adds a step without applying the limits: inside a command they wait for the outermost +/// command to collapse its steps (see [`Session::trim_history`]). +fn push_entry(history: &mut Vec, label: String, wb: Arc, next: &Workbook, selection: Selection) { let retained_bytes = history_retained_bytes(&wb, next); history.push(HistoryEntry { label, wb, selection, retained_bytes }); +} + +/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one +/// (even if that one edit exceeds the budget). +fn trim_to_budget(history: &mut Vec, byte_budget: usize) { let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes)); - // Always preserve the latest action, even if that one edit exceeds the budget. while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) { total = total.saturating_sub(history.remove(0).retained_bytes); } } 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); @@ -191,6 +284,7 @@ impl DocState { unlocked_ranges: vec![], content_kind: None, scripts_trusted, + trust, } } pub fn is_dirty(&self) -> bool { @@ -307,8 +401,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. @@ -320,6 +419,17 @@ 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, + /// 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, @@ -359,7 +469,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); } @@ -373,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 @@ -412,7 +523,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()))?; @@ -421,36 +533,352 @@ 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()))); + // 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 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))); + 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()), + 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); 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) => { 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 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) { d.wb = wb.clone(); d.selection = sel.clone(); + if let Some((undo, redo)) = history_before { + 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 { + self.trim_history(); + self.group_step = None; + } 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. 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) }); + } 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. + // 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 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) { - push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel); + 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, + // 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 + && 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.stepped && Arc::ptr_eq(&g.after, &d.wb)) + { + label = last.label.clone(); + } + d.undo.truncate(len); + } + push_entry(&mut d.undo, label, wb, &d.wb, sel); + 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 + // 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 + } + + /// 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)?; + let doc = token.doc(); + // 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), + }, + 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) + } + } + } + + /// 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 { + push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection); d.redo.clear(); d.revision += 1; } - result + 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(); + } + 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 + /// 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) { + let groups = &self.groups; + for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) { + trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT); + } + } + + /// 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; + // 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(()) + } + + /// 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) + } + + /// 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 { @@ -468,6 +896,8 @@ impl Session { 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 { let what = match id { "file.open" => "`path` or `base64`", diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs new file mode 100644 index 000000000..7804be6c9 --- /dev/null +++ b/crates/engine/src/record.rs @@ -0,0 +1,66 @@ +//! 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; +/// 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)] +#[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`] 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) { + // 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()); + if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { + 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/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.rs b/crates/engine/src/tests.rs index 756a4d090..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(); @@ -4599,3 +4604,4 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() { let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap(); assert!(!html.contains("") && !html.contains("table-layout") && !html.contains("