Merge #2151 (9fdf03cf, with #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2144

# Conflicts:
#	crates/engine/src/lib.rs
#	docs/macros.md
This commit is contained in:
prxnvt
2026-10-25 02:17:32 -04:00
52 changed files with 2316 additions and 584 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency: concurrency:
group: sonarcloud-${{ github.ref }} group: sonarcloud-${{ github.ref }}
cancel-in-progress: true # A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
sonarcloud: sonarcloud:
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
{ {
// No usable saved language: keep the browser's, as the desktop keeps the system's. // No usable saved language: keep the browser's, as the desktop keeps the system's.
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language); let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
app.ui = state; app.ui = state.at_startup();
app.ui.language = language; app.ui.language = language;
} }
if let Some(s) = prefs.filter(readable) if let Some(s) = prefs.filter(readable)
+1 -1
View File
@@ -111,7 +111,7 @@ fn load_prefs(app: &mut SheetApp) {
{ {
let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v)); let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v));
let language = saved.unwrap_or(app.ui.language); let language = saved.unwrap_or(app.ui.language);
app.ui = ui; app.ui = ui.at_startup();
app.ui.language = language; app.ui.language = language;
} }
if let Ok(b) = std::fs::read(dir.join("prefs.json")) if let Ok(b) = std::fs::read(dir.join("prefs.json"))
+45
View File
@@ -652,6 +652,24 @@ fn filter_blank(v: &Value) -> bool {
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
} }
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
return;
}
let Some(af) = sh.autofilter.as_ref() else { return };
let r = af.range;
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
return;
}
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
if let Some(af) = sh.autofilter.as_mut() {
af.range = grown;
}
}
/// Hides rows of the autofilter range that fail any criterion. /// Hides rows of the autofilter range that fail any criterion.
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
let Some(sh) = wb.sheet(sheet) else { return }; let Some(sh) = wb.sheet(sheet) else { return };
@@ -1554,6 +1572,33 @@ mod tests {
} }
} }
#[test]
fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap();
s.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1());
s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap();
assert_eq!(range(&s), Some("A1:B4".to_string()));
s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A row typed after a blank row does not grow it.
s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A cell outside the filter's columns does not either.
s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// Empty text is a blank entry, not a value.
s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap();
assert_eq!(range(&s), Some("A1:B5".to_string()));
// A formula and Ctrl+Enter (range.fill) are entries too.
s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap();
assert_eq!(range(&s), Some("A1:B6".to_string()));
s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap();
assert_eq!(range(&s), Some("A1:B7".to_string()));
}
#[test] #[test]
fn list_validation_without_formula1_is_an_error() { fn list_validation_without_formula1_is_an_error() {
let mut s = Session::new(); let mut s = Session::new();
+5 -1
View File
@@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at); sh.remove_cell(at);
} }
} }
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at); cx.touch(sheet, at);
Ok(Json::Null) Ok(Json::Null)
})?; })?;
@@ -350,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
sh.remove_cell(at); sh.remove_cell(at);
} }
} }
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at); cx.touch(sheet, at);
count += 1; count += 1;
} }
@@ -390,7 +392,9 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
} }
cx.sheet_mut(sheet)?.set_cell(at, cell); let sh = cx.sheet_mut(sheet)?;
sh.set_cell(at, cell);
super::data::grow_autofilter(sh, at);
cx.touch(sheet, at); cx.touch(sheet, at);
} }
} }
+22 -2
View File
@@ -212,6 +212,15 @@ pub fn specs() -> Vec<CommandSpec> {
trust_scripts trust_scripts
), ),
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), 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 // Cell controls, themes, borders, styles
cmd!( cmd!(
"insert.checkbox", "insert.checkbox",
@@ -800,10 +809,9 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
fn new_window(s: &mut Session, _: &Json) -> Result<Json> { fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?.clone(); 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.selection = d.selection.clone();
copy.scripts_trusted = d.scripts_trusted; copy.scripts_trusted = d.scripts_trusted;
copy.trust = d.trust;
let i = s.add_document(copy); let i = s.add_document(copy);
Ok(json!({"index": i})) Ok(json!({"index": i}))
} }
@@ -1088,6 +1096,18 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"ran": n})) 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<Json> {
// Inside another command the open groups are that command's own (an action in progress):
// a step of it that stopped them would pull the workbook from under the rest of its run.
if s.depth() > 1 {
return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into()));
}
let uid = s.doc()?.uid;
Ok(json!({"stopped": s.abort_groups(uid)}))
}
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> { fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
edit(s, |cx| { edit(s, |cx| {
+3 -4
View File
@@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
Some(name) => { Some(name) => {
let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?; let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
let title = crate::sample::title(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(), None => s.new_workbook(),
}; };
@@ -83,12 +83,11 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
{ {
s.close_document(0); 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 { 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); doc.content_kind = Some(crate::io::FileKind::Xlsx);
} }
// Someone else may have made the file: its active content asks before it runs.
doc.trust = crate::Trust::FromFile;
let i = s.add_document(doc); let i = s.add_document(doc);
// The decrypted-workbook notice is for the info bar, not a message box. // The decrypted-workbook notice is for the info bar, not a message box.
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
+92 -5
View File
@@ -1,7 +1,7 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellError, CellRef, RangeRef, Value}; 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 serde_json::{Value as Json, json};
use super::*; use super::*;
@@ -32,18 +32,26 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Name…", "Define Name…",
["Formulas", "Defined Names"], ["Formulas", "Defined Names"],
None, 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_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
has_doc, has_doc,
define_name 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_<key>_<n>` or `_sc_<key>`, 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!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
cmd!( cmd!(
"formulas.createFromSelection", "formulas.createFromSelection",
"Create from Selection", "Create from Selection",
["Formulas", "Defined Names"], ["Formulas", "Defined Names"],
Some("Cmd+Shift+F3"), 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_<key>_<n>` and `_sc_<key>`, are listed in `skipped` unless force is true)}",
has_doc, has_doc,
create_from_selection create_from_selection
), ),
@@ -268,6 +276,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
if !valid_name(&name) { if !valid_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into())); 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 d = s.doc()?;
let scope = match str_param(p, "scope") { let scope = match str_param(p, "scope") {
None | Some("Workbook") => None, 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<Json> { fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); 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") { let scope = match str_param(p, "scope") {
None => None, None => None,
Some("Workbook") => Some(None), Some("Workbook") => Some(None),
@@ -461,7 +493,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
// reported. // reported.
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); 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<String> = skipped.into_iter().map(|(n, _)| n).collect(); let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
let n = made.len(); let n = made.len();
edit(s, |cx| { edit(s, |cx| {
@@ -742,6 +778,57 @@ mod tests {
assert_eq!(r["refersTo"], "=Sheet1!$A$1"); 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::<Vec<_>>(), 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] #[test]
fn arabic_defined_names_count_characters_not_bytes() { fn arabic_defined_names_count_characters_not_bytes() {
let mut s = Session::new(); let mut s = Session::new();
+1 -1
View File
@@ -109,7 +109,7 @@ pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
/// Undo and Redo wait while an undo group is open on the active document (an action that spans /// Undo and Redo wait while an undo group is open on the active document (an action that spans
/// several calls is still running). /// several calls is still running).
fn not_grouping(s: &Session) -> std::result::Result<(), String> { fn not_grouping(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first".into()) } else { Ok(()) } if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
} }
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?; has_doc(s)?;
+227 -10
View File
@@ -179,6 +179,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc, has_doc,
change_source change_source
), ),
cmd!(
"pivot.showDetails",
"Show Details",
[],
None,
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}",
has_doc,
show_details
),
cmd!( cmd!(
"pivot.delete", "pivot.delete",
"Delete PivotTable", "Delete PivotTable",
@@ -654,16 +663,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let (si, anchor) = match target { let (si, anchor) = match target {
Some(t) => t, Some(t) => t,
None => { None => {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len()); let at = insert_sheet_before_active(cx);
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
new_sheet_at = Some(at); new_sheet_at = Some(at);
(at, CellRef::new(2, 0)) (at, CellRef::new(2, 0))
} }
@@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
Ok(res) Ok(res)
} }
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
/// Show Details sheet); returns its index.
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
let nm = cx.wb.next_sheet_name();
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
for n in cx.wb.names.iter_mut() {
if let Some(sc) = n.scope.as_mut()
&& *sc >= at
{
*sc += 1;
}
}
at
}
/// Show Details (double-click a value cell): the source records behind it, as values with their
/// column's number format, under the source headers on a new sheet.
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
.iter()
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?;
let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
if d.wb.protected_structure {
return Err(other("The workbook is protected and cannot be changed."));
}
let count = records.len();
let mut new_sheet_at: Option<usize> = None;
let res = edit(s, |cx| {
let si = insert_sheet_before_active(cx);
new_sheet_at = Some(si);
let base = cx.wb.styles.get(StyleId::default()).clone();
let fmts: Vec<StyleId> = src
.formats
.iter()
.map(|f| {
let mut st = base.clone();
st.num_fmt = NumFmt::new(f);
cx.wb.styles.intern(st)
})
.collect();
// Columns wide enough for their text, as the PivotTable report's are (dates would show
// as ####); the first rows are enough to judge a column.
let font = base.font.clone();
let widths: Vec<f32> = src
.headers
.iter()
.enumerate()
.map(|(c, h)| {
let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General");
let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c)));
let widest = texts
.map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false))
.fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max);
(widest + 14.0).min(320.0)
})
.collect();
let sh = cx.sheet_mut(si)?;
for (c, w) in widths.into_iter().enumerate() {
if w > sh.col_width(c as u32) {
sh.cols.entry(c as u32).or_default().size = Some(w);
}
}
for (c, h) in src.headers.iter().enumerate() {
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
}
for (r, i) in records.iter().enumerate() {
let Some(rec) = src.rows.get(*i) else { continue };
for (c, v) in rec.iter().enumerate() {
let style = fmts.get(c).copied().unwrap_or_default();
if v.is_empty() && style == StyleId::default() {
continue;
}
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
}
}
let name = sh.name.clone();
cx.wb.active_sheet = si;
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
cx.structural = true;
Ok(json!({"sheet": name, "records": count}))
})?;
if let Some(at) = new_sheet_at
&& let Ok(d) = s.doc_mut()
{
d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default());
}
Ok(res)
}
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> { fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?; let d = s.doc()?;
let name = match str_param(p, "table") { let name = match str_param(p, "table") {
@@ -2236,6 +2334,7 @@ mod tests {
"data.refreshAll", "data.refreshAll",
"pivot.changeSource", "pivot.changeSource",
"pivot.delete", "pivot.delete",
"pivot.showDetails",
]; ];
let hostile = [ let hostile = [
json!({}), json!({}),
@@ -2363,4 +2462,122 @@ mod tests {
assert_eq!(l[0]["anchor"], "A3"); assert_eq!(l[0]["anchor"], "A3");
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
} }
/// The active sheet's first five columns as shown, down to the first blank row.
fn sheet_rows(s: &mut Session) -> Vec<Vec<String>> {
let mut out = Vec::new();
for r in 0..30u32 {
let row: Vec<String> = (0..5)
.map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string())
.collect();
if row.iter().all(String::is_empty) {
break;
}
out.push(row);
}
out
}
#[test]
fn show_details_lists_a_value_cells_records_on_a_new_sheet() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 is East × Apples (100 + 60); the active cell is the default.
s.execute("selection.set", json!({"range": "B5"})).unwrap();
let r = s.execute("pivot.showDetails", json!({})).unwrap();
assert_eq!(r["records"], 2);
let d = s.doc().unwrap();
assert_eq!(d.wb.sheets.len(), 3);
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
assert_eq!(
sheet_rows(&mut s),
vec![
vec!["Region", "Product", "Date", "Units", "Sales"],
vec!["East", "Apples", "1/15/2026", "10", "100"],
vec!["East", "Apples", "9/9/2026", "6", "60"],
]
);
// The dates fit their column instead of showing ####.
let sh = s.doc().unwrap().wb.active().unwrap().clone();
assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5));
// One undo step takes the sheet away.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
assert_eq!(val(&s, "B5"), Value::Number(160.0));
}
#[test]
fn show_details_on_a_total_takes_every_record_it_adds_up() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
let records = |s: &mut Session, cell: &str| {
let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap();
s.execute("edit.undo", json!({})).unwrap();
n
};
assert_eq!(records(&mut s, "E5"), 5, "East's row total");
assert_eq!(records(&mut s, "C8"), 4, "Pears' column total");
assert_eq!(records(&mut s, "E8"), 12, "the grand total");
// A subtotal at the top of its group, in a two-level row axis.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(records(&mut s, "B4"), 5);
assert_eq!(text(&s, "A6"), "Pears");
assert_eq!(records(&mut s, "B6"), 2);
}
#[test]
fn show_details_leaves_out_the_records_the_report_filters_out() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap();
let gt = find_row(&s, "Grand Total").unwrap();
let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap();
assert_eq!(r["records"], 2);
let rows = sheet_rows(&mut s);
assert_eq!(rows.len(), 3);
assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}");
}
#[test]
fn show_details_refuses_a_report_its_source_has_moved_on_from() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// B5 still shows East × Apples (160), but the first East record is now Central's.
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
assert_eq!(val(&s, "B5"), Value::Number(160.0));
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
// After a refresh East × Apples (now B6) has the one record left.
s.execute("pivot.refresh", json!({})).unwrap();
assert_eq!(text(&s, "A6"), "East");
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1);
}
#[test]
fn show_details_refuses_a_report_that_would_now_be_shorter() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
// North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total.
s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap();
s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap();
assert_eq!(text(&s, "A8"), "Grand Total");
let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string();
assert!(e.contains("out of date"), "{e}");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
#[test]
fn show_details_needs_a_value_cell() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] {
assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
}
} }
+289 -34
View File
@@ -7,8 +7,16 @@
//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep //! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep
//! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this //! one name each, `_sc_<key>`; reading accepts that form as well, so those items can move to this
//! format later, and a write of the same key replaces it. //! 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, HashSet}; use std::collections::{BTreeMap, HashSet};
use std::fmt::Write as _;
use gridcraft_model::{DefinedName, Workbook}; use gridcraft_model::{DefinedName, Workbook};
use serde_json::Value as Json; use serde_json::Value as Json;
@@ -25,23 +33,106 @@ const FORMULA_UNITS: usize = 8_192;
const MAX_KEY: usize = 200; const MAX_KEY: usize = 200;
/// The longest item: 4,194,304 characters. /// The longest item: 4,194,304 characters.
pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; 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<u64, &'a str>,
legacy: Option<&'a str>,
}
impl<'a> Parts<'a> {
fn add(&mut self, chunk: Option<u64>, 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<usize> = 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<String, Parts<'a>> {
let prefix = prefix.to_ascii_lowercase();
let mut items: BTreeMap<String, Parts<'a>> = 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). /// 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<String> { pub fn get(wb: &Workbook, key: &str) -> Option<String> {
let mut chunks: BTreeMap<u64, &str> = BTreeMap::new(); let mut parts = Parts::default();
let mut legacy = None;
for n in wb.names.iter() { for n in wb.names.iter() {
match item_of(n) { if let Some((k, chunk)) = item_of(n)
Some((k, Some(i))) if k.eq_ignore_ascii_case(key) => { && k.eq_ignore_ascii_case(key)
chunks.entry(i).or_insert(&n.formula); {
} parts.add(chunk, &n.formula);
Some((k, None)) if k.eq_ignore_ascii_case(key) => legacy = legacy.or(Some(&n.formula)),
_ => {}
} }
} }
assemble(&parts)
}
/// What a listing found: the first [`MAX_ITEMS`] items or keys, and how many more there were.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Listing<T> {
pub found: T,
/// Items past the cap, not listed. A caller says so rather than letting them vanish.
pub more: usize,
}
/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass
/// over the names: the first [`MAX_ITEMS`] items that can be read, and the count of the readable
/// ones past them. An item over the size limit is neither listed nor counted, so it takes no
/// place another item could have. ([`keys`] lists keys without reading the items.)
pub fn items(wb: &Workbook, prefix: &str) -> Listing<BTreeMap<String, String>> {
let mut listing: Listing<BTreeMap<String, String>> = Listing::default();
for (k, parts) in group(wb, prefix) {
let Some(text) = parts.is_item().then(|| assemble(&parts)).flatten() else { continue };
if listing.found.len() < MAX_ITEMS {
listing.found.insert(k, text);
} else {
listing.more = listing.more.saturating_add(1);
}
}
listing
}
/// The text of the item held by `parts`, as [`get`] reads it.
fn assemble(parts: &Parts) -> Option<String> {
let chunks = &parts.chunks;
let mut out = String::new(); let mut out = String::new();
if !chunks.contains_key(&0) { if !chunks.contains_key(&0) {
out = string_literals(legacy?); out = string_literals(parts.legacy?);
} else { } else {
// Chunks run 0, 1, 2, …; anything after a gap isn't part of the item. // Chunks run 0, 1, 2, …; anything after a gap isn't part of the item.
for (want, (i, f)) in chunks.iter().enumerate() { for (want, (i, f)) in chunks.iter().enumerate() {
@@ -49,14 +140,47 @@ pub fn get(wb: &Workbook, key: &str) -> Option<String> {
break; break;
} }
out.push_str(&string_literals(f)); out.push_str(&string_literals(f));
if out.len() > MAX_ITEM_CHARS.saturating_mul(4) { if out.len() > MAX_STORED_BYTES {
return None; return None;
} }
} }
out = unescape(&out);
} }
(out.chars().count() <= MAX_ITEM_CHARS).then_some(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. /// 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<()> { pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
set_many(wb, &[(key.to_string(), text.to_string())]) set_many(wb, &[(key.to_string(), text.to_string())])
@@ -76,7 +200,11 @@ pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> {
MAX_ITEM_CHARS / (1024 * 1024) MAX_ITEM_CHARS / (1024 * 1024)
))); )));
} }
keys.insert(key.to_ascii_lowercase()); // Two items under one key (names compare without case) would leave two hidden names of
// one name, of which an xlsx file keeps one: a caller's mistake, refused.
if !keys.insert(key.to_ascii_lowercase()) {
return Err(EngineError::Other(format!("`{key}` is given twice.")));
}
for (i, formula) in chunk_formulas(text).into_iter().enumerate() { for (i, formula) in chunk_formulas(text).into_iter().enumerate() {
planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
} }
@@ -110,18 +238,21 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
set(wb, key, &v.to_string()) set(wb, key, &v.to_string())
} }
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted. /// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> { /// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
let mut keys: Vec<String> = wb /// where a [`get`] per key would take one each.
.names pub fn keys(wb: &Workbook, prefix: &str) -> Listing<Vec<String>> {
.iter() let mut found: Vec<String> = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).collect();
// A later chunk on its own is no item, as `get` reads it. let more = found.len().saturating_sub(MAX_ITEMS);
.filter_map(|n| item_of(n).filter(|(_, chunk)| chunk.is_none_or(|i| i == 0)).map(|(k, _)| k.to_ascii_lowercase())) found.truncate(MAX_ITEMS);
.filter(|k| k.starts_with(&prefix.to_ascii_lowercase())) Listing { found, more }
.collect(); }
keys.sort();
keys.dedup(); /// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
keys /// `_gc_<key>_<k>` name, or a workbook-scope legacy `_sc_<key>` 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_<key>_<k>` and `_sc_<key>` give `<key>`. /// The item a hidden name belongs to, lower-cased: `_gc_<key>_<k>` and `_sc_<key>` give `<key>`.
@@ -165,7 +296,8 @@ fn check_key(key: &str) -> Result<()> {
} }
/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula /// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula
/// at most 8,192 characters. Concatenating [`string_literals`] of each gives `text` back. /// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]).
/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back.
pub fn chunk_formulas(text: &str) -> Vec<String> { pub fn chunk_formulas(text: &str) -> Vec<String> {
let mut formulas = Vec::new(); let mut formulas = Vec::new();
let (mut formula, mut units) = (String::new(), 0usize); let (mut formula, mut units) = (String::new(), 0usize);
@@ -187,14 +319,22 @@ pub fn chunk_formulas(text: &str) -> Vec<String> {
*lit_units = 0; *lit_units = 0;
}; };
for ch in text.chars() { for ch in text.chars() {
if lit_units + ch.len_utf16() > LITERAL_UNITS { // An escaped character is the marker and four digits, kept in one literal.
let escaped = needs_escape(ch);
let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() };
if lit_units + cost > LITERAL_UNITS {
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
} }
lit_units += ch.len_utf16(); lit_units += cost;
if ch == '"' { if escaped {
literal.push('"'); literal.push(ESCAPE);
let _ = write!(literal, "{:04X}", u32::from(ch));
} else {
if ch == '"' {
literal.push('"');
}
literal.push(ch);
} }
literal.push(ch);
} }
if lit_units > 0 { if lit_units > 0 {
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
@@ -310,10 +450,10 @@ mod tests {
// Only a later chunk: no item, not listed, and the legacy name is still read. // Only a later chunk: no item, not listed, and the legacy name is still read.
wb.names.push(name("_gc_orphan_2", "\"c\"")); wb.names.push(name("_gc_orphan_2", "\"c\""));
assert_eq!(get(&wb, "orphan"), None); assert_eq!(get(&wb, "orphan"), None);
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new()); assert_eq!(keys(&wb, "orphan").found, Vec::<String>::new());
wb.names.push(name("_sc_orphan", "\"old\"")); wb.names.push(name("_sc_orphan", "\"old\""));
assert_eq!(get(&wb, "orphan").as_deref(), Some("old")); assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
assert_eq!(keys(&wb, "orphan"), ["orphan"]); assert_eq!(keys(&wb, "orphan").found, ["orphan"]);
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal. // 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_1", "\"b\"&A1&"));
wb.names.push(name("_gc_mix_0", "SUM(\"a\")")); wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
@@ -361,7 +501,7 @@ mod tests {
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") }; let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
wb.names.push(own.clone()); wb.names.push(own.clone());
assert_eq!(get(&wb, "watch"), None); assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty()); assert!(keys(&wb, "").found.is_empty());
// Writing the item would put a hidden name of the same name beside it, and an xlsx file // 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. // keeps only one of them: refused, with the names as they were.
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string(); let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
@@ -375,6 +515,18 @@ mod tests {
assert_eq!(wb.names, vec![own]); assert_eq!(wb.names, vec![own]);
} }
#[test]
fn set_many_refuses_one_key_twice() {
// Two items under one key, in any case, would leave two hidden names of one name.
let mut wb = Workbook::new();
wb.names.push(name("_gc_other_0", "\"stays\""));
let err = set_many(&mut wb, &[("k".into(), "a".into()), ("K".into(), "b".into())]).unwrap_err().to_string();
assert!(err.contains("given twice"), "{err}");
assert_eq!(wb.names.len(), 1, "nothing changed");
set_many(&mut wb, &[("k".into(), "a".into()), ("k2".into(), "b".into())]).unwrap();
assert_eq!((get(&wb, "k").as_deref(), get(&wb, "K2").as_deref(), get(&wb, "other").as_deref()), (Some("a"), Some("b"), Some("stays")));
}
#[test] #[test]
fn a_chunk_number_with_leading_zeros_is_another_name() { 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 // `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
@@ -382,7 +534,7 @@ mod tests {
let mut wb = Workbook::new(); let mut wb = Workbook::new();
wb.names.push(name("_gc_watch_00", "\"theirs\"")); wb.names.push(name("_gc_watch_00", "\"theirs\""));
assert_eq!(get(&wb, "watch"), None); assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty()); assert!(keys(&wb, "").found.is_empty());
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") }; let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
wb.names.push(own.clone()); wb.names.push(own.clone());
set(&mut wb, "watch", "ours").unwrap(); set(&mut wb, "watch", "ours").unwrap();
@@ -398,12 +550,74 @@ mod tests {
set(&mut wb, "macro_2", "b").unwrap(); set(&mut wb, "macro_2", "b").unwrap();
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap(); set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
set(&mut wb, "scripts", "{}").unwrap(); set(&mut wb, "scripts", "{}").unwrap();
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]); assert_eq!(keys(&wb, "macro_").found, ["macro_1", "macro_2"]);
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1")); assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts")); assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
assert_eq!(item_key("Total"), None); 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 listing = items(&wb, "MACRO_");
let all = &listing.found;
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names");
assert_eq!(all.len(), MAX_ITEMS);
let first: Vec<&String> = all.keys().take(2).collect();
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.found, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
assert_eq!((listing.more, listed.more), (10_002, 10_002), "the rest are counted, not lost");
// Under the cap every item is there, read as `get` reads it.
wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1"));
let few = items(&wb, "macro_").found;
let want: BTreeMap<String, String> =
[("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_").found, ["macro_big", "macro_legacy"]);
assert_eq!((items(&wb, "").found.len(), items(&wb, "").more), (3, 0), "the other item too, none past the cap");
}
#[test]
fn an_item_over_the_size_limit_takes_no_place_in_a_listing() {
// One item too large to read sorts first and one sorts last, around MAX_ITEMS readable
// ones: every readable item is listed, and neither oversized one is counted as more.
let mut wb = Workbook::new();
let oversized = |wb: &mut Workbook, key: &str| {
for (i, formula) in chunk_formulas(&"x".repeat(MAX_ITEM_CHARS + 1)).into_iter().enumerate() {
wb.names.push(name(&format!("_gc_{key}_{i}"), &formula));
}
};
oversized(&mut wb, "macro_0");
for i in 0..MAX_ITEMS {
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
}
oversized(&mut wb, "macro_z");
assert_eq!(get(&wb, "macro_0"), None, "too large to read");
let listing = items(&wb, "macro_");
assert_eq!((listing.found.len(), listing.more), (MAX_ITEMS, 0));
assert!(listing.found.contains_key("macro_09999"));
}
#[test] #[test]
fn items_round_trip_through_xlsx() { fn items_round_trip_through_xlsx() {
let mut wb = Workbook::new(); let mut wb = Workbook::new();
@@ -415,5 +629,46 @@ mod tests {
assert!(warnings.is_empty(), "{warnings:?}"); assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str())); assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str()));
assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"]))); 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::<String>()), 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}"));
} }
} }
+4 -1
View File
@@ -166,7 +166,10 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?; let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
wb.styles.rebuild_index(); wb.styles.rebuild_index();
if wb.sheets.is_empty() { if wb.sheets.is_empty() {
// The file's macros are read after this (`macros::load`), so they come along.
let macros = std::mem::take(&mut wb.macros_read);
wb = Workbook::new(); wb = Workbook::new();
wb.macros_read = macros;
} }
// The file's band placement and cell count are derived state: rebuild them from the // The file's band placement and cell count are derived state: rebuild them from the
// cells themselves, dropping what is outside the sheet grid (#1008). // cells themselves, dropping what is outside the sheet grid (#1008).
@@ -259,7 +262,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), FileKind::Json => crate::macros::json_bytes(wb),
FileKind::Html => to_html(wb, sheet).map(String::into_bytes), FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
} }
} }
+70 -17
View File
@@ -141,12 +141,13 @@ pub struct Clipboard {
pub trust: Trust, pub trust: Trust,
} }
/// Whether a document's active content (macros) may run without asking the person first. /// Whether a document's active content (macros) may run without asking the person first. There
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] /// 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")] #[serde(rename_all = "camelCase")]
pub enum Trust { pub enum Trust {
/// Made in this session: a new workbook or a sample. /// Made in this session: a new workbook or a sample.
#[default]
Local, Local,
/// Opened from a file, or holding content pasted from one: active content asks before it /// Opened from a file, or holding content pasted from one: active content asks before it
/// runs. /// runs.
@@ -273,7 +274,10 @@ fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
} }
impl DocState { impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, 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<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new(); let mut calc = Calc::new();
calc.recalc_all(&mut wb); calc.recalc_all(&mut wb);
let scripts_trusted = !cmd::extra::has_scripts(&wb); let scripts_trusted = !cmd::extra::has_scripts(&wb);
@@ -294,7 +298,7 @@ impl DocState {
unlocked_ranges: vec![], unlocked_ranges: vec![],
content_kind: None, content_kind: None,
scripts_trusted, scripts_trusted,
trust: Trust::Local, trust,
touched: None, touched: None,
} }
} }
@@ -501,7 +505,7 @@ impl Session {
let name = wb.next_sheet_name(); let name = wb.next_sheet_name();
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(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 /// Journals a command, then trims the oldest entries past either cap. A recording in progress
@@ -571,8 +575,15 @@ impl Session {
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); 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 // 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, // made or undid, and the redo history they cleared, come back. A length is not enough,
// because a nested `edit.undo` removes entries. // because a nested `edit.undo` removes entries. A query runs nothing that changes the
let history_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))); // 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. // The groups open on the document before the command (usually none), for the same reason.
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before { let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
Some((uid, ..)) => { Some((uid, ..)) => {
@@ -586,6 +597,7 @@ impl Session {
None => (0, 0, String::new(), Selection::default()), None => (0, 0, String::new(), Selection::default()),
}); });
let doc_uid = before.as_ref().map(|(uid, ..)| *uid); 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)); let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
self.marks.extend(marked); self.marks.extend(marked);
self.depth = self.depth.saturating_add(1); self.depth = self.depth.saturating_add(1);
@@ -631,10 +643,23 @@ impl Session {
return Err(EngineError::Internal(id.to_string(), msg)); 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() { if spec.journal && result.is_ok() {
// A command that started recording is not part of the recording; one that stopped it // A command that started recording is not part of the recording; one that stopped it
// finds no recorder. // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
if let Some((doc, sheet, sheet_name, selection)) = place // file, `home.formatCells` with no style) is not recorded either: the dialog's own
// call is, so playing the recording back runs the command once, with its parameters.
// A call that opened a dialog and changed the workbook too (a script with an edit before
// a menu-style step) is recorded: the edit happened, and the dialog's confirming call
// is a step of its own.
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
let changed = before.as_ref().is_some_and(|(uid, wb, _)| self.docs.iter().any(|d| d.uid == *uid && !Arc::ptr_eq(&d.wb, wb)));
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| (!opened_dialog || changed) && !record::NOT_RECORDED.contains(&id))
&& let Some(rec) = self.recorder.as_mut() && let Some(rec) = self.recorder.as_mut()
{ {
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) }); rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) });
@@ -788,6 +813,29 @@ impl Session {
Ok(changed) 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 /// 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. /// 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 /// Only steps after the latest mark go: a running command's start, an open group's start and
@@ -858,6 +906,16 @@ impl Session {
self.depth self.depth
} }
/// Runs a command as a command in progress would (one level deeper), for tests of what a
/// nested call may do.
#[cfg(test)]
pub(crate) fn execute_nested(&mut self, id: &str, params: Json) -> Result<Json> {
self.depth = self.depth.saturating_add(1);
let r = self.execute(id, params);
self.depth = self.depth.saturating_sub(1);
r
}
/// Whether an undo group is open on the document with this uid. /// Whether an undo group is open on the document with this uid.
pub fn in_group(&self, doc: u64) -> bool { pub fn in_group(&self, doc: u64) -> bool {
self.groups.iter().any(|g| g.doc == doc) self.groups.iter().any(|g| g.doc == doc)
@@ -886,17 +944,12 @@ impl Session {
/// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no /// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no
/// file yet, `data.getData`) is an error instead of a silent success. /// file yet, `data.getData`) is an error instead of a silent success.
pub fn execute_headless(&mut self, id: &str, params: Json) -> Result<Json> { pub fn execute_headless(&mut self, id: &str, params: Json) -> Result<Json> {
let recorded = self.recorder.as_ref().map(|r| (r.steps.len(), r.dropped));
let r = self.execute(id, params); let r = self.execute(id, params);
let requests = self.take_ui_requests(); let requests = self.take_ui_requests();
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); 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 { if r.is_ok() && asked_for_file {
// The caller gets an error, so the recording keeps no step for the command.
if let Some((steps, dropped)) = recorded
&& let Some(rec) = self.recorder.as_mut()
{
rec.truncate(steps, dropped);
}
let what = match id { let what = match id {
"file.open" => "`path` or `base64`", "file.open" => "`path` or `base64`",
"data.getData" | "data.fromTextCsv" => "`path` or `text`", "data.getData" | "data.fromTextCsv" => "`path` or `text`",
+87 -11
View File
@@ -52,7 +52,8 @@ pub fn check(m: &Macro) -> Result<(), String> {
} }
/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of /// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of
/// JSON), so saving a workbook never fails because of one of its macros. /// JSON), so a macro's size never makes a save fail. (A clashing visible name doesn't either:
/// [`with_names`] stores the macro under another index.)
fn check_size(m: &Macro) -> Result<(), String> { fn check_size(m: &Macro) -> Result<(), String> {
if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS { if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS {
return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS)); return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS));
@@ -105,34 +106,88 @@ pub fn fingerprint(m: &Macro) -> String {
} }
/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the /// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the
/// field, or `None` when there is nothing to change. /// field, or `None` when there is nothing to change. The macros keep their order; the index `n`
/// is this build's choice, so it steps around any a person's own visible name already uses.
pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> { pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> {
// Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some // Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some
// other key stays, as `load` leaves it. // other key stays, as `load` leaves it.
let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some()); let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some());
if wb.macros.is_empty() && !wb.names.iter().any(stored) { if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) {
return Ok(None); return Ok(None);
} }
let mut out = wb.clone(); let mut out = wb.clone();
// One pass drops the items of the last save, and one writes the macros (a file can hold a // One pass drops the items of the last save, and one writes the macros (a file can hold a
// thousand macros and many thousands of names). // thousand macros and many thousands of names).
out.names.retain(|n| !stored(n)); out.names.retain(|n| !stored(n));
let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect(); // A visible workbook-scope name of the storage shape is someone's own, and `set_many` won't
// write a hidden one of the same name beside it. Such a name (`_GC_MACRO_1_0`, or one a
// person defined after the file opened) blocks its item's key, in any chunk, so the macros
// take the free indices from 0 up. The keys compare as the stash reads them, so this skips
// exactly what `set_many` would refuse, and no arithmetic depends on a number from the file.
let blocked: HashSet<String> = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect();
let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key));
// Macros in a newer format follow the readable ones, written back as the file held them.
let texts = wb.macros.iter().map(|m| macro_json(m).to_string()).chain(wb.macros_unread.iter().map(Json::to_string));
let items: Vec<(String, String)> = texts.zip(free).map(|(text, key)| (key, text)).collect();
stash::set_many(&mut out, &items)?; stash::set_many(&mut out, &items)?;
out.macros.clear(); // An empty list of its own, not a copy of the shared one cleared.
out.macros = gridcraft_model::Macros::default();
out.macros_unread = std::sync::Arc::default();
Ok(Some(out)) Ok(Some(out))
} }
/// After a file is opened: moves macros out of hidden names into `Workbook::macros` and drops /// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into
/// macros that are damaged, over the limits or in a newer format. Returns a warning for what was dropped. /// the `macros` list as the file held them.
pub fn json_bytes(wb: &Workbook) -> crate::Result<Vec<u8>> {
// Every path that adds a macro checks it, so this refuses only one that skipped the check, as
// the xlsx save does, instead of writing a macro the next open would drop.
for m in &wb.macros {
check(m).map_err(crate::EngineError::Other)?;
}
let bytes = if wb.macros_unread.is_empty() {
serde_json::to_vec_pretty(wb)
} else {
serde_json::to_value(wb).and_then(|mut v| {
if let Some(list) = v.as_object_mut().map(|o| o.entry("macros").or_insert_with(|| Json::Array(vec![]))).and_then(Json::as_array_mut) {
list.extend(wb.macros_unread.iter().cloned());
}
serde_json::to_vec_pretty(&v)
})
};
bytes.map_err(|e| crate::EngineError::Other(e.to_string()))
}
/// Whether `v` is a macro in a newer format than this build reads.
fn is_newer(v: &Json) -> bool {
v.get("v").and_then(Json::as_u64).is_some_and(|found| found > u64::from(gridcraft_model::MACRO_VERSION))
}
/// Keeps a macro in a newer format unread, if it is small enough to save back
/// ([`stash::MAX_ITEM_CHARS`] characters of JSON, the size every xlsx save can write).
fn keep_unread(unread: &mut Vec<Json>, v: Json, bad: &mut usize) {
if v.to_string().chars().count() <= stash::MAX_ITEM_CHARS {
unread.push(v);
} else {
*bad = bad.saturating_add(1);
}
}
/// After a file is opened: moves macros out of hidden names into `Workbook::macros`, keeps
/// macros in a newer format unread (`Workbook::macros_unread`), and drops macros that are damaged
/// or over the limits. Returns a warning for what was kept unread and one for what was dropped.
pub fn load(wb: &mut Workbook) -> Vec<String> { pub fn load(wb: &mut Workbook) -> Vec<String> {
let mut warnings = Vec::new(); let mut warnings = Vec::new();
let mut bad = 0usize; let mut bad = 0usize;
let mut unread = Vec::new();
// Entries of the JSON format's field, each read on its own so a damaged one drops alone. // Entries of the JSON format's field, each read on its own so a damaged one drops alone.
let read = std::mem::take(&mut wb.macros_read); let read = std::mem::take(&mut wb.macros_read);
bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS)); bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS));
for v in read.iter().take(gridcraft_model::MAX_MACROS) { for v in read.into_iter().take(gridcraft_model::MAX_MACROS) {
match parse_macro(v) { if is_newer(&v) {
keep_unread(&mut unread, v, &mut bad);
continue;
}
match parse_macro(&v) {
Ok(m) => wb.macros.push(m), Ok(m) => wb.macros.push(m),
Err(_) => bad += 1, Err(_) => bad += 1,
} }
@@ -156,7 +211,12 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
let mut item = Workbook::new(); let mut item = Workbook::new();
for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) { for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) {
item.names = names; item.names = names;
match stash::get_json(&item, &key).ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) { let v = stash::get_json(&item, &key);
if let Some(v) = v.as_ref().filter(|v| is_newer(v)) {
keep_unread(&mut unread, v.clone(), &mut bad);
continue;
}
match v.ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) {
Ok(m) => found.push(m), Ok(m) => found.push(m),
Err(_) => bad += 1, Err(_) => bad += 1,
} }
@@ -184,9 +244,25 @@ pub fn load(wb: &mut Workbook) -> Vec<String> {
Err(_) => bad += 1, Err(_) => bad += 1,
} }
} }
// Readable and unread macros together stay within the limit, the readable ones first.
let room = gridcraft_model::MAX_MACROS.saturating_sub(wb.macros.len());
bad = bad.saturating_add(unread.len().saturating_sub(room));
unread.truncate(room);
if !unread.is_empty() {
let one = unread.len() == 1;
warnings.push(format!(
"{} macro{} in the workbook {} made by a newer version of GridCraft: this version can't show or run {}, and saving keeps {} unchanged.",
unread.len(),
if one { "" } else { "s" },
if one { "was" } else { "were" },
if one { "it" } else { "them" },
if one { "it" } else { "them" },
));
}
wb.macros_unread = std::sync::Arc::new(unread);
if bad > 0 { if bad > 0 {
warnings.push(format!( warnings.push(format!(
"{bad} macro{} in the workbook couldn't be opened: {} damaged, made by a newer version of GridCraft, or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).", "{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).",
if bad == 1 { "" } else { "s" }, if bad == 1 { "" } else { "s" },
if bad == 1 { "it is" } else { "they are" }, if bad == 1 { "it is" } else { "they are" },
gridcraft_model::MAX_MACROS, gridcraft_model::MAX_MACROS,
+9 -4
View File
@@ -288,9 +288,12 @@ pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", "))); return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", ")));
} }
}; };
// After a method the result is plain cells: `data` no longer applies, and only `data` // After a method the result is plain cells, except that `col` and `cols` keep the input's top
// keeps the header row for `col("…")`. // row, so a later `data` still takes the header off; only `data` keeps the header row for
header = false; // `col("…")`.
if !matches!(method.as_str(), "col" | "cols") {
header = false;
}
if method != "data" { if method != "data" {
names_row = None; names_row = None;
} }
@@ -615,7 +618,9 @@ mod tests {
("input", "B2:D11"), ("input", "B2:D11"),
("Input.col(2)", "C2:C11"), ("Input.col(2)", "C2:C11"),
("Input.col(\"Amount\")", "C2:C11"), ("Input.col(\"Amount\")", "C2:C11"),
("Input.col(\"date\").data", "D2:D11"), ("Input.col(\"date\").data", "D3:D11"),
("Input.cols(2..3).data", "C3:D11"),
("Input.col(2).data.col(1)", "C3:C11"),
("Input.data", "B3:D11"), ("Input.data", "B3:D11"),
("Input.data.col(2)", "C3:C11"), ("Input.data.col(2)", "C3:C11"),
("Input.data.col(\"Amount\")", "C3:C11"), ("Input.data.col(\"Amount\")", "C3:C11"),
+271 -20
View File
@@ -213,24 +213,82 @@ fn a_macro_built_in_code_goes_through_the_size_check_too() {
assert!(check(&big).unwrap_err().contains("too large")); assert!(check(&big).unwrap_err().contains("too large"));
let mut wb = Workbook::new(); let mut wb = Workbook::new();
wb.macros.push(big); wb.macros.push(big);
assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save"); // A macro that fails `check` fails the save in either format, rather than being written and
// dropped the next time the file opens.
for file in ["book.xlsx", "book.json"] {
assert!(crate::io::save_bytes(&wb, file).is_err(), "{file}");
}
}
fn visible(name: &str) -> DefinedName {
DefinedName { name: name.into(), scope: None, formula: "Sheet1!$A$1".into(), comment: String::new(), hidden: false }
}
/// Saves `wb` to xlsx and returns the names in the file and the workbook opened from it.
fn xlsx_round_trip(wb: &Workbook) -> (Vec<String>, Workbook) {
let bytes = crate::io::save_bytes(wb, "book.xlsx").unwrap();
let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0);
let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
(raw, back)
} }
#[test] #[test]
fn a_save_refuses_a_clashing_visible_name_before_changing_anything() { fn a_visible_name_of_the_storage_shape_does_not_block_a_save() {
// The index a macro is stored under is GridCraft's choice, so a person's own visible name
// of that shape (here in capitals: names compare without case) is stepped around, not
// refused. Stale hidden items still go in the same pass.
let mut wb = Workbook::new(); let mut wb = Workbook::new();
wb.macros.push(sample_macro("A")); wb.macros.push(sample_macro("A"));
wb.macros.push(sample_macro("B")); wb.macros.push(sample_macro("B"));
let own = DefinedName { hidden: false, ..hidden("_GC_MACRO_1_0", "mine") }; wb.names.push(visible("_GC_MACRO_0_0"));
wb.names.push(own.clone());
wb.names.push(hidden("_gc_macro_7_0", "stale")); wb.names.push(hidden("_gc_macro_7_0", "stale"));
let err = crate::io::save_bytes(&wb, "book.xlsx").unwrap_err().to_string(); let (raw, back) = xlsx_round_trip(&wb);
assert!(err.contains("_GC_MACRO_1_0"), "{err}"); assert_eq!(raw, ["_GC_MACRO_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]);
assert_eq!(wb.names.len(), 2, "nothing changed"); assert_eq!(back.macros, wb.macros, "both macros, in their order");
wb.names.remove(0); assert_eq!(back.names, vec![visible("_GC_MACRO_0_0")], "the person's name is kept as it was");
let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); assert_eq!(wb.names.len(), 2, "the open workbook is not changed by a save");
let names = names_of(&raw); }
assert_eq!(names, ["_gc_macro_0_0", "_gc_macro_1_0"], "the stale item went in the same pass");
#[test]
fn a_huge_visible_index_takes_no_part_in_numbering() {
// A file can carry a visible name with any number in it. The macros take the free indices
// from 0 up, so no arithmetic depends on that number (one past the largest would overflow).
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
wb.names.push(visible(&format!("_gc_macro_{}_0", u64::MAX)));
wb.names.push(visible("_gc_macro_0_0"));
let (raw, back) = xlsx_round_trip(&wb);
assert_eq!(raw, [format!("_gc_macro_{}_0", u64::MAX), "_gc_macro_0_0".into(), "_gc_macro_1_0".into()]);
assert_eq!(back.macros, wb.macros);
assert_eq!(names_of(&back), names_of(&wb));
}
#[test]
fn a_visible_legacy_name_of_the_storage_shape_is_kept() {
// The stash reads a legacy `_sc_<key>` name as data whether or not it is hidden, so writing
// key `macro_0` would remove a person's visible `_sc_macro_0`. That key is stepped around too.
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
wb.names.push(visible("_sc_macro_0"));
let (raw, back) = xlsx_round_trip(&wb);
assert_eq!(raw, ["_sc_macro_0", "_gc_macro_1_0"]);
assert_eq!(back.macros, wb.macros);
assert_eq!(back.names, vec![visible("_sc_macro_0")]);
}
#[test]
fn a_name_defined_after_opening_does_not_block_the_next_save() {
// Once a file is open its macros are out of the names, so Define Name accepts a visible
// `_gc_macro_0_0`; the next save steps around it.
let mut s = crate::Session::new();
s.execute("file.new", json!({})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).macros.push(sample_macro("A"));
s.execute("formulas.defineName", json!({"name": "_gc_macro_0_0", "refersTo": "=Sheet1!$A$1"})).unwrap();
let (raw, back) = xlsx_round_trip(&s.doc().unwrap().wb);
assert_eq!(raw, ["_gc_macro_0_0", "_gc_macro_1_0"]);
assert_eq!(back.macros.len(), 1);
assert_eq!(names_of(&back), ["_gc_macro_0_0"]);
} }
#[test] #[test]
@@ -272,7 +330,8 @@ fn damaged_or_oversized_macros_are_dropped_with_a_warning() {
let mut wb = Workbook::new(); let mut wb = Workbook::new();
wb.macros = (0..MAX_MACROS + 5).map(|i| Macro { name: format!("M{i}"), ..Macro::default() }).collect(); wb.macros = (0..MAX_MACROS + 5).map(|i| Macro { name: format!("M{i}"), ..Macro::default() }).collect();
wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1]; wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1];
let bytes = crate::io::save_bytes(&wb, "many.json").unwrap(); // Written as another program would: a GridCraft save refuses a macro that fails `check`.
let bytes = serde_json::to_vec(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap(); let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap();
// Only the first MAX_MACROS entries are read, as in xlsx. // Only the first MAX_MACROS entries are read, as in xlsx.
assert_eq!(back.macros.len(), MAX_MACROS - 1); assert_eq!(back.macros.len(), MAX_MACROS - 1);
@@ -285,7 +344,8 @@ fn damaged_or_oversized_macros_are_dropped_with_a_warning() {
assert!(err.contains("too large"), "{err}"); assert!(err.contains("too large"), "{err}");
let mut wb = Workbook::new(); let mut wb = Workbook::new();
wb.macros.push(Macro { name: "Huge".into(), steps: vec![Step::command("cell.set", json!({"input": huge}))], ..Macro::default() }); wb.macros.push(Macro { name: "Huge".into(), steps: vec![Step::command("cell.set", json!({"input": huge}))], ..Macro::default() });
let bytes = crate::io::save_bytes(&wb, "huge.json").unwrap(); // Written as another program would: a GridCraft save refuses a macro that fails `check`.
let bytes = serde_json::to_vec(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap(); let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap();
assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}"); assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}");
} }
@@ -319,7 +379,8 @@ fn json_field_macros_get_the_name_check_too() {
let mut wb = Workbook::new(); let mut wb = Workbook::new();
wb.macros.push(Macro { name: "a\nb".into(), ..Macro::default() }); wb.macros.push(Macro { name: "a\nb".into(), ..Macro::default() });
wb.macros.push(Macro { name: "Fine".into(), ..Macro::default() }); wb.macros.push(Macro { name: "Fine".into(), ..Macro::default() });
let bytes = crate::io::save_bytes(&wb, "ctl.json").unwrap(); // Written as another program would: a GridCraft save refuses a macro that fails `check`.
let bytes = serde_json::to_vec(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("ctl.json", &bytes).unwrap(); let (back, warnings) = crate::io::open_bytes("ctl.json", &bytes).unwrap();
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["Fine"]); assert_eq!(names, ["Fine"]);
@@ -391,10 +452,11 @@ fn a_damaged_json_macro_drops_alone() {
} }
#[test] #[test]
fn a_macro_in_a_newer_format_is_dropped_not_rewritten() { fn a_macro_in_a_newer_format_is_kept_unread_and_saved_back_unchanged() {
// A newer build's macro (`v` 2, with a field this build doesn't know) would lose that field // A newer build's macro (`v` 2, with a field this build doesn't know) would lose that field
// here and be saved back as if complete, so it is dropped with the usual warning instead. // if it were read here, so it is never read: it is kept as the file held it, saved back
// Version 1 and a missing version are read as before. // unchanged, and the newer build gets it back whole. Version 1 and a missing version are
// read as before.
let future = json!({"name": "Future", "v": MACRO_VERSION + 1, "fromALaterBuild": {"x": 1}, "steps": [{"command": "cell.set"}]}); let future = json!({"name": "Future", "v": MACRO_VERSION + 1, "fromALaterBuild": {"x": 1}, "steps": [{"command": "cell.set"}]});
let current = json!({"name": "Current", "v": MACRO_VERSION, "steps": [{"command": "cell.set"}]}); let current = json!({"name": "Current", "v": MACRO_VERSION, "steps": [{"command": "cell.set"}]});
let missing = json!({"name": "Missing"}); let missing = json!({"name": "Missing"});
@@ -407,17 +469,24 @@ fn a_macro_in_a_newer_format_is_dropped_not_rewritten() {
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["Current", "Missing"], "{file}"); assert_eq!(names, ["Current", "Missing"], "{file}");
assert!(back.macros.iter().all(|m| m.v == MACRO_VERSION), "{file}"); assert!(back.macros.iter().all(|m| m.v == MACRO_VERSION), "{file}");
assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version"), "{file}: {warnings:?}"); assert_eq!(*back.macros_unread, std::slice::from_ref(&future), "{file}");
// Saving and opening again leaves no reduced copy of it behind. assert!(
warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version") && warnings[0].contains("unchanged"),
"{file}: {warnings:?}"
);
// Saved and opened again, it is still there, whole, and the others are as they were.
let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(back, file).unwrap()).unwrap(); let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(back, file).unwrap()).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}"); assert_eq!(warnings.len(), 1, "{file}: {warnings:?}");
assert_eq!(again.macros, back.macros, "{file}"); assert_eq!(again.macros, back.macros, "{file}");
assert_eq!(again.macros_unread, back.macros_unread, "{file}");
}; };
// The JSON format's field. // The JSON format's field.
let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
v["macros"] = json!([future, current, missing]); v["macros"] = json!([future, current, missing]);
let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap();
check("book.json", &back, &warnings); check("book.json", &back, &warnings);
let saved: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&back, "book.json").unwrap()).unwrap();
assert!(saved["macros"].as_array().unwrap().contains(&future), "the file holds it as it was");
// Hidden names in xlsx. // Hidden names in xlsx.
let mut wb = Workbook::new(); let mut wb = Workbook::new();
for (i, m) in [&future, &current, &missing].into_iter().enumerate() { for (i, m) in [&future, &current, &missing].into_iter().enumerate() {
@@ -429,6 +498,42 @@ fn a_macro_in_a_newer_format_is_dropped_not_rewritten() {
check("book.xlsx", &back, &warnings); check("book.xlsx", &back, &warnings);
} }
#[test]
fn kept_macros_share_the_save_limits_and_the_numbering() {
// A newer macro too large for the file is left out (keeping it would make every xlsx save
// fail), and readable and unread macros together stay within the macro limit, readable ones
// first. Kept ones are numbered after the readable ones, around a person's visible name.
let newer = |name: &str| json!({"name": name, "v": MACRO_VERSION + 1});
let mut big = newer("Big");
big["pad"] = json!(long(crate::cmd::stash::MAX_ITEM_CHARS));
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
let mut list = vec![big, newer("Kept"), json!({"name": "Read"})];
file["macros"] = json!(list);
let (mut wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!(*wb.macros_unread, [newer("Kept")]);
assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}");
wb.names.push(visible("_gc_macro_1_0"));
let (raw, back) = {
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0);
(raw, crate::io::open_bytes("book.xlsx", &bytes).unwrap().0)
};
assert_eq!(raw, ["_gc_macro_1_0", "_gc_macro_0_0", "_gc_macro_2_0"]);
assert_eq!((back.macros.len(), back.macros_unread.len()), (1, 1));
// Past the limit the rest are left out; and when a JSON file's list is full, an unread item
// it also holds in hidden names is the one left out: readable macros come first.
list = (0..MAX_MACROS).map(|i| json!({"name": format!("M{i}")})).collect();
let mut with_item = Workbook::new();
with_item.names.push(hidden("_gc_macro_0_0", &newer("InNames").to_string()));
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&with_item, "book.json").unwrap()).unwrap();
for extra in [None, Some(newer("Over"))] {
file["macros"] = json!(list.iter().cloned().chain(extra).collect::<Vec<_>>());
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!((wb.macros.len(), wb.macros_unread.len()), (MAX_MACROS, 0));
assert!(warnings.len() == 1 && warnings[0].contains("couldn't be opened"), "{warnings:?}");
}
}
#[test] #[test]
fn many_macros_with_one_name_all_survive_save_and_reopen() { fn many_macros_with_one_name_all_survive_save_and_reopen() {
// MAX_MACROS macros with the same name, at the length limit, so the suffixes run from " (2)" // MAX_MACROS macros with the same name, at the length limit, so the suffixes run from " (2)"
@@ -478,3 +583,149 @@ fn naming_clashing_macros_takes_linear_work() {
taken.insert("a (2)"); taken.insert("a (2)");
assert_eq!(taken.unique("a"), "a (4)"); assert_eq!(taken.unique("a"), "a (4)");
} }
#[test]
fn a_json_workbook_with_no_sheets_keeps_its_macros() {
// A JSON file with an empty sheet list opens as a new workbook; its macros come along.
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap();
v["sheets"] = json!([]);
let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back.sheets.len(), 1);
assert_eq!(back.macros, wb.macros);
}
fn long(n: usize) -> String {
"x".repeat(n)
}
/// `sample_macro` as JSON with `change` made to it.
fn changed(change: fn(&mut serde_json::Value)) -> serde_json::Value {
let mut v = macro_json(&sample_macro("T"));
change(&mut v);
v
}
#[test]
fn every_field_a_file_sets_has_a_limit() {
// The sample's steps: 0 a command, 1 a Repeat whose body holds an If whose else holds a Stop.
let refused: [(&str, fn(&mut serde_json::Value)); 14] = [
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT + 1))),
("shortcut line break", |v| v["shortcut"] = json!("Ctrl+\nM")),
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS + 1])),
("permission text", |v| v["permissions"] = json!(["data getData"])),
("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF + 1))),
("default range", |v| v["inputs"][0]["defaultRef"] = json!(long(MAX_MACRO_REF + 1))),
("column types", |v| {
v["inputs"][0]["cols"] = json!([1, 0]);
v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS + 1]);
}),
("command id", |v| v["steps"][0]["id"] = json!("cell set")),
("empty command id", |v| v["steps"][0]["id"] = json!("")),
("Repeat range", |v| v["steps"][1]["over"] = json!(long(MAX_MACRO_REF + 1))),
("If cell", |v| v["steps"][1]["body"][0]["cell"] = json!(long(MAX_MACRO_REF + 1))),
// As JSON the string takes its two quotes as well.
("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 1))),
("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT + 1))),
("Stop message, nested", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT * 2))),
];
for (what, change) in refused {
assert!(parse_macro(&changed(change)).is_err(), "{what} past its limit is refused");
}
let accepted: [(&str, fn(&mut serde_json::Value)); 9] = [
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT))),
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS])),
("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF))),
("column types", |v| {
v["inputs"][0]["cols"] = json!([1, 0]);
v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS]);
}),
("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 2))),
// Empty and NotEmpty ignore the value, which is then left out (null).
("If with no value", |v| {
v["steps"][1]["body"][0]["op"] = json!("empty");
v["steps"][1]["body"][0].as_object_mut().unwrap().remove("value");
}),
// How many times a Repeat may run is the runner's to bound, not the format's.
("Repeat max", |v| v["steps"][1]["max"] = json!(u32::MAX)),
("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT))),
("no shortcut", |v| {
v.as_object_mut().unwrap().remove("shortcut");
}),
];
for (what, change) in accepted {
assert!(parse_macro(&changed(change)).is_ok(), "{what} at its limit is accepted");
}
// Every command there is passes as a step and as a permission.
for spec in crate::command_specs() {
let m = json!({"name": "T", "steps": [{"command": spec.id}], "permissions": [spec.id]});
assert!(parse_macro(&m).is_ok(), "{}", spec.id);
}
}
#[test]
fn an_input_declaration_is_checked_as_a_whole() {
let input = |i: serde_json::Value| json!({"name": "T", "inputs": [i]});
for i in [
json!({"name": "Input", "rows": [5, 2]}),
json!({"name": "Input", "cols": [3, 2]}),
json!({"name": "Input", "kind": "cell", "cols": [2, 5]}),
json!({"name": "Input", "kind": "cell", "rows": [0, 0]}),
json!({"name": "Input", "kind": "cell", "header": true}),
json!({"name": "Input", "kind": "cell", "colTypes": ["number", "text"]}),
json!({"name": "Input", "cols": [2, 2], "colTypes": ["number", "text", "any"]}),
] {
assert!(parse_macro(&input(i.clone())).is_err(), "{i}");
}
for i in [
json!({"name": "Input", "rows": [5, 0]}),
json!({"name": "Input", "rows": [2, 2], "cols": [0, 3]}),
json!({"name": "Input", "kind": "cell"}),
json!({"name": "Input", "kind": "cell", "rows": [1, 1], "cols": [1, 1], "colTypes": ["number"]}),
json!({"name": "Input", "cols": [1, 2], "colTypes": ["number", "text"]}),
] {
assert!(parse_macro(&input(i.clone())).is_ok(), "{i}");
}
// From a file, a macro whose declaration fails is left out with the usual warning, and the
// workbook's other macros open.
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
file["macros"] = json!([input(json!({"name": "Input", "rows": [5, 2]})), {"name": "Fine"}]);
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Fine"]);
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
#[test]
fn the_storage_rules_hold_together_through_both_formats() {
// A visible name of the storage shape, a macro in a newer format and one whose input breaks
// the declaration rules, in one file, saved and opened again in each format and then the
// other: the visible name stays, the newer macro comes back whole, the broken one is left out
// once, and the readable one survives.
let newer = json!({"name": "Kept", "v": MACRO_VERSION + 1, "fromALaterBuild": true});
let broken = json!({"name": "Broken", "inputs": [{"name": "Input", "rows": [5, 2]}]});
let mut start = Workbook::new();
start.names.push(visible("_gc_macro_0_0"));
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&start, "book.json").unwrap()).unwrap();
file["macros"] = json!([newer, broken, {"name": "Good"}]);
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}");
let expect = |wb: &Workbook, how: &str| {
assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Good"], "{how}");
assert_eq!(*wb.macros_unread, std::slice::from_ref(&newer), "{how}");
assert_eq!(names_of(wb), ["_gc_macro_0_0"], "{how}");
};
expect(&wb, "opened");
for formats in [["book.xlsx", "book.json"], ["book.json", "book.xlsx"]] {
let mut now = wb.clone();
for file in formats {
let (back, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&now, file).unwrap()).unwrap();
assert_eq!(warnings.len(), 1, "{file}: only the kept macro is reported again: {warnings:?}");
expect(&back, file);
now = back;
}
}
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
assert_eq!(names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0), ["_gc_macro_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]);
}
+53
View File
@@ -803,6 +803,51 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -
/// Computes a PivotTable report from the workbook. /// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> { pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?; let src = read_source(wb, &pt.source, pivot_sheet)?;
compute_from(wb, pt, &src, None)
}
/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end
/// edits that cell instead).
pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
/// Show Details: the source list and the indices of its records behind the value cell at
/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when
/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() };
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
// Excel reads its PivotTable cache; without one, the report written by the last refresh
// must still be what the source gives. Checked first: in a stale report even a cell that
// computes as a label may show a value.
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
let current = pt.last_range.is_some_and(|r| {
r.height() == out.height().max(1)
&& r.width() == out.width.max(1)
&& out.grid.iter().enumerate().all(|(ri, line)| {
line.iter().enumerate().all(|(ci, oc)| {
sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value
})
})
});
if !current {
return Err("The PivotTable report is out of date. Refresh it first.".into());
}
let Some((rp, cp)) = probe.hit else { return Ok(None) };
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
Ok(Some((src, records)))
}
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
/// value cell at `at`, and the paths of every record the filters let through.
struct Probe {
at: (usize, usize),
hit: Option<(Vec<u32>, Vec<u32>)>,
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
}
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
@@ -914,6 +959,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
if !col_leaves.contains(&cp[..shown]) { if !col_leaves.contains(&cp[..shown]) {
col_leaves.insert(cp[..shown].to_vec()); col_leaves.insert(cp[..shown].to_vec());
} }
if let Some(pr) = probe.as_deref_mut() {
pr.paths.push((ri, rp.clone(), cp.clone()));
}
row_set.insert(rp); row_set.insert(rp);
col_set.insert(cp); col_set.insert(cp);
// Refuse a report that cannot fit before the accumulators for more column groups are allocated. // Refuse a report that cannot fit before the accumulators for more column groups are allocated.
@@ -1278,6 +1326,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
_ => raw, _ => raw,
}, },
}; };
if let Some(pr) = probe.as_deref_mut()
&& pr.at == (r, l + j)
{
pr.hit = Some((rp.to_vec(), cp.to_vec()));
}
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
} }
} }
+11 -11
View File
@@ -18,6 +18,10 @@ pub const RECORD_LIMIT: usize = 100_000;
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive. /// 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; pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
/// Commands never recorded: recovery actions, not steps of the person's work. A recording that
/// replayed Stop Action would stop the run it is a step of.
pub const NOT_RECORDED: [&str; 1] = ["automate.stopAction"];
/// One recorded command and where it ran. /// One recorded command and where it ran.
#[derive(Clone, Debug, PartialEq, Serialize)] #[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -49,22 +53,18 @@ pub struct Recorder {
impl Recorder { impl Recorder {
/// Adds a step, or counts it as dropped once the buffer is full. /// Adds a step, or counts it as dropped once the buffer is full.
pub fn push(&mut self, step: RecordedStep) { pub fn push(&mut self, step: RecordedStep) {
// Once a step has been dropped, or the step count is full, the rest are counted without
// being serialized (a `file.open` step holds a whole file).
if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT {
self.dropped = self.dropped.saturating_add(1);
return;
}
let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
let fits = self.steps.len() < RECORD_LIMIT && self.bytes.saturating_add(size) <= RECORD_MAX_BYTES; if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
if fits && self.dropped == 0 {
self.bytes = self.bytes.saturating_add(size); self.bytes = self.bytes.saturating_add(size);
self.steps.push(step); self.steps.push(step);
} else { } else {
self.dropped = self.dropped.saturating_add(1); self.dropped = self.dropped.saturating_add(1);
} }
} }
/// Forgets the steps after the first `steps` and puts the dropped count back to `dropped`,
/// for a command the caller received as failed after it was recorded.
pub fn truncate(&mut self, steps: usize, dropped: usize) {
for step in self.steps.drain(steps.min(self.steps.len())..) {
self.bytes = self.bytes.saturating_sub(serde_json::to_vec(&step).map_or(0, |v| v.len()));
}
self.dropped = dropped;
}
} }
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option<Workbook> {
_ => return None, _ => 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 { let ok = match name {
"budget" => budget(&mut s), "budget" => budget(&mut s),
"sales" => sales(&mut s), "sales" => sales(&mut s),
+5
View File
@@ -3,6 +3,11 @@ use serde_json::json;
use crate::Session; 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<usize> = const { std::cell::Cell::new(0) };
}
fn s() -> Session { fn s() -> Session {
let mut s = Session::new(); let mut s = Session::new();
s.new_workbook(); s.new_workbook();
+220
View File
@@ -643,6 +643,142 @@ fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() {
} }
} }
#[test]
fn a_command_that_fails_rolls_back_the_groups_it_opened() {
// A `?` between `group_begin` and `group_end` used to leave the group open for good: Undo
// and Redo refused, and later edits made no steps.
crate::cmd::register_undoable_test_command("test.groupThenFail", |s, _| {
let _token = s.group_begin("Half")?;
put(s, "A1", "half done")?;
Err(EngineError::Other("stopped halfway".into()))
});
crate::cmd::register_undoable_test_command("test.editGroupThenFail", |s, _| {
put(s, "B1", "outside")?;
let _token = s.group_begin("Half")?;
put(s, "B2", "inside")?;
Err(EngineError::Other("stopped halfway".into()))
});
let mut s = s();
set(&mut s, "A1", "before");
set(&mut s, "A2", "undone");
s.execute("edit.undo", json!({})).unwrap();
s.execute("selection.set", json!({"range": "C3"})).unwrap();
let uid = s.doc().unwrap().uid;
let err = s.execute("test.groupThenFail", json!({})).unwrap_err().to_string();
assert!(err.contains("stopped halfway"), "{err}");
assert!(!s.in_group(uid), "no group is left open");
assert_eq!(v(&s, "A1").display(), "before");
assert_eq!(s.doc().unwrap().selection.a1(), "C3");
assert_eq!(undo_labels(&s), ["Enter Cell"]);
assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history comes back");
assert_eq!(s.execute("history.list", json!({})).unwrap()["grouping"], json!(false));
set(&mut s, "A3", "next");
assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell"], "later edits make steps again");
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "A3").is_empty());
// What the command changed before opening the group stays, as for any failed command, and is
// a step of its own; what it changed inside the group goes.
assert!(s.execute("test.editGroupThenFail", json!({})).is_err());
assert!(!s.in_group(uid));
assert_eq!((v(&s, "B1").display(), v(&s, "B2").display()), ("outside".into(), String::new()));
assert_eq!(undo_labels(&s), ["Enter Cell", "test.editGroupThenFail"]);
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "B1").is_empty());
// A group open before the command stays open: only the command's own group goes.
let token = s.group_begin("Outer").unwrap();
set(&mut s, "D1", "outer");
assert!(s.execute("test.groupThenFail", json!({})).is_err());
assert!(s.in_group(uid), "the outer group is still running");
assert_eq!((v(&s, "A1").display(), v(&s, "D1").display()), ("before".into(), "outer".into()));
assert!(s.group_end(token).unwrap());
assert_eq!(undo_labels(&s), ["Enter Cell", "Outer"]);
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "D1").is_empty());
}
#[test]
fn stop_action_rolls_back_a_group_left_open() {
// A host that dropped its token, or a debugger the person wants to abandon: the message on
// Undo names the command that stops it, and the command puts the start back.
let mut s = s();
set(&mut s, "A1", "start");
set(&mut s, "A2", "undone");
s.execute("edit.undo", json!({})).unwrap();
let uid = s.doc().unwrap().uid;
let token = s.group_begin("Debug").unwrap();
set(&mut s, "A1", "step 1");
drop(s.group_begin("Inner").unwrap());
set(&mut s, "A3", "step 2");
let err = s.execute("edit.undo", json!({})).unwrap_err().to_string();
assert!(err.contains("automate.stopAction"), "{err}");
assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true}));
assert!(!s.in_group(uid));
assert_eq!(v(&s, "A1").display(), "start");
assert!(v(&s, "A3").is_empty());
assert_eq!(undo_labels(&s), ["Enter Cell"], "stopping is not a step of its own");
assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history comes back");
assert!(s.group_end(token).is_err(), "the token is spent");
assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": false}));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(v(&s, "A2").display(), "undone");
// Other documents keep their groups.
let other = s.new_workbook();
let other_uid = s.documents()[other].uid;
let _kept = s.group_begin("Theirs").unwrap();
s.set_active(0);
assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": false}));
assert!(s.in_group(other_uid));
}
fn history_of(s: &Session) -> Vec<(String, *const gridcraft_model::Workbook)> {
let d = s.doc().unwrap();
d.undo.iter().chain(d.redo.iter()).map(|e| (e.label.clone(), std::sync::Arc::as_ptr(&e.wb))).collect()
}
#[test]
fn queries_take_no_copy_of_the_history() {
// `execute` copies the history so a panic can put it back; a query leaves it alone, so the
// copy is skipped for the queries front ends run all the time.
let mut s = s();
for i in 0..50 {
set(&mut s, "A1", &i.to_string());
}
s.execute("edit.undo", json!({})).unwrap();
super::HISTORY_COPIES.with(|n| n.set(0));
for _ in 0..100 {
s.execute("history.list", json!({})).unwrap();
s.execute("cell.get", json!({"cell": "A1"})).unwrap();
s.execute("document.inspect", json!({})).unwrap();
}
assert_eq!(super::HISTORY_COPIES.with(|n| n.get()), 0);
set(&mut s, "A2", "x");
assert_eq!(super::HISTORY_COPIES.with(|n| n.get()), 1, "a command that can change it still copies it");
}
#[test]
fn every_query_leaves_the_history_alone() {
// What lets a query skip the copy: none of them changes the undo or redo history.
let mut s = Session::new();
s.execute("file.new", json!({"sample": "sales"})).unwrap();
set(&mut s, "A1", "1");
set(&mut s, "A1", "2");
s.execute("edit.undo", json!({})).unwrap();
let before = history_of(&s);
assert_eq!(before.len(), 2);
let mut queries = 0;
for spec in crate::command_specs().iter().filter(|c| !c.journal && !c.undoable) {
queries += 1;
for params in [json!({}), json!({"range": "A1:B3", "cell": "A1", "sheet": 0}), json!([1, 2])] {
let _ = s.execute(spec.id, params.clone());
s.set_active(0);
assert_eq!(history_of(&s), before, "{} {params}", spec.id);
}
}
assert!(queries > 40, "{queries}");
}
// ---------------------------------------------------------------- recording buffer // ---------------------------------------------------------------- recording buffer
fn recorded_ids(s: &Session) -> Vec<String> { fn recorded_ids(s: &Session) -> Vec<String> {
@@ -662,6 +798,74 @@ fn a_headless_call_that_fails_for_want_of_a_file_is_not_recorded() {
assert_eq!(rec.dropped, 0); assert_eq!(rec.dropped, 0);
} }
#[test]
fn a_call_that_only_opens_a_dialog_is_not_recorded() {
// Menu-style calls with no parameters only queue their dialog; the dialog's confirming call
// is the step, so playing the recording back runs each command once, with its parameters.
let mut s = s();
s.start_recording();
for id in ["file.open", "data.fromTextCsv", "home.formatCells"] {
s.execute(id, json!({})).unwrap_or_else(|e| panic!("{id}: {e}"));
let dialogs = s.take_ui_requests();
assert!(dialogs.iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))), "{id} opens a dialog: {dialogs:?}");
}
assert!(recorded_ids(&s).is_empty(), "{:?}", recorded_ids(&s));
set(&mut s, "A1", "1");
s.execute("home.formatCells", json!({"range": "A1", "style": {"bold": true}})).unwrap();
assert_eq!(recorded_ids(&s), ["cell.set", "home.formatCells"]);
// A dialog queued before the call, and not taken yet, does not hide the call.
s.execute("file.open", json!({})).unwrap();
set(&mut s, "A2", "2");
assert_eq!(recorded_ids(&s), ["cell.set", "home.formatCells", "cell.set"]);
assert_eq!(s.stop_recording().unwrap().dropped, 0);
}
#[test]
fn stop_action_runs_only_on_its_own_and_is_not_recorded() {
// A step of an action in progress may not stop the action: nested, the command is refused
// and the action's group stays. Nor is Stop Action a step of the person's work: a recording
// leaves it out, so playing the recording back never stops the run it is part of.
let mut s = s();
s.start_recording();
let r: crate::Result<()> = s.grouped("Outer", |s| {
set(s, "A1", "one");
let err = s.execute_nested("automate.stopAction", json!({})).unwrap_err().to_string();
assert!(err.contains("on its own"), "{err}");
set(s, "A2", "two");
Ok(())
});
r.unwrap();
assert_eq!(v(&s, "A2").display(), "two");
assert_eq!(undo_labels(&s), ["Outer"], "the action kept its group");
// On its own it stops a group left open; the recording keeps the edits and not the stop.
let _left = s.group_begin("Left open").unwrap();
set(&mut s, "A3", "three");
assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true}));
assert!(v(&s, "A3").is_empty());
assert_eq!(recorded_ids(&s), ["cell.set", "cell.set", "cell.set"]);
assert_eq!(s.stop_recording().unwrap().dropped, 0);
}
#[test]
fn a_run_that_edits_and_opens_a_dialog_is_recorded() {
// A script with an edit before a menu-style step: the edit happened, so the run is a step of
// the recording (the person's confirming call is another). Only a call that opened a dialog
// and changed nothing is left out.
let mut s = s();
let edit_then_dialog = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}, {"command": "home.formatCells", "params": {}}]);
let dialog_only = json!([{"command": "home.formatCells", "params": {}}]);
s.execute("automate.newScript", json!({"name": "Edit", "commands": edit_then_dialog})).unwrap();
s.execute("automate.newScript", json!({"name": "Ask", "commands": dialog_only})).unwrap();
s.start_recording();
s.execute("automate.runScript", json!({"name": "Ask"})).unwrap();
assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))));
assert!(recorded_ids(&s).is_empty(), "{:?}", recorded_ids(&s));
s.execute("automate.runScript", json!({"name": "Edit"})).unwrap();
assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))));
assert_eq!(recorded_ids(&s), ["automate.runScript"], "the edit happened, so the run is a step");
assert_eq!(s.stop_recording().unwrap().dropped, 0);
}
#[test] #[test]
fn the_recorder_keeps_top_level_calls_with_where_they_ran() { fn the_recorder_keeps_top_level_calls_with_where_they_ran() {
let mut s = s(); let mut s = s();
@@ -803,6 +1007,22 @@ fn documents_know_where_they_came_from() {
assert_eq!(trust(&s), Trust::FromFile); assert_eq!(trust(&s), Trust::FromFile);
} }
#[test]
fn whoever_makes_a_document_says_where_it_came_from() {
// No default trust: a document built from bytes outside `file.open` (a recovery copy, a
// template) is untrusted because its maker says so, not trusted because it forgot to.
let mut s = Session::new();
let bytes = gridcraft_xlsx::write_xlsx(&gridcraft_model::Workbook::new()).unwrap();
let (wb, _) = crate::io::open_bytes("recovered.xlsx", &bytes).unwrap();
s.add_document(crate::DocState::new(wb, None, "Recovered".into(), Trust::FromFile));
assert_eq!(trust(&s), Trust::FromFile);
assert_eq!(s.execute("document.inspect", json!({})).unwrap()["trust"], "fromFile");
s.add_document(crate::DocState::new(gridcraft_model::Workbook::new(), None, "Made here".into(), Trust::Local));
assert_eq!(trust(&s), Trust::Local);
s.new_workbook();
assert_eq!(trust(&s), Trust::Local);
}
#[test] #[test]
fn pasting_from_a_file_carries_its_trust() { fn pasting_from_a_file_carries_its_trust() {
let mut s = s(); // document 0: Local let mut s = s(); // document 0: Local
+1 -1
View File
@@ -26,7 +26,7 @@ fn opened() -> Session {
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap(); let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap(); let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
let mut s = Session::new(); let mut s = Session::new();
s.add_document(DocState::new(back, None, "dynamic".into())); s.add_document(DocState::new(back, None, "dynamic".into(), gridcraft_engine::Trust::FromFile));
s s
} }
@@ -78,7 +78,7 @@ fn longest_flat_chains_survive_editing_saving_and_reopening() {
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save"); let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open"); let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
let mut session = Session::new(); let mut session = Session::new();
session.add_document(DocState::new(reopened, None, "chains".into())); session.add_document(DocState::new(reopened, None, "chains".into(), gridcraft_engine::Trust::FromFile));
session.execute("formulas.calculateNow", json!({})).expect("recalc"); session.execute("formulas.calculateNow", json!({})).expect("recalc");
assert_eq!(value(&session, "C2"), Value::Number(4096.0)); assert_eq!(value(&session, "C2"), Value::Number(4096.0));
assert_eq!(value(&session, "C3"), Value::Number(-4094.0)); assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
+2 -1
View File
@@ -6,7 +6,8 @@ const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\
fn with_name(name: &str, refers_to: &str) -> Session { fn with_name(name: &str, refers_to: &str) -> Session {
let mut s = Session::new(); let mut s = Session::new();
s.new_workbook(); s.new_workbook();
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap(); // `force`: the name commands refuse GridCraft's own data names otherwise.
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to, "force": true})).unwrap();
s s
} }
+16 -5
View File
@@ -306,12 +306,10 @@ fn modulo(x: f64, d: f64) -> R<f64> {
if d == 0.0 { if d == 0.0 {
return Err(CellError::Div0); return Err(CellError::Div0);
} }
let q = (x / d).floor(); // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors.
let r = x - d * q; let r = x % d;
// Keep the sign of the divisor and avoid returning |d| from rounding. // Keep the sign of the divisor and avoid returning |d| from rounding.
if r != 0.0 && (r < 0.0) != (d < 0.0) { let r = if r != 0.0 && (r < 0.0) != (d < 0.0) { r + d } else { r };
return Ok(r + d);
}
if r.abs() >= d.abs() { if r.abs() >= d.abs() {
return Ok(0.0); return Ok(0.0);
} }
@@ -1442,6 +1440,19 @@ mod tests {
close(ev1("ODD", 0.0), 1.0); close(ev1("ODD", 0.0), 1.0);
} }
#[test]
fn mod_tiny_divisor_stays_in_range() {
for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] {
match ev2("MOD", x, d) {
Value::Number(r) => {
assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}");
assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}");
}
other => panic!("MOD({x}, {d}) = {other:?}"),
}
}
}
#[test] #[test]
fn arithmetic() { fn arithmetic() {
close(ev2("MOD", 3.0, 2.0), 1.0); close(ev2("MOD", 3.0, 2.0), 1.0);
+90 -16
View File
@@ -13,8 +13,17 @@ pub const MAX_MACRO_STEPS: usize = 10_000;
pub const MAX_MACRO_DEPTH: usize = 4; pub const MAX_MACRO_DEPTH: usize = 4;
/// The longest macro or input name, in characters. /// The longest macro or input name, in characters.
pub const MAX_MACRO_NAME: usize = 255; pub const MAX_MACRO_NAME: usize = 255;
/// The longest macro description or step note, in characters. /// The longest macro description, step note, Stop message or If value (as JSON), in characters.
pub const MAX_MACRO_TEXT: usize = 4_096; pub const MAX_MACRO_TEXT: usize = 4_096;
/// The longest reference or token in a macro (an output anchor, an input's default range, a
/// Repeat's range, an If's cell), in characters.
pub const MAX_MACRO_REF: usize = 1_024;
/// The longest shortcut text (`Ctrl+Shift+M`), in characters.
pub const MAX_MACRO_SHORTCUT: usize = 64;
/// The most commands a macro's permissions list.
pub const MAX_MACRO_PERMISSIONS: usize = 256;
/// The most column types an input lists: one per column of a sheet.
pub const MAX_MACRO_COLUMNS: usize = 16_384;
/// A recorded or hand-made macro: a list of typed steps that runs as one undo step. Most steps run /// A recorded or hand-made macro: a list of typed steps that runs as one undo step. Most steps run
/// a GridCraft command; Repeat, If and Stop add bounded control flow. References in step /// a GridCraft command; Repeat, If and Stop add bounded control flow. References in step
@@ -63,11 +72,17 @@ impl Macro {
count(&self.steps) count(&self.steps)
} }
/// Checks the format version ([`check_version`]), the size limits ([`MAX_MACRO_STEPS`], /// Checks every field a file can set: the format version ([`check_version`]); the lengths of
/// [`MAX_MACRO_DEPTH`], name and text lengths) and that the name has no control characters. /// the name, description, shortcut, notes, messages, references and command ids; the counts of
/// steps ([`MAX_MACRO_STEPS`]), inputs, permissions and column types; the nesting depth
/// ([`MAX_MACRO_DEPTH`]); and each input's declaration. Command parameters are bounded only by
/// the size of the whole macro, which [`MAX_MACRO_STEPS`] and the workbook's item limit cap.
/// Every struct is taken apart field by field, so a field added later doesn't compile here
/// until someone decides its limit.
pub fn check_limits(&self) -> Result<(), String> { pub fn check_limits(&self) -> Result<(), String> {
check_version(u64::from(self.v))?; let Macro { v, name, description, shortcut, inputs, steps, output, permissions } = self;
let name = self.name.trim(); check_version(u64::from(*v))?;
let name = name.trim();
if name.is_empty() { if name.is_empty() {
return Err("A macro needs a name.".into()); return Err("A macro needs a name.".into());
} }
@@ -77,8 +92,19 @@ impl Macro {
if name.chars().any(char::is_control) { if name.chars().any(char::is_control) {
return Err("A macro name can't hold line breaks or other control characters.".into()); return Err("A macro name can't hold line breaks or other control characters.".into());
} }
if self.description.chars().count() > MAX_MACRO_TEXT { check_text("A macro description", description, MAX_MACRO_TEXT, false)?;
return Err(format!("A macro description can be at most {MAX_MACRO_TEXT} characters long.")); if let Some(shortcut) = shortcut {
check_text("A shortcut", shortcut, MAX_MACRO_SHORTCUT, true)?;
}
let OutputRule { anchor, on_conflict: _ } = output;
if let Some(anchor) = anchor {
check_text("The output anchor", anchor, MAX_MACRO_REF, true)?;
}
if permissions.len() > MAX_MACRO_PERMISSIONS {
return Err(format!("A macro can list at most {MAX_MACRO_PERMISSIONS} permissions."));
}
for p in permissions {
check_id("A permission", p)?;
} }
// Count without recursing past the depth limit (a hostile file can nest deeper). // Count without recursing past the depth limit (a hostile file can nest deeper).
// A Repeat or If at the top is nested 1 deep; one inside it, 2 deep. // A Repeat or If at the top is nested 1 deep; one inside it, 2 deep.
@@ -88,24 +114,36 @@ impl Macro {
if *count > MAX_MACRO_STEPS { if *count > MAX_MACRO_STEPS {
return Err(format!("A macro can have at most {MAX_MACRO_STEPS} steps.")); return Err(format!("A macro can have at most {MAX_MACRO_STEPS} steps."));
} }
if s.note.chars().count() > MAX_MACRO_TEXT { let Step { kind, enabled: _, breakpoint: _, note } = s;
return Err(format!("A step note can be at most {MAX_MACRO_TEXT} characters long.")); check_text("A step note", note, MAX_MACRO_TEXT, false)?;
match kind {
StepKind::Command { id, params: _ } => check_id("A step's command", id)?,
StepKind::Repeat { over, of: _, max: _, body: _ } => check_text("A Repeat step's range", over, MAX_MACRO_REF, true)?,
StepKind::If { cell, op: _, value, then: _, otherwise: _ } => {
check_text("An If step's cell", cell, MAX_MACRO_REF, true)?;
// Empty and NotEmpty ignore the value, which is then null.
if serde_json::to_string(value).map_or(usize::MAX, |t| t.chars().count()) > MAX_MACRO_TEXT {
return Err(format!("An If step's value can be at most {MAX_MACRO_TEXT} characters long."));
}
}
StepKind::Stop { message, rollback: _ } => check_text("A Stop message", message, MAX_MACRO_TEXT, false)?,
} }
if s.kind.children().next().is_some() && level > MAX_MACRO_DEPTH { if kind.children().next().is_some() && level > MAX_MACRO_DEPTH {
return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep."));
} }
for body in s.kind.children() { for body in kind.children() {
walk(body, level + 1, count)?; walk(body, level + 1, count)?;
} }
} }
Ok(()) Ok(())
} }
walk(&self.steps, 1, &mut 0)?; walk(steps, 1, &mut 0)?;
if self.inputs.len() > 64 { if inputs.len() > 64 {
return Err("A macro can declare at most 64 inputs.".into()); return Err("A macro can declare at most 64 inputs.".into());
} }
for (k, i) in self.inputs.iter().enumerate() { for (k, i) in inputs.iter().enumerate() {
let n = i.name.as_str(); let NamedInput { name, kind, rows, cols, header, col_types, non_empty: _, default_ref } = i;
let n = name.as_str();
let ident = n.chars().next().is_some_and(char::is_alphabetic) && n.chars().all(|c| c.is_alphanumeric() || c == '_'); let ident = n.chars().next().is_some_and(char::is_alphabetic) && n.chars().all(|c| c.is_alphanumeric() || c == '_');
if !ident || n.chars().count() > MAX_MACRO_NAME { if !ident || n.chars().count() > MAX_MACRO_NAME {
return Err(format!("'{n}' can't name an input: use letters, digits and _, starting with a letter.")); return Err(format!("'{n}' can't name an input: use letters, digits and _, starting with a letter."));
@@ -117,14 +155,50 @@ impl Macro {
if gridcraft_core::RangeRef::parse(n).is_some() { if gridcraft_core::RangeRef::parse(n).is_some() {
return Err(format!("'{n}' reads as a cell reference and can't name an input.")); return Err(format!("'{n}' reads as a cell reference and can't name an input."));
} }
if self.inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) { if inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) {
return Err(format!("Two inputs are called '{n}'.")); return Err(format!("Two inputs are called '{n}'."));
} }
for (what, [least, most]) in [("rows", rows), ("columns", cols)] {
if *most != 0 && least > most {
return Err(format!("Input '{n}' allows {least} to {most} {what}: the least can't be more than the most."));
}
}
// A cell is one row and one column ([1, 1], or the default [1, 0]) with no header.
let one = |b: &[u32; 2]| *b == [1, 1] || *b == [1, 0];
if *kind == InputKind::Cell && (!one(rows) || !one(cols) || *header || col_types.len() > 1) {
return Err(format!("Input '{n}' is a single cell: one row and one column, no header, at most one column type."));
}
let most = if cols[1] == 0 { MAX_MACRO_COLUMNS } else { usize::try_from(cols[1]).unwrap_or(usize::MAX).min(MAX_MACRO_COLUMNS) };
if col_types.len() > most {
return Err(format!("Input '{n}' lists the types of {} columns, but has at most {most}.", col_types.len()));
}
if let Some(r) = default_ref {
check_text(&format!("The default range of input '{n}'"), r, MAX_MACRO_REF, true)?;
}
} }
Ok(()) Ok(())
} }
} }
/// Refuses `text` longer than `max` characters, and, for a one-line field, one that holds line
/// breaks or other control characters.
fn check_text(what: &str, text: &str, max: usize, one_line: bool) -> Result<(), String> {
if text.chars().count() > max {
return Err(format!("{what} can be at most {max} characters long."));
}
if one_line && text.chars().any(char::is_control) {
return Err(format!("{what} can't hold line breaks or other control characters."));
}
Ok(())
}
/// Refuses a command id that isn't one: empty, longer than a name, or holding anything other than
/// letters, digits, `.` and `_` (every command id is of that form).
fn check_id(what: &str, id: &str) -> Result<(), String> {
let ok = !id.is_empty() && id.len() <= MAX_MACRO_NAME && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_');
if ok { Ok(()) } else { Err(format!("{what} must be a command id (letters, digits, . and _): '{}'", id.chars().take(64).collect::<String>())) }
}
/// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so /// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so
/// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every /// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every
/// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list /// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list
+6
View File
@@ -188,6 +188,11 @@ pub struct Workbook {
/// Never written. /// Never written.
#[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::macros::entries")] #[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::macros::entries")]
pub macros_read: Vec<serde_json::Value>, pub macros_read: Vec<serde_json::Value>,
/// Macros in a newer format than this build reads, as the file held them. They are never read,
/// listed or run; a save writes them back unchanged, so the newer build gets them back whole.
/// Shared between snapshots, as nothing changes them.
#[serde(skip)]
pub macros_unread: Arc<Vec<serde_json::Value>>,
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
#[serde(skip)] #[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>, pub preserved: Option<Arc<crate::preserved::Preserved>>,
@@ -236,6 +241,7 @@ impl Workbook {
csv_delimiter: None, csv_delimiter: None,
macros: crate::macros::Macros::default(), macros: crate::macros::Macros::default(),
macros_read: vec![], macros_read: vec![],
macros_unread: Arc::default(),
preserved: None, preserved: None,
source_dir: None, source_dir: None,
source_path: None, source_path: None,
+1 -4
View File
@@ -1084,11 +1084,8 @@ mod tests {
} }
assert!(age(Language::En, f64::MAX).ends_with(" years ago")); assert!(age(Language::En, f64::MAX).ends_with(" years ago"));
// Other languages put the count where their own words want it. // Other languages put the count where their own words want it.
assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前");
assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen");
assert_eq!(age(Language::Fr, 90.0), "il y a 1 min");
assert_eq!(age(Language::It, 3.0 * DAY), "3 giorni fa");
for lang in Language::ALL { for lang in Language::ALL {
assert!(age(lang, 3.0 * DAY).contains('3'), "{lang:?}: {}", age(lang, 3.0 * DAY));
for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] { for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] {
let words = age(lang, seconds); let words = age(lang, seconds);
assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}"); assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}");
-7
View File
@@ -105,13 +105,6 @@ fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> {
Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() })) Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() }))
} }
/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the
/// function help tables are checked against them.
#[cfg(test)]
pub(crate) fn argument_names(signature: &str) -> Vec<&str> {
split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default()
}
/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's /// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's
/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`). /// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`).
fn argument_name(arg: &str) -> &str { fn argument_name(arg: &str) -> &str {
+49 -3
View File
@@ -2388,6 +2388,20 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
&& !mods.shift && !mods.shift
{ {
app.grid.last_click = None; app.grid.last_click = None;
// A PivotTable's value cell shows its source records on a new sheet (Excel's Show
// Details); its labels and headers still edit.
if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) {
let not_a_value_cell =
gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string();
match app.run("pivot.showDetails", json!({"cell": c.a1()})) {
Ok(_) => return,
Err(e) if e == not_a_value_cell => {}
Err(e) => {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
return;
}
}
}
// Following a hyperlink requires a single click in Excel; double-click edits. // Following a hyperlink requires a single click in Excel; double-click edits.
app.begin_edit(None, false); app.begin_edit(None, false);
return; return;
@@ -2399,9 +2413,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
d.selection.anchor = sel.anchor; d.selection.anchor = sel.anchor;
} }
} else if mods.command { } else if mods.command {
// Ctrl+click on a hyperlink's text follows it instead of adding the cell to the selection. // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection.
if let Some(h) = sh.hyperlinks.get(&c).cloned() if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked && primary_clicked
&& !mods.alt
&& p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8
{ {
follow_link(app, &h.target); follow_link(app, &h.target);
@@ -3089,6 +3104,19 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
} }
} }
/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so
/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom,
/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px.
fn editor_slack(size: f32) -> f32 {
14.0 + size
}
/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the
/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies.
fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 {
if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) }
}
fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {
let Some(ed) = app.editor.as_ref() else { return }; let Some(ed) = app.editor.as_ref() else { return };
if ed.sheet != wb.active_sheet { if ed.sheet != wb.active_sheet {
@@ -3115,7 +3143,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
// Excel keeps the cell's height for one line and only grows for extra lines. // Excel keeps the cell's height for one line and only grows for extra lines.
let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() };
((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), h) (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h)
}; };
// Grows to the left on a right-to-left sheet. // Grows to the left on a right-to-left sheet.
let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h)));
@@ -3397,7 +3425,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
let size = vec2(18.0, 18.0); let size = vec2(18.0, 18.0);
let corner = geo.range_rect(sh, tag.target).right_bottom(); let corner = geo.range_rect(sh, tag.target).right_bottom();
let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size));
if !geo.cells.contains_rect(button) { // `button` is in screen space, so compare it with the mirrored cells area.
if !geo.mirror_rect(geo.cells).contains_rect(button) {
return; return;
} }
let lang = crate::i18n::current(ui.ctx()); let lang = crate::i18n::current(ui.ctx());
@@ -4004,6 +4033,23 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn editor_matches_the_cell_while_the_text_fits() {
use super::editor_width;
assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0);
assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0);
assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0);
}
#[test]
fn editor_slack_fits_one_more_glyph_at_high_zoom() {
use super::editor_slack;
// A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px.
for size in [14.7_f32, 25.0, 60.0] {
assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size));
}
}
#[test] #[test]
fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() {
use super::{HAlign, painted_halign}; use super::{HAlign, painted_halign};
+19 -191
View File
@@ -321,21 +321,14 @@ fn name_sort_key(name: &str) -> String {
mod tests { mod tests {
use super::*; use super::*;
/// Help tables are sorted, unique, and keyed by text the function catalog still has. /// Help tables are sorted and unique: lookups binary-search them.
#[test] #[test]
fn function_help_is_sorted_and_keyed_by_current_catalog_text() { fn function_help_tables_are_sorted_and_unique() {
// Lookups binary-search the tables. A key that no longer matches the catalog (an English
// description reworded, an argument renamed) would quietly show English again.
let catalog = gridcraft_engine::cmd::formulas::function_list();
let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
let arguments: std::collections::HashSet<&str> =
catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect();
for language in Language::ALL { for language in Language::ALL {
let Some((description_table, argument_table)) = language.function_help() else { continue }; let Some((description_table, argument_table)) = language.function_help() else { continue };
for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] { for table in [description_table, argument_table] {
assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique"); assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique");
for (english, translated) in table { for (english, translated) in table {
assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use");
assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}"); assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}");
} }
} }
@@ -343,105 +336,27 @@ mod tests {
} }
#[test] #[test]
fn translations_are_unique_and_preserve_unknown_text() { fn untranslated_text_falls_back_to_english() {
for (i, (en, ja)) in ja::CORE.iter().enumerate() { let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label);
assert!(!ja.is_empty());
assert!(ja::CORE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label));
}
for (english, _) in ja::CORE { for (english, _) in ja::CORE {
assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}"); assert_eq!(Language::En.tr(english), label(english));
assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}");
assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}");
assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}");
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
assert!(el::translate(english).is_some_and(|text| !text.is_empty()), "missing Greek: {english}");
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}");
assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}");
assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}");
assert!(it::translate(english).is_some_and(|text| !text.is_empty()), "missing Italian: {english}");
assert!(uk::translate(english).is_some_and(|text| !text.is_empty()), "missing Ukrainian: {english}");
assert!(vi::translate(english).is_some_and(|text| !text.is_empty()), "missing Vietnamese: {english}");
assert!(nl::translate(english).is_some_and(|text| !text.is_empty()), "missing Dutch: {english}");
assert!(ro::translate(english).is_some_and(|text| !text.is_empty()), "missing Romanian: {english}");
} }
assert_eq!(Language::Ja.tr("Data"), "データ"); for language in Language::ALL {
assert_eq!(Language::Zh.tr("Data"), "数据"); assert_eq!(language.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ko.tr("Data"), "데이터"); assert_eq!(language.tr("Not in any catalog|Untranslated"), "Untranslated");
assert_eq!(Language::Ru.tr("Data"), "Данные"); }
assert_eq!(Language::PtBr.tr("Data"), "Dados"); // A `context|label` key shows only its label in English.
assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro.");
assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro.");
assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
assert_eq!(Language::Es.tr("Data"), "Datos");
assert_eq!(Language::El.tr("Data"), "Δεδομένα");
assert_eq!(Language::El.tr("Page Layout|Orientation"), "Προσανατολισμός σελίδας");
assert_eq!(Language::El.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::El.tr("Page {page} of {pages}"), "Σελίδα {page} από {pages}");
assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles");
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::De.tr("Data"), "Daten");
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Fr.tr("Data"), "Données");
assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Tr.tr("Data"), "Veri");
assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::PtBr.tr("Date"), "Data");
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1");
// Czech "Data" is spelled as in English, so probe it with a label that differs.
assert_eq!(Language::Cs.tr("Data"), "Data");
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Tr.tr("Formulas"), "Formüller");
// A `context|label` key shows only its label in English and splits the translation.
assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation");
assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); // Errors drop the `command `id`:` prefix before translating; an untranslated one stays whole.
assert_eq!(Language::Ja.tr("Orientation"), "方向"); assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), Language::PtBr.tr("Select a chart first."));
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace"); assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu"); assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
assert_eq!(Language::Sr.tr("Data"), "Podaci");
assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija");
assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija");
assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü");
assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme");
assert_eq!(Language::Pl.tr("Data"), "Dane");
assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja");
assert_eq!(Language::It.tr("Data"), "Dati");
assert_eq!(Language::It.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::It.tr("Freeze Panes"), "Blocca riquadri");
assert_eq!(Language::It.tr("Page Layout|Orientation"), "Orientamento");
assert_eq!(Language::It.tr("Orientation"), "Orientamento");
assert_eq!(Language::Uk.tr("Data"), "Дані");
assert_eq!(Language::Uk.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Vi.tr("Data"), "Dữ liệu");
assert_eq!(Language::Vi.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Vi.tr("Page Layout|Orientation"), "Hướng giấy");
assert_eq!(Language::Vi.tr("Orientation"), "Hướng chữ");
assert_eq!(Language::Nl.tr("Data"), "Gegevens");
assert_eq!(Language::Nl.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Nl.tr("Page Layout|Orientation"), "Paginastand");
assert_eq!(Language::Nl.tr("Orientation"), "Richting");
assert_eq!(Language::Ro.tr("Data"), "Date");
assert_eq!(Language::Ro.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ro.tr("Page Layout|Orientation"), "Orientare");
assert_eq!(Language::Ro.tr("Orientation"), "Orientare text");
assert_eq!(Language::parse("xx"), None); assert_eq!(Language::parse("xx"), None);
} }
/// A catalog whose translations slip onto their neighbours' keys still covers every key, so /// A catalog whose translations slip onto their neighbours' keys still covers every key, so
/// check the shape of each translation against its English: placeholders survive, and a /// check the shape of each translation against its English: placeholders survive, and a
/// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. French /// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label.
/// once shipped with over a hundred translations shifted this way ("Close" read "Société").
#[test] #[test]
fn translations_keep_the_shape_of_their_english() { fn translations_keep_the_shape_of_their_english() {
let placeholders = let placeholders =
@@ -457,106 +372,19 @@ mod tests {
} }
} }
} }
for (english, french) in [
("Close", "Fermer"),
("File", "Fichier"),
("Save", "Enregistrer"),
("Animations", "Animations"),
("Company", "Société"),
("Show Detail", "Afficher le détail"),
("Solver", "Solveur"),
] {
assert_eq!(Language::Fr.tr(english), french);
}
}
#[test]
fn german_cell_and_header_context_menus_are_fully_translated() {
for label in [
"Cut",
"Copy",
"Paste",
"Paste Special…",
"Insert…",
"Delete…",
"Clear Contents",
"Filter by Selected Cell's Value",
"Sort A to Z",
"Sort Z to A",
"New Comment",
"New Note",
"Format Cells…",
"Pick From Drop-down List…",
"Define Name…",
"Insert Copied Cells",
"Insert Copied Cells…",
"Insert Cut Cells",
"Insert",
"Delete",
"Row Height…",
"AutoFit Row Height",
"Column Width…",
"AutoFit Column Width",
"Hide",
"Unhide",
"Group",
] {
assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}");
}
}
#[test]
fn sheet_tab_menu_and_autosave_labels_are_translated() {
let labels = [
"Insert Sheet",
"Delete",
"Rename",
"Move or Copy…",
"Duplicate",
"Tab Color",
"No Color",
"Hide",
"Unhide…",
"Protect Sheet…",
"Unprotect Sheet…",
"Select All Sheets",
"AutoSave",
"AutoSave: save after every change (workbooks saved to a file)",
"Previous sheet",
"Next sheet",
"All sheets",
"New sheet (⇧F11)",
];
for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] {
for label in labels {
assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code());
}
}
assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado");
} }
#[test] #[test]
fn translations_never_show_the_context_prefix() { fn translations_never_show_the_context_prefix() {
// `tr` returns a found translation as it is, so a catalog that keeps `context|` in its // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
// value shows it on screen (File › Options read "Options|Général" in French). // value shows it on screen (File › Options read "Options|Général" in French).
for language in Language::ALL { let keys = ja::CORE.iter().map(|(english, _)| *english).chain(["Options|General", "Options|Save", "Sheet Options|View"]);
for (english, _) in ja::CORE { for english in keys {
for language in Language::ALL {
let shown = language.tr(english); let shown = language.tr(english);
assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code()); assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code());
} }
} }
assert_eq!(Language::Fr.tr("Options|General"), "Général");
assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer");
assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage");
assert_eq!(Language::Sr.tr("Options|General"), "Opšte");
assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj");
assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz");
assert_eq!(Language::Nl.tr("Options|General"), "Algemeen");
assert_eq!(Language::Nl.tr("Options|Save"), "Opslaan");
assert_eq!(Language::Nl.tr("Sheet Options|View"), "Weergeven");
assert_eq!(Language::Ro.tr("Options|General"), "General");
assert_eq!(Language::Ro.tr("Options|Save"), "Salvare");
assert_eq!(Language::Ro.tr("Sheet Options|View"), "Vizualizare");
} }
#[test] #[test]
+7 -125
View File
@@ -1,12 +1,12 @@
//! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's //! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's
//! Japanese wording, and anything absent falls back to English. //! Japanese wording, and anything absent falls back to English.
//! //!
//! [`CORE`] is the shared reference catalog every language translates: the ribbon, sheet tabs, //! [`CORE`] is the shared reference set: the ribbon, sheet tabs, widgets and the most common dialog
//! widgets and the most common dialog labels. [`DIALOGS`] adds the dialog titles and labels, //! labels. [`DIALOGS`] adds the dialog titles and labels, message boxes, Backstage/File-view items,
//! message boxes, Backstage/File-view items, template names and native-menu strings the Brazilian //! template names and native-menu strings. The two lists never overlap. A string a language doesn't
//! Portuguese catalog (`pt.rs`) covers on top of it. The two lists never overlap. //! translate shows in English; `cargo xtask i18n-report` lists them per language.
/// English label → Japanese, the shared reference set every catalog covers. /// English label → Japanese, the shared reference set.
pub(super) const CORE: &[(&str, &str)] = &[ pub(super) const CORE: &[(&str, &str)] = &[
("Paste", "貼り付け"), ("Paste", "貼り付け"),
("Paste Values", "値の貼り付け"), ("Paste Values", "値の貼り付け"),
@@ -1150,99 +1150,17 @@ mod tests {
DIALOGS.iter().chain(CORE).map(|(english, _)| *english) DIALOGS.iter().chain(CORE).map(|(english, _)| *english)
} }
/// The decoded value of the Rust string literal starting at byte `start` (its opening `"`), and
/// the byte index just past its closing quote. Escapes (`\n`, `\"`, `\\`, `\t`, `\u{…}`) are
/// decoded, so the keys compare equal to the ones in the tables above.
fn string_literal(source: &str, start: usize) -> Option<(String, usize)> {
if !source[start..].starts_with('"') {
return None;
}
let mut value = String::new();
let mut chars = source[start + 1..].char_indices();
while let Some((offset, c)) = chars.next() {
match c {
'"' => return Some((value, start + 1 + offset + 1)),
'\\' => match chars.next().map(|(_, c)| c) {
Some('n') => value.push('\n'),
Some('t') => value.push('\t'),
Some('r') => value.push('\r'),
Some('0') => value.push('\0'),
Some('\\') => value.push('\\'),
Some('"') => value.push('"'),
Some('\'') => value.push('\''),
Some('u') => {
let mut hex = String::new();
for (_, c) in chars.by_ref() {
if c == '}' {
break;
}
if c != '{' {
hex.push(c);
}
}
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
value.push(c);
}
}
// A line continuation (backslash, newline, indentation) adds nothing.
_ => {}
},
_ => value.push(c),
}
}
None
}
/// Every key `pt.rs` translates, read out of its source with a small scanner: a match arm is a
/// string literal whose `=>` is followed by another string literal, which covers the block form
/// (`"key" => { "value" }`) as well. Parsing the real source keeps the two catalogs from
/// drifting apart again instead of checking a remembered count.
fn portuguese_keys() -> Vec<String> {
let source = include_str!("pt.rs");
let mut literals: Vec<(String, usize, usize)> = Vec::new();
let mut i = 0;
while i < source.len() {
let rest = &source[i..];
if rest.starts_with("//") {
i += rest.find('\n').unwrap_or(rest.len());
continue;
}
if rest.starts_with('"') {
let (value, end) = string_literal(source, i).unwrap();
literals.push((value, i, end));
i = end;
continue;
}
i += rest.chars().next().unwrap().len_utf8();
}
let mut keys = Vec::new();
for pair in literals.windows(2) {
let (key, _, end) = &pair[0];
let start = pair[1].1;
let gap = source[*end..start].trim();
if gap == "=>" || gap == "=> {" {
keys.push(key.clone());
}
}
keys
}
#[test] #[test]
fn keys_are_unique_and_every_value_is_translated() { fn keys_are_unique_and_no_value_is_blank() {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for (english, japanese) in DIALOGS.iter().chain(CORE) { for (english, japanese) in DIALOGS.iter().chain(CORE) {
assert!(!japanese.is_empty(), "empty translation for {english:?}");
assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}"); assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}");
assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}"); assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}");
} }
assert_eq!(seen.len(), catalog_keys().count());
assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers");
assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it");
assert_eq!(catalog_keys().count(), 954);
} }
#[test] #[test]
fn extra_labels_are_translated_and_do_not_overlap_the_catalog() { fn extra_labels_do_not_overlap_the_catalog() {
let catalog: HashSet<&str> = catalog_keys().collect(); let catalog: HashSet<&str> = catalog_keys().collect();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for (english, japanese) in EXTRA { for (english, japanese) in EXTRA {
@@ -1252,40 +1170,4 @@ mod tests {
assert_eq!(translate(english), Some(*japanese)); assert_eq!(translate(english), Some(*japanese));
} }
} }
#[test]
fn covers_every_key_the_portuguese_catalog_translates() {
let portuguese = portuguese_keys();
assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count");
let japanese: HashSet<&str> = catalog_keys().collect();
for key in &portuguese {
assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not");
}
assert_eq!(japanese.len(), portuguese.len(), "the two catalogs hold the same key set");
}
#[test]
fn high_value_strings_use_excel_japanese_wording() {
for (english, japanese) in [
("OK", "OK"),
("Cancel", "キャンセル"),
("Don't Save", "保存しない"),
("Text Box", "テキスト ボックス"),
("Row Height", "行の高さ"),
("Column Width", "列の幅"),
("Sort", "並べ替え"),
("Filter", "フィルター"),
("Conditional Formatting", "条件付き書式"),
("Do you want to save the changes you made to {title}?", "{title} への変更を保存しますか?"),
("Save a copy as .{format}", ".{format} としてコピーを保存"),
("Format Cells|Fill", "塗りつぶし"),
("Insert\nSlicer", "スライサーの\n挿入"),
] {
assert_eq!(translate(english), Some(japanese), "{english:?}");
assert_eq!(crate::i18n::Language::Ja.tr(english), japanese, "{english:?}");
}
// A `context|label` key shows only its label in English; an unknown label keeps the text.
assert_eq!(crate::i18n::Language::En.tr("Format Cells|Fill"), "Fill");
assert_eq!(crate::i18n::Language::Ja.tr("Not in any catalog"), "Not in any catalog");
}
} }
+8
View File
@@ -117,6 +117,14 @@ impl Default for UiState {
} }
impl UiState { impl UiState {
/// The state saved by an earlier run, as the app starts with it. The ribbon tab is written to
/// the preferences but not restored: Excel opens on Home every launch, so a contextual tab
/// (Table Design) cannot come back with nothing selected to justify it.
pub fn at_startup(mut self) -> Self {
self.ribbon_tab = "Home".into();
self
}
pub fn theme_preference(&self) -> egui::ThemePreference { pub fn theme_preference(&self) -> egui::ThemePreference {
if self.system_theme { if self.system_theme {
egui::ThemePreference::System egui::ThemePreference::System
+23 -6
View File
@@ -74,15 +74,23 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
{ {
app.run_or_alert("file.save", json!({})); app.run_or_alert("file.save", json!({}));
} }
let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); // The commands' own availability: there must be a step, and no undo group may be open on
let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty()); // the document (an action that spans several calls is still running).
if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)).clicked() && can_undo { let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok();
let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok();
// Named and marked enabled or not for assistive technology (and the UI tests).
let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch));
undo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_undo, "Undo"));
if undo.clicked() && can_undo {
app.run_or_alert("edit.undo", json!({})); app.run_or_alert("edit.undo", json!({}));
} }
let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch));
egui::Popup::menu(&undo_list).show(|ui| { egui::Popup::menu(&undo_list).show(|ui| {
let labels: Vec<String> = let labels: Vec<String> = if can_undo {
app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default(); app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default()
} else {
Vec::new()
};
if labels.is_empty() { if labels.is_empty() {
ui.label("Can't Undo"); ui.label("Can't Undo");
} }
@@ -92,7 +100,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
} }
} }
}); });
if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)).clicked() && can_redo { let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch));
redo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_redo, "Redo"));
if redo.clicked() && can_redo {
app.run_or_alert("edit.redo", json!({})); app.run_or_alert("edit.redo", json!({}));
} }
let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch));
@@ -204,6 +214,11 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let collapsed = app.ui.ribbon_collapsed; let collapsed = app.ui.ribbon_collapsed;
let h = if collapsed { 34.0 } else { 34.0 + 92.0 }; let h = if collapsed { 34.0 } else { 34.0 + 92.0 };
// A contextual tab exists only while its object is selected (Excel hides Table Design once the
// cell leaves the table); a remembered one that is gone would draw an empty ribbon.
if CONTEXTUAL_TABS.contains(&app.ui.ribbon_tab.as_str()) && !contextual_tabs(app).contains(&app.ui.ribbon_tab.as_str()) {
app.ui.ribbon_tab = "Home".to_string();
}
egui::Panel::top("ribbon") egui::Panel::top("ribbon")
.exact_size(h) .exact_size(h)
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 10, right: 10, top: 0, bottom: 6 })) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 10, right: 10, top: 0, bottom: 6 }))
@@ -322,6 +337,8 @@ fn theme_choices(app: &mut SheetApp, ui: &mut Ui) {
} }
} }
const CONTEXTUAL_TABS: [&str; 3] = ["Table Design", "Chart Design", crate::paper_view::TAB];
fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
let mut v = Vec::new(); let mut v = Vec::new();
if let Some(d) = app.session.active() if let Some(d) = app.session.active()
@@ -0,0 +1,94 @@
//! A contextual ribbon tab (Table Design, Chart Design) only exists while its object is selected.
//! The tab remembered in the saved UI state, or left behind when the selection moves away, must
//! not leave the ribbon blank: it falls back to Home.
use egui::vec2;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn session_with_table() -> Session {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
session.execute("selection.set", json!({"range": "A2"})).unwrap();
session
}
fn app_restored_on(tab: &str, session: Session) -> Harness {
let mut app = SheetApp::new(session, Default::default());
app.ui.ribbon_tab = tab.into();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
#[test]
fn a_saved_table_design_tab_without_a_table_opens_on_home() {
let mut session = Session::new();
session.new_workbook();
let h = app_restored_on("Table Design", session);
assert_eq!(h.state().ui.ribbon_tab, "Home");
}
#[test]
fn a_saved_chart_design_tab_without_a_chart_opens_on_home() {
let mut session = Session::new();
session.new_workbook();
let h = app_restored_on("Chart Design", session);
assert_eq!(h.state().ui.ribbon_tab, "Home");
}
#[test]
fn leaving_the_table_while_on_table_design_returns_to_home() {
let mut h = harness(session_with_table());
h.state_mut().ui.ribbon_tab = "Table Design".into();
h.run_steps(2);
assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the tab stays while the cell is in the table");
h.state_mut().session.execute("selection.set", json!({"range": "D10"})).unwrap();
h.run_steps(2);
assert_eq!(h.state().ui.ribbon_tab, "Home");
}
#[test]
fn startup_opens_on_home_not_the_saved_tab() {
let saved = gridcraft_ui_egui::UiState { ribbon_tab: "Table Design".into(), dark: true, ..Default::default() };
let json = serde_json::to_string(&saved).unwrap();
assert!(json.contains(r#""ribbonTab":"Table Design""#), "{json}");
let restored = serde_json::from_str::<gridcraft_ui_egui::UiState>(&json).unwrap().at_startup();
assert_eq!(restored.ribbon_tab, "Home", "the saved tab is not restored");
assert!(restored.dark, "the other preferences are");
}
#[test]
fn an_ordinary_tab_stays_put_in_a_session() {
let mut session = Session::new();
session.new_workbook();
let mut h = harness(session);
h.state_mut().ui.ribbon_tab = "Data".into();
h.run_steps(3);
assert_eq!(h.state().ui.ribbon_tab, "Data");
}
+10 -14
View File
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
use egui_kittest::kittest::Queryable as _; use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::{Session, core::CellRef, core::Value}; use gridcraft_engine::{Session, core::CellRef, core::Value};
use gridcraft_ui_egui::i18n::Language;
use gridcraft_ui_egui::{Services, SheetApp}; use gridcraft_ui_egui::{Services, SheetApp};
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
@@ -134,30 +135,25 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
#[test] #[test]
fn translated_options_headings_show_no_context_prefix() { fn translated_options_headings_show_no_context_prefix() {
for (code, headings, prefixed, (view, view_prefixed)) in [ for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] {
("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")),
("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")),
("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")),
("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")),
] {
let mut h = harness(Services::default()); let mut h = harness(Services::default());
h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap(); h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap();
h.state_mut().run("file.options", serde_json::json!({})).unwrap(); h.state_mut().run("file.options", serde_json::json!({})).unwrap();
h.run_steps(3); h.run_steps(3);
assert_eq!(page(&h), Some("options")); assert_eq!(page(&h), Some("options"));
for heading in headings { for key in ["Options|General", "Options|Save"] {
assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading"); let heading = language.tr(key);
} assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}");
for label in prefixed { assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading");
assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context");
} }
// Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same // Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same
// prefix. The View tab may share the word, so count at least the two checkboxes. // prefix. The View tab may share the word, so count at least the two checkboxes.
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
h.state_mut().ui.ribbon_tab = "Page Layout".into(); h.state_mut().ui.ribbon_tab = "Page Layout".into();
h.run_steps(3); h.run_steps(3);
assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context"); let view = language.tr("Sheet Options|View");
assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}"); assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}");
assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}");
} }
} }
@@ -209,3 +209,65 @@ fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() {
let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap()); let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap());
assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner"); assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner");
} }
/// Right-to-left sheet with column A made `chars` wide (so column B's right edge, where the fill
/// range B1:B6 ends, sits far enough right to be scrolled to either edge of the cells area).
fn rtl_wide_a(chars: u32) -> Harness {
harness(|s| {
one_two(s);
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": chars})).unwrap();
s.execute("view.rightToLeft", json!({"on": true})).unwrap();
})
}
/// Logical right edge of the filled column B (scroll-dependent) and the current geometry.
fn b_right(h: &Harness) -> (f32, Geo) {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
(geo.cell_rect(sheet, CellRef::parse("B6").unwrap()).right(), geo)
}
fn scroll_b_right_to(h: &mut Harness, logical_x: f32) {
let (now, geo) = b_right(h);
h.state_mut().view_mut().unwrap().scroll.x += (now - logical_x) / geo.z;
h.run_steps(3);
let got = b_right(h).0;
assert!((got - logical_x).abs() < 1.0, "setup: column B ends at logical x {got}, wanted {logical_x}");
}
/// Logical x of the range's corner is `cells.right() - 25`: the 18 px button (at corner + 2 ..
/// corner + 20) fits in the cells area, so on screen it sits at the left edge of the mirrored cells
/// area, left of the filled range. `fill_options_tag` tested the mirrored button against the
/// unmirrored cells, i.e. against the row-heading strip it does not own, and hid it.
#[test]
fn the_button_shows_near_the_left_screen_edge_on_a_right_to_left_sheet() {
let mut h = rtl_wide_a(220);
let (_, geo) = b_right(&h);
scroll_b_right_to(&mut h, geo.cells.right() - 25.0);
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
let (_, geo) = b_right(&h);
let cells = h.state().grid.cells_rect.unwrap();
assert!(geo.header_w > 20.0 && geo.cells.right() - 25.0 + 20.0 > geo.cells.right() - geo.header_w, "setup: in the zone the old check hides");
let button = h.query_by_label("Auto Fill Options").expect("the button fits inside the cells area, so it shows").rect();
assert!(cells.contains_rect(button), "{button:?} outside {cells:?}");
}
/// The corner is 10 px left of `cells.left()` (scrolled partly behind the row headings, which are
/// on the right on screen). The button would start 8 px behind the headings, so it must not be
/// drawn there. The old check let it through because the mirrored button fits the unmirrored cells.
#[test]
fn the_button_stays_off_the_row_headings_on_a_right_to_left_sheet() {
let mut h = rtl_wide_a(20);
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
assert!(h.query_by_label("Auto Fill Options").is_some(), "setup: the button shows before scrolling");
let (_, geo) = b_right(&h);
scroll_b_right_to(&mut h, geo.cells.left() - 10.0);
let cells = h.state().grid.cells_rect.unwrap();
if let Some(b) = h.query_by_label("Auto Fill Options") {
let button = b.rect();
assert!(cells.contains_rect(button), "{button:?} overlaps the row headings, cells area is {cells:?}");
}
}
@@ -176,14 +176,12 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() {
let mut app = app(); let mut app = app();
app.run("app.language.set", json!({"code":"pt-BR"})).unwrap(); app.run("app.language.set", json!({"code":"pt-BR"})).unwrap();
assert_eq!(app.ui.language, Language::PtBr); assert_eq!(app.ui.language, Language::PtBr);
assert_eq!(app.ui.language.tr("Data"), "Dados");
assert!(enter(&mut app, "=SUM(1.5,2.25)")); assert!(enter(&mut app, "=SUM(1.5,2.25)"));
app.begin_edit(None, true); app.begin_edit(None, true);
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
assert!(!enter(&mut app, "=SUMA(1;2)")); assert!(!enter(&mut app, "=SUMA(1;2)"));
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
assert_eq!(restored.language, Language::PtBr); assert_eq!(restored.language, Language::PtBr);
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación");
} }
#[test] #[test]
@@ -193,7 +191,6 @@ fn czech_interface_retains_its_canonical_formula_fallback() {
let mut app = app(); let mut app = app();
app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap(); app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap();
assert_eq!(app.ui.language, Language::Cs); assert_eq!(app.ui.language, Language::Cs);
assert_eq!(app.ui.language.tr("Formulas"), "Vzorce");
assert!(enter(&mut app, "=SUM(1.5,2.25)")); assert!(enter(&mut app, "=SUM(1.5,2.25)"));
app.begin_edit(None, true); app.begin_edit(None, true);
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
@@ -225,8 +222,6 @@ fn turkish_interface_retains_its_canonical_formula_fallback() {
let mut app = app(); let mut app = app();
app.run("app.language.set", json!({"code":"tr-TR"})).unwrap(); app.run("app.language.set", json!({"code":"tr-TR"})).unwrap();
assert_eq!(app.ui.language, Language::Tr); assert_eq!(app.ui.language, Language::Tr);
assert_eq!(app.ui.language.tr("Data"), "Veri");
assert_eq!(app.ui.language.tr("Formulas"), "Formüller");
assert!(enter(&mut app, "=SUM(1.5,2.25)")); assert!(enter(&mut app, "=SUM(1.5,2.25)"));
app.begin_edit(None, true); app.begin_edit(None, true);
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
@@ -83,3 +83,15 @@ fn ctrl_click_beside_the_link_text_still_extends_the_selection() {
assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection"); assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection");
assert!(h.state().dialog.is_none(), "no link was followed"); assert!(h.state().dialog.is_none(), "no link was followed");
} }
#[test]
fn ctrl_alt_click_on_the_link_text_still_extends_the_selection() {
let mut h = harness();
click_at(&mut h, "B2", 0.9, Modifiers::NONE);
click_at(&mut h, "B4", 0.3, Modifiers { alt: true, ..Modifiers::COMMAND });
let d = h.state().session.active().unwrap();
let ranges: Vec<String> = d.selection.ranges.iter().map(|r| r.a1()).collect();
assert_eq!(ranges, ["B2", "B4"], "Ctrl+Alt+click adds the hyperlink cell to the selection");
assert_eq!(d.selection.active.a1(), "B4", "the link was not followed");
assert!(h.state().dialog.is_none(), "no link dialog");
}
+21 -31
View File
@@ -4,6 +4,7 @@ use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable as _; use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session; use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value}; use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::i18n::Language;
use gridcraft_ui_egui::{SheetApp, grid::Geo}; use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json; use serde_json::json;
@@ -84,7 +85,7 @@ fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() {
// Column A frozen: a page is the scrolling part only. // Column A frozen: a page is the scrolling part only.
sh.freeze = Some((0, 1)); sh.freeze = Some((0, 1));
let mut s = Session::new(); let mut s = Session::new();
s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into())); s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local));
let mut h = harness(s); let mut h = harness(s);
let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar"); let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar");
assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}"); assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}");
@@ -400,8 +401,7 @@ fn renders_every_ribbon_tab_without_panicking() {
} }
#[test] #[test]
fn language_switch_translates_the_ribbon_and_persists() { fn language_switch_renders_the_ribbon_and_persists() {
use gridcraft_ui_egui::i18n::Language;
let mut h = harness(blank()); let mut h = harness(blank());
// English by default whatever the host's locale: only the desktop app reads the system language. // English by default whatever the host's locale: only the desktop app reads the system language.
assert_eq!(h.state().ui.language, Language::En); assert_eq!(h.state().ui.language, Language::En);
@@ -415,31 +415,13 @@ fn language_switch_translates_the_ribbon_and_persists() {
for id in ["app.language.set", "app.language.english", "app.language.japanese"] { for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
assert!(ids.contains(&id), "engine.commands lists {id}"); assert!(ids.contains(&id), "engine.commands lists {id}");
} }
for (language, expected) in [ for language in Language::ALL {
(Language::Zh, "数据"),
(Language::Ja, "データ"),
(Language::Ko, "데이터"),
(Language::Ru, "Данные"),
(Language::PtBr, "Dados"),
(Language::De, "Daten"),
(Language::Fr, "Données"),
(Language::Tr, "Veri"),
(Language::It, "Dati"),
] {
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
h.run_steps(2); // every locale renders the ribbon without panicking h.run_steps(2); // every locale renders the ribbon without panicking
assert_eq!(h.state().ui.language, language); assert_eq!(h.state().ui.language, language);
assert_eq!(language.tr("Data"), expected);
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(restored.language, language); assert_eq!(restored.language, language);
} }
// Czech spells "Data" as English does, so the Czech table is checked on a label that differs.
h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap();
h.run_steps(2);
assert_eq!(h.state().ui.language, Language::Cs);
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(restored.language, Language::Cs);
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
assert_eq!(h.state().ui.language, Language::En); assert_eq!(h.state().ui.language, Language::En);
} }
@@ -452,16 +434,21 @@ fn pivot_fields_pane_follows_the_interface_language() {
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap(); h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap();
h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();
h.run_steps(4); h.run_steps(4);
for label in ["Campos de tabla dinámica", "Filas", "Valores", "Actualizar", "Campo calculado…"] { let es = |english| Language::Es.tr(english);
assert!(h.query_by_label(label).is_some(), "the pane shows `{label}`"); for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] {
assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english));
}
if es("PivotTable Fields") != "PivotTable Fields" {
assert!(h.query_by_label("PivotTable Fields").is_none());
} }
assert!(h.query_by_label("PivotTable Fields").is_none());
h.get_by_label("Region ▾").click(); h.get_by_label("Region ▾").click();
h.run_steps(2); h.run_steps(2);
for label in ["Mover a rótulos de columna", "Agrupar por años", "Quitar campo"] { for english in ["Move to Column Labels", "Group by Years", "Remove Field"] {
assert!(h.query_by_label(label).is_some(), "the field menu shows `{label}`"); assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english));
}
if es("Remove Field") != "Remove Field" {
assert!(h.query_by_label("Remove Field").is_none());
} }
assert!(h.query_by_label("Remove Field").is_none());
} }
#[test] #[test]
@@ -492,9 +479,12 @@ fn format_cells_draws_its_labels_in_the_interface_language() {
h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap();
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
h.run_steps(2); h.run_steps(2);
assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); let scientific = Language::De.tr("Scientific");
assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated");
h.get_by_label("Wissenschaftlich").click(); if scientific != "Scientific" {
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
}
h.get_by_label(scientific).click();
h.run_steps(2); h.run_steps(2);
let style = &h.state().dialog.as_ref().unwrap().values["style"]; let style = &h.state().dialog.as_ref().unwrap().values["style"];
assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code");
+3
View File
@@ -16,6 +16,7 @@ mod comments_pane;
mod commit_after_pointing; mod commit_after_pointing;
mod committed_text_fields; mod committed_text_fields;
mod context_menu_stays_open; mod context_menu_stays_open;
mod contextual_ribbon_tab;
mod control_timeout; mod control_timeout;
mod dialog_button_row; mod dialog_button_row;
mod dialog_confirm; mod dialog_confirm;
@@ -59,6 +60,7 @@ mod page_layout_view;
mod page_setup_margins; mod page_setup_margins;
mod paste_special_dialog; mod paste_special_dialog;
mod pivot_pane; mod pivot_pane;
mod pivot_show_details;
mod popup_dismissal; mod popup_dismissal;
mod protect_password; mod protect_password;
mod right_to_left; mod right_to_left;
@@ -83,6 +85,7 @@ mod touch_pan;
mod trace_navigation; mod trace_navigation;
mod translated_form_fields; mod translated_form_fields;
mod ui_clock; mod ui_clock;
mod undo_while_grouping;
mod validation_alert; mod validation_alert;
mod validation_input_message; mod validation_input_message;
mod wheel_zoom; mod wheel_zoom;
@@ -0,0 +1,106 @@
//! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's
//! Show Details); a label of the report still opens the cell for editing.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap();
// Report at D3: D4 East, E4 its Sum of Sales.
session
.execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]}))
.unwrap();
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center()
}
fn double_click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) {
for _ in 0..2 {
h.input_mut().events.extend([
Event::PointerMoved(pos),
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
]);
h.run_steps(2);
}
}
#[test]
fn double_click_on_a_value_cell_shows_its_records_on_a_new_sheet() {
let mut h = harness();
let pos = center(&h, "E4");
double_click(&mut h, pos);
let app = h.state();
assert!(app.editor.is_none(), "the value cell must not open for editing");
let wb = &app.session.active().unwrap().wb;
assert_eq!(wb.sheets.len(), 2);
let sheet = wb.active().unwrap();
assert_eq!(sheet.name, "Sheet2");
let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display();
assert_eq!(
[text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")],
["Region", "Sales", "East", "100", "East", "30", ""]
);
}
#[test]
fn double_click_on_a_row_label_still_edits() {
let mut h = harness();
let pos = center(&h, "D4");
double_click(&mut h, pos);
let app = h.state();
assert!(app.editor.is_some());
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
}
#[test]
fn double_click_on_a_value_cell_of_a_protected_workbook_says_why_and_does_not_edit() {
let mut h = harness();
h.state_mut().session.execute("review.protectWorkbook", json!({"on": true})).unwrap();
h.run_steps(2);
let pos = center(&h, "E4");
double_click(&mut h, pos);
let app = h.state();
assert!(app.editor.is_none(), "the value cell must not open for editing");
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("protected")), "{:?}", app.message);
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
}
#[test]
fn double_click_in_an_out_of_date_report_asks_for_a_refresh() {
let mut h = harness();
// West goes away without a refresh: E6, the Grand Total on the sheet, has no cell in the
// report the source now gives.
h.state_mut().session.execute("range.setValues", json!({"range": "Sheet1!A3", "values": [["East"]]})).unwrap();
h.run_steps(2);
let pos = center(&h, "E6");
double_click(&mut h, pos);
let app = h.state();
assert!(app.editor.is_none(), "the cell must not open for editing");
assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message);
assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1);
}
+1 -1
View File
@@ -18,7 +18,7 @@ fn harness(freeze: Option<(u32, u32)>) -> Harness {
sh.view_active = CellRef::parse("A31").unwrap(); sh.view_active = CellRef::parse("A31").unwrap();
sh.freeze = freeze; sh.freeze = freeze;
let mut session = Session::new(); let mut session = Session::new();
session.add_document(DocState::new(wb, None, "Book1".into())); session.add_document(DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| { |ui, app: &mut SheetApp| {
app.logic(ui.ctx()); app.logic(ui.ctx());
@@ -4,6 +4,7 @@
use egui_kittest::kittest::Queryable as _; use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session; use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::i18n::Language;
use serde_json::json; use serde_json::json;
fn harness() -> egui_kittest::Harness<'static, SheetApp> { fn harness() -> egui_kittest::Harness<'static, SheetApp> {
@@ -23,19 +24,17 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
#[test] #[test]
fn form_fields_are_translated() { fn form_fields_are_translated() {
for (dialog, translated, english) in [ for (dialog, labels) in [("pageSetup", ["Left margin (in):", "Footer (in):"]), ("insertPaste", ["Shift cells right", "Shift cells down"])] {
("pageSetup", ["Margem esquerda (pol.):", "Rodapé (pol.):"], ["Left margin (in):", "Footer (in):"]),
("insertPaste", ["Deslocar células para a direita", "Deslocar células para baixo"], ["Shift cells right", "Shift cells down"]),
] {
let mut h = harness(); let mut h = harness();
h.state_mut().run("app.language.set", json!({"language": "pt"})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "pt"})).unwrap();
h.state_mut().open_dialog(dialog, json!({})); h.state_mut().open_dialog(dialog, json!({}));
h.run_steps(3); h.run_steps(3);
for label in translated { for english in labels {
assert!(h.query_by_label_contains(label).is_some(), "{dialog}: no {label}"); let translated = Language::PtBr.tr(english);
} assert!(h.query_by_label_contains(translated).is_some(), "{dialog}: no {translated}");
for label in english { if translated != english {
assert!(h.query_by_label_contains(label).is_none(), "{dialog}: {label} is untranslated"); assert!(h.query_by_label_contains(english).is_none(), "{dialog}: {english} is untranslated");
}
} }
} }
} }
@@ -0,0 +1,57 @@
//! Undo and Redo on the Quick Access Toolbar follow the commands' own availability: while an undo
//! group is open on the document (an action that spans several calls is still running), both
//! buttons are off, and a click neither runs the command nor raises its refusal.
use egui_kittest::kittest::{NodeT as _, Queryable as _};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
h
}
fn history(h: &Harness) -> (usize, usize) {
let d = h.state().session.active().unwrap();
(d.undo.len(), d.redo.len())
}
#[test]
fn undo_and_redo_wait_while_an_undo_group_is_open() {
let mut h = harness();
assert_eq!(history(&h), (1, 1));
let token = h.state_mut().session.group_begin("Long action").unwrap();
h.run_steps(2);
for button in ["Undo", "Redo"] {
assert!(h.get_by_label(button).accesskit_node().is_disabled(), "{button} is off while the group is open");
h.get_by_label(button).click();
h.run_steps(2);
assert!(h.state().message.is_none(), "{button}: no refusal is raised while the group is open");
assert_eq!(history(&h), (1, 1), "{button}: the history is as it was");
}
h.state_mut().session.group_end(token).unwrap();
h.run_steps(2);
assert!(!h.get_by_label("Undo").accesskit_node().is_disabled() && !h.get_by_label("Redo").accesskit_node().is_disabled());
// With the group ended, the buttons work again.
h.get_by_label("Undo").click();
h.run_steps(2);
assert_eq!(history(&h), (0, 2));
h.get_by_label("Redo").click();
h.run_steps(2);
assert_eq!(history(&h), (1, 1));
}
+33 -15
View File
@@ -1,6 +1,6 @@
# Architecture # Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365)
How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md).
@@ -34,7 +34,9 @@ including tests, 2026-10-10.
enabled, run, journal, undoable }`; ids follow Excel's ribbon (`home.bold`, `insert.chart`, enabled, run, journal, undoable }`; ids follow Excel's ribbon (`home.bold`, `insert.chart`,
`data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`).
`Session::execute` catches escaped panics and restores the workbook, its selection and its `Session::execute` catches escaped panics and restores the workbook, its selection and its
undo and redo history as they were before the command (never-crash guard). undo and redo history as they were before the command (never-crash guard); a group that was
open before the command and that the command ended or rolled back stays ended and keeps a
step from its start to the restored workbook, so Undo still returns to the state before it.
2. **Edits** change the model through copy-on-write bands; undo keeps the previous snapshot. 2. **Edits** change the model through copy-on-write bands; undo keeps the previous snapshot.
Undo retains at most 100 steps and trims older steps when its estimated retained cell storage Undo retains at most 100 steps and trims older steps when its estimated retained cell storage
exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget. exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget.
@@ -59,24 +61,40 @@ Actions made of many commands (macros, a debugger that runs one step per call) u
closes its own groups still makes one undo step for itself, covering its edits inside and outside closes its own groups still makes one undo step for itself, covering its edits inside and outside
the groups. A command that ends a group open before it leaves the group its step and makes one the groups. A command that ends a group open before it leaves the group its step and makes one
of its own for what it changes after the end; a group a command opens and leaves open starts of its own for what it changes after the end; a group a command opens and leaves open starts
where the command did. The history limit is applied when the outermost command finishes; until where the command did, but only when the command succeeds: a command that fails rolls back
then the steps made inside it are bounded on their own. every group it opened, as `group_rollback` does. `Session::abort_groups(doc)`, and the command
`automate.stopAction` on the active workbook, roll back the groups left open on a document (a
debugger the person abandons, a caller that dropped its token), so Undo works there again. The
command runs on its own: a step of a script or macro may not stop the action it is part of, and
a recording leaves it out, so playing the recording back never stops the run. The
history limit is applied when the outermost command finishes; until then the steps made inside
it are bounded on their own.
- **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful
top-level command with the document, sheet and selection it ran in, without secret parameters. top-level command with the document, sheet and selection it ran in, without secret parameters.
Commands run by other commands are not recorded, and the buffer is independent of the journal Commands run by other commands are not recorded, nor is a menu-style call that only opened a
dialog (the dialog's confirming call is the step; a run that changed the workbook before a step
of it opened a dialog is recorded), and the buffer is independent of the journal
(Automate › Record Actions still records through the journal). (Automate › Record Actions still records through the journal).
- **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and
`FromFile` for workbooks opened from a file, and for workbooks that received a paste from one `FromFile` for workbooks opened from a file. `Trust` has no default: `DocState::new` takes it,
that writes formulas or validation: Paste, Paste Special › All, All Except Borders, Formulas, so every way of making a document says where its content came from. It is also `FromFile` for
Formulas and Number Formats or Validation, and any linked paste other than column widths or workbooks that received a paste from one that writes formulas or validation: Paste, Paste
notes. Values, formats, column widths and notes carry nothing active. Active content in a Special › All, All Except Borders, Formulas, Formulas and Number Formats or Validation, and any
`FromFile` workbook asks before it runs. This linked paste other than column widths or notes. Values, formats, column widths and notes carry
is provenance, which no decision changes; `DocState::scripts_trusted` is the separate nothing active. Active content in a `FromFile` workbook asks before it runs. This is provenance,
per-session consent for stored `automate.*` scripts (`automate.trustScripts`). which no decision changes; `DocState::scripts_trusted` is the separate per-session consent for
stored `automate.*` scripts (`automate.trustScripts`).
- **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden
workbook-scope names `_gc_<key>_0`, `_gc_<key>_1`, … of string literals within Excel's 255- and workbook-scope names `_gc_<key>_0`, `_gc_<key>_1`, … of string literals within Excel's 255- and
8,192-character limits, so it survives an xlsx round trip. The older single-name `_sc_<key>` 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls
items are read as well. other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter
U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items`
and `stash::keys` list the items under a key prefix in one pass over the names, at most
10,000 of them (`stash::MAX_ITEMS`) with a count of the rest, so a caller can say what it did
not list. The older single-name `_sc_<key>` items are read as well.
`formulas.defineName` and `formulas.deleteName` refuse a name the workbook holds such an item
under (a hidden workbook-scope name of either shape; a visible one is the person's own), and
`formulas.createFromSelection` skips such labels, unless `force: true` is passed.
## Agent control ## Agent control
@@ -103,6 +121,6 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-23 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage |
| 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee |
| 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps |
+4 -2
View File
@@ -1,6 +1,6 @@
# Gaps # Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus) · **Target:** Microsoft Excel (Microsoft 365) > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -84,7 +84,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F)
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
`insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields
and values on rows done). and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists
its source records on a new sheet; the "Enable show details" option isn't modelled yet.
- **Impact:** pivots are the analyst's main tool. - **Impact:** pivots are the analyst's main tool.
- **Estimate:** 20–30 h for beta (25–40 h full). - **Estimate:** 20–30 h for beta (25–40 h full).
@@ -178,6 +179,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) |
| 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step | | 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step |
| 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite | | 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite |
| 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) | | 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) |
+5 -3
View File
@@ -1,6 +1,6 @@
# Localization parity # Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus; Dutch interface language; Romanian interface catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats, packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -96,8 +96,9 @@ language (none so far) and of localized function names.
## Infrastructure (do first) ## Infrastructure (do first)
1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes, 1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes,
status and error messages, command labels, function descriptions), with a missing-key check status and error messages, command labels, function descriptions). A string a catalog lacks
in `cargo xtask ci`. Language switching and the system-language default already exist (#31). shows in English and never fails a check: `cargo xtask i18n-report` lists the missing strings
per language, and `cargo xtask ci` runs it first. Language switching and the system-language default already exist (#31).
2. Locale-aware formulas: localized function-name tables and separators at the formula-bar 2. Locale-aware formulas: localized function-name tables and separators at the formula-bar
boundary only (files keep en-US invariant names), locale number and date formats, list boundary only (files keep en-US invariant names), locale number and date formats, list
separators in CSV. separators in CSV.
@@ -109,6 +110,7 @@ language (none so far) and of localized function names.
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay |
| 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review |
| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |
| 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) | | 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) |
+31 -15
View File
@@ -40,16 +40,28 @@ no macro debugger, recorder or management commands in this version, and no macro
`{"command": "cell.set", "params": {…}}` is accepted for a command step. `{"command": "cell.set", "params": {…}}` is accepted for a command step.
- **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum; - **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum;
rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`), rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`),
`nonEmpty` and `defaultRef`. An input's name is letters, digits and `_`, starting with a letter, `nonEmpty` and `defaultRef`. These declare what a range must look like for the macro to work
and is neither a token of its own (`Output`, `Row`, `Column`, `Name`) nor something that reads on it. An input's name is letters, digits and `_`, starting with a letter, and is neither a
as a cell reference (`Q1`), so a step written with plain A1 text keeps meaning the cell. A run token of its own (`Output`, `Row`, `Column`, `Name`) nor something that reads as a cell
binds each input to the range it is given, its default, or (for the first input) the reference (`Q1`), so a step written with plain A1 text keeps meaning the cell. The declaration
selection, and refuses with messages that name the cells: "Input column 2 (Amount) must hold is checked as a whole: neither `rows` nor `cols` may have a least above its most, a `cell` input
numbers; C4 holds text." has one row and one column (`[1, 1]`, or the default `[1, 0]`), no header and at most one column
- **Limits**: 1,000 macros a workbook, 10,000 steps a macro, Repeat and If nested 4 deep, type, and no input lists more column types than it allows columns. A run binds each input
100,000 steps executed per run and 60 seconds a run, both shared by the footprint dry runs and to the range it is given, its default, or (for the first input) the selection, and refuses
the run itself (`timeLimit` to change the time; the web build has no clock, so there only the with messages that name the cells: "Input column 2 (Amount) must hold numbers; C4 holds text."
step limit applies). - **Limits**: 1,000 macros a workbook, 10,000 steps a macro, Repeat and If nested 4 deep, and 64
inputs a macro. Names (of macros and inputs) and command ids are at most 255 characters;
descriptions, step notes, Stop messages and If values (as JSON) 4,096; references and tokens
(`output.anchor`, `defaultRef`, a Repeat's `over`, an If's `cell`) 1,024; a shortcut 64. A
macro lists at most 256 permissions, each a command id, and an input at most 16,384 column
types. One-line fields hold no line breaks or other control characters, and a command id is
letters, digits, `.` and `_`. A command step's `params` are bounded only by the size of the
whole macro (4,194,304 characters of JSON). A macro read from a file that breaks any of these is
left out with a warning; it is never changed to fit. When a macro runs, a Repeat whose range has
more rows or columns than its `max` is refused, and a run executes at most 100,000 steps (each
pass through a Repeat counts) and takes at most 60 seconds, both shared by the footprint dry
runs and the run itself (`timeLimit` to change the time; the web build has no clock, so there
only the step limit applies).
## Tokens ## Tokens
@@ -118,13 +130,17 @@ approval needed and the footprint, without changing anything.
Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden
defined names (`_gc_macro_<n>_<k>`, the macro's JSON split into string literals within Excel's defined names (`_gc_macro_<n>_<k>`, the macro's JSON split into string literals within Excel's
limits), which other spreadsheet applications keep but do not run. limits), which other spreadsheet applications keep but do not run. A visible name of that shape
is someone's own name: opening a file leaves it alone, and saving stores the macros under indices
it does not use.
Each macro carries its format version, `v` (1 in this version; a missing `v` reads as 1). When a Each macro carries its format version, `v` (1 in this version; a missing `v` reads as 1). When a
file is opened, a macro that is damaged, over the limits or in a newer format (a `v` above 1) is file is opened, a macro in a newer format (a `v` above 1) is kept unread, with a warning: this
left out with one warning, and the rest of the workbook and its other macros open. A newer version can't show or run it, and saving writes it back unchanged, so the newer
macro is never read in part, so a save can't write it back with its newer fields missing; a version gets it back whole. It is never read in part. Kept macros count toward the 1,000-macro
macro that was left out is not in the saved file. limit after the readable ones, and one too large for an xlsx file is left out. A macro that is
damaged or over the limits is left out with one warning, and the rest of the workbook and its
other macros open; a macro that was left out is not in the saved file.
## Automate scripts and VBA ## Automate scripts and VBA
+1 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
publish = false publish = false
description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | version | stats | ico" description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | i18n-report | version | stats | ico"
[lints] [lints]
workspace = true workspace = true
+218
View File
@@ -0,0 +1,218 @@
//! `cargo xtask i18n-report`: the interface strings each language doesn't translate yet, read from
//! the catalogs in `crates/ui-egui/src/i18n/`. A string a catalog lacks shows in English, so the
//! report always succeeds; `cargo xtask ci` runs it first so the list shows in the gate log.
//!
//! The reference set is every key any catalog translates. A catalog is a source file with a
//! `fn translate`; its keys are the string literals that start a match arm (`"key" => …`) or open a
//! table row (`("key", …)`). With the `engine` feature it also lists function help rows keyed by
//! English the function catalog no longer has: those show the English help.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::Path;
/// Missing keys listed per language; the rest are counted.
const LIST_CAP: usize = 50;
pub fn run(root: &Path) -> Result<(), String> {
let dir = root.join("crates/ui-egui/src/i18n");
let mut catalogs = BTreeMap::new();
let mut help = BTreeMap::new();
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) => {
println!("i18n-report: can't read {}: {e}", dir.display());
return Ok(());
}
};
for entry in entries.flatten() {
let path = entry.path();
let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(source) = std::fs::read_to_string(&path) else { continue };
if let Some(language) = stem.strip_suffix("_functions") {
help.insert(language.to_owned(), catalog_keys(&source));
} else if source.contains("fn translate(") {
catalogs.insert(stem, catalog_keys(&source));
}
}
print!("{}", render(&catalogs));
print!("{}", function_help(&help));
Ok(())
}
/// Per language: how many reference keys it lacks, then the first [`LIST_CAP`] of them.
pub fn render(catalogs: &BTreeMap<String, BTreeSet<String>>) -> String {
let reference: BTreeSet<&String> = catalogs.values().flatten().collect();
let mut out =
format!("i18n-report: {} interface strings across {} catalogs; a string a catalog lacks shows in English\n", reference.len(), catalogs.len());
for (language, keys) in catalogs {
let missing: Vec<&&String> = reference.iter().filter(|key| !keys.contains(key.as_str())).collect();
let _ = writeln!(out, " {language}: {} missing", missing.len());
list(&mut out, missing.iter().map(|key| key.as_str()), missing.len());
}
out
}
fn list<'a>(out: &mut String, keys: impl Iterator<Item = &'a str>, count: usize) {
for key in keys.take(LIST_CAP) {
let _ = writeln!(out, " {key:?}");
}
if count > LIST_CAP {
let _ = writeln!(out, " … and {} more", count - LIST_CAP);
}
}
/// Function help rows whose English the function catalog no longer has (an English description
/// reworded, an argument renamed).
#[cfg(feature = "engine")]
fn function_help(help: &BTreeMap<String, BTreeSet<String>>) -> String {
let catalog = gridcraft_engine::cmd::formulas::function_list();
let mut known: BTreeSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
known.extend(catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(argument_names));
let mut out = String::new();
for (language, keys) in help {
let stale: Vec<&str> = keys.iter().map(String::as_str).filter(|key| !known.contains(key)).collect();
let _ = writeln!(out, " {language} function help: {} rows, {} keyed by English the function catalog no longer has", keys.len(), stale.len());
list(&mut out, stale.iter().copied(), stale.len());
}
out
}
#[cfg(not(feature = "engine"))]
fn function_help(_: &BTreeMap<String, BTreeSet<String>>) -> String {
String::new()
}
/// `SUM(number1, [number2], ...)` -> `number`, `number`: brackets, index and `...` dropped.
#[cfg(feature = "engine")]
fn argument_names(signature: &str) -> Vec<&str> {
let Some(inner) = signature.split_once('(').and_then(|(_, rest)| rest.strip_suffix(')')) else { return Vec::new() };
inner
.split(", ")
.map(|arg| {
let arg = arg.strip_prefix('[').unwrap_or(arg);
let arg = arg.strip_suffix(']').unwrap_or(arg);
arg.trim_end_matches(|c: char| c.is_ascii_digit())
})
.filter(|name| !name.is_empty() && *name != "...")
.collect()
}
/// The keys of one catalog source: string literals that start a match arm or open a table row.
/// Comments and the test module are skipped.
pub fn catalog_keys(source: &str) -> BTreeSet<String> {
let source = source.split("#[cfg(test)]").next().unwrap_or(source);
let mut keys = BTreeSet::new();
for (value, start, end) in literals(source) {
let before = source.get(..start).unwrap_or("").trim_end();
let after = source.get(end..).unwrap_or("").trim_start();
if after.starts_with("=>") || (before.ends_with('(') && after.starts_with(',')) {
keys.insert(value);
}
}
keys
}
/// Every string literal outside `//` comments: its decoded value, and the byte range from its
/// opening quote to just past its closing one.
fn literals(source: &str) -> Vec<(String, usize, usize)> {
let mut out = Vec::new();
let mut chars = source.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == '/' && source.get(i..).is_some_and(|rest| rest.starts_with("//")) {
for (_, c) in chars.by_ref() {
if c == '\n' {
break;
}
}
continue;
}
if c != '"' {
continue;
}
let mut value = String::new();
let mut end = source.len();
while let Some((j, c)) = chars.next() {
match c {
'"' => {
end = j + 1;
break;
}
'\\' => match chars.next().map(|(_, c)| c) {
Some('n') => value.push('\n'),
Some('t') => value.push('\t'),
Some('r') => value.push('\r'),
Some('0') => value.push('\0'),
Some(c @ ('\\' | '"' | '\'')) => value.push(c),
Some('u') => {
let mut hex = String::new();
for (_, c) in chars.by_ref() {
match c {
'}' => break,
'{' => {}
c => hex.push(c),
}
}
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
value.push(c);
}
}
// A line continuation skips the newline and the next line's indentation.
Some('\n') => {
while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
chars.next();
}
}
_ => {}
},
c => value.push(c),
}
}
out.push((value, i, end));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_match_arms_and_table_rows_and_lists_what_each_catalog_lacks() {
let matched = r#"
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Einfügen", // "Commented" => "x",
"Line\nbreak \"quoted\"" => {
"Zeile"
}
_ => return None,
})
}
#[cfg(test)]
mod tests { const T: &[(&str, &str)] = &[("Test only", "x")]; }
"#;
let table = r#"
pub(super) const CORE: &[(&str, &str)] = &[
("Paste", "貼り付け"),
(
"Long \u{2026}",
"長い",
),
];
pub(super) fn translate(text: &str) -> Option<&'static str> { None }
"#;
let de = catalog_keys(matched);
assert_eq!(de.iter().map(String::as_str).collect::<Vec<_>>(), ["Line\nbreak \"quoted\"", "Paste"]);
let ja = catalog_keys(table);
assert_eq!(ja.iter().map(String::as_str).collect::<Vec<_>>(), ["Long …", "Paste"]);
let report = render(&BTreeMap::from([("de".to_owned(), de), ("ja".to_owned(), ja)]));
assert!(report.starts_with("i18n-report: 3 interface strings across 2 catalogs"), "{report}");
assert!(report.contains(" de: 1 missing\n \"Long …\"\n"), "{report}");
assert!(report.contains(" ja: 1 missing\n \"Line\\nbreak \\\"quoted\\\"\"\n"), "{report}");
}
}
+8 -3
View File
@@ -6,6 +6,7 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
mod assets; mod assets;
mod i18n_report;
mod ico; mod ico;
mod keytips; mod keytips;
mod layers; mod layers;
@@ -21,11 +22,12 @@ const USAGE: &str = "\
usage: cargo xtask <command> usage: cargo xtask <command>
commands: commands:
ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check, ci i18n-report, fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm,
keytips --check (stops at the first failure; the parity check runs only if parity --check, keytips --check (stops at the first failure; the parity check runs
docs/parity-checklist.md exists) only if docs/parity-checklist.md exists)
test workspace tests (cargo nextest when installed, else cargo test), then doctests test workspace tests (cargo nextest when installed, else cargo test), then doctests
assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md
i18n-report list the interface strings each language doesn't translate yet (always passes)
layers enforce the crate dependency layering (AGENTS.md \"Layering\") layers enforce the crate dependency layering (AGENTS.md \"Layering\")
wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call
parity [--check] [--out PATH] parity [--check] [--out PATH]
@@ -44,6 +46,7 @@ fn main() -> ExitCode {
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) { let result = match args.first().map(String::as_str) {
Some("assets") => assets::run(&root()), Some("assets") => assets::run(&root()),
Some("i18n-report") => i18n_report::run(&root()),
Some("layers") => cmd_layers(), Some("layers") => cmd_layers(),
Some("wasm") => cmd_wasm(), Some("wasm") => cmd_wasm(),
Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("test") => cmd_test().and_then(|()| cmd_doctest()),
@@ -194,6 +197,8 @@ fn cmd_doctest() -> Result<(), String> {
fn cmd_ci() -> Result<(), String> { fn cmd_ci() -> Result<(), String> {
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>); type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
let steps: Vec<Step> = vec![ let steps: Vec<Step> = vec![
// Missing translations never fail the gate; the list goes first so every log shows it.
("i18n-report", Box::new(|| i18n_report::run(&root()))),
( (
"fmt", "fmt",
Box::new(|| { Box::new(|| {