mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 21:26:48 -06:00
Merge #2144 (9331b05f, with #2151, #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2145
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,7 +511,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
|
||||||
@@ -588,8 +592,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, ..)) => {
|
||||||
@@ -603,6 +614,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);
|
||||||
@@ -649,10 +661,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(¶ms) });
|
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) });
|
||||||
@@ -806,6 +831,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
|
||||||
@@ -876,6 +924,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)
|
||||||
@@ -904,17 +962,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`",
|
||||||
|
|||||||
@@ -53,7 +53,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));
|
||||||
@@ -106,34 +107,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,
|
||||||
}
|
}
|
||||||
@@ -157,7 +212,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,
|
||||||
}
|
}
|
||||||
@@ -185,9 +245,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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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, ¤t, &missing].into_iter().enumerate() {
|
for (i, m) in [&future, ¤t, &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"]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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) |
|
||||||
|
|||||||
@@ -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
@@ -40,16 +40,28 @@ The engine has no macro recorder or management commands in this version, and no
|
|||||||
`{"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
|
||||||
|
|
||||||
@@ -129,13 +141,17 @@ of the step that just ran, and the cells changed so far.
|
|||||||
|
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(|| {
|
||||||
|
|||||||
Reference in New Issue
Block a user