mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 16:30:50 -06:00
# Conflicts: # crates/engine/src/lib.rs # docs/macros.md
This commit is contained in:
@@ -652,6 +652,24 @@ fn filter_blank(v: &Value) -> bool {
|
||||
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.
|
||||
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
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]
|
||||
fn list_validation_without_formula1_is_an_error() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.remove_cell(at);
|
||||
}
|
||||
}
|
||||
super::data::grow_autofilter(sh, at);
|
||||
cx.touch(sheet, at);
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -350,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.remove_cell(at);
|
||||
}
|
||||
}
|
||||
super::data::grow_autofilter(sh, at);
|
||||
cx.touch(sheet, at);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
trust_scripts
|
||||
),
|
||||
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
|
||||
cmd!(
|
||||
noundo "automate.stopAction",
|
||||
"Stop Action",
|
||||
[],
|
||||
None,
|
||||
"{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began",
|
||||
has_doc,
|
||||
stop_action
|
||||
),
|
||||
// Cell controls, themes, borders, styles
|
||||
cmd!(
|
||||
"insert.checkbox",
|
||||
@@ -800,10 +809,9 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?.clone();
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust);
|
||||
copy.selection = d.selection.clone();
|
||||
copy.scripts_trusted = d.scripts_trusted;
|
||||
copy.trust = d.trust;
|
||||
let i = s.add_document(copy);
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
@@ -1088,6 +1096,18 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(name) => {
|
||||
let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
|
||||
let title = crate::sample::title(name);
|
||||
s.add_document(DocState::new(wb, None, title))
|
||||
s.add_document(DocState::new(wb, None, title, crate::Trust::Local))
|
||||
}
|
||||
None => s.new_workbook(),
|
||||
};
|
||||
@@ -83,12 +83,11 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
s.close_document(0);
|
||||
}
|
||||
let mut doc = DocState::new(wb, path, title);
|
||||
// Someone else may have made the file: its active content asks before it runs.
|
||||
let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile);
|
||||
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
|
||||
doc.content_kind = Some(crate::io::FileKind::Xlsx);
|
||||
}
|
||||
// Someone else may have made the file: its active content asks before it runs.
|
||||
doc.trust = crate::Trust::FromFile;
|
||||
let i = s.add_document(doc);
|
||||
// The decrypted-workbook notice is for the info bar, not a message box.
|
||||
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -32,18 +32,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Define Name…",
|
||||
["Formulas", "Defined Names"],
|
||||
None,
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
|
||||
has_doc,
|
||||
define_name
|
||||
),
|
||||
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
|
||||
cmd!(
|
||||
"formulas.deleteName",
|
||||
"Delete Name",
|
||||
[],
|
||||
None,
|
||||
"{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc_<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!(
|
||||
"formulas.createFromSelection",
|
||||
"Create from Selection",
|
||||
["Formulas", "Defined Names"],
|
||||
Some("Cmd+Shift+F3"),
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc_<key>_<n>` and `_sc_<key>`, are listed in `skipped` unless force is true)}",
|
||||
has_doc,
|
||||
create_from_selection
|
||||
),
|
||||
@@ -268,6 +276,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !valid_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() {
|
||||
refuse_stash_name(&s.doc()?.wb, p, n)?;
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let scope = match str_param(p, "scope") {
|
||||
None | Some("Workbook") => None,
|
||||
@@ -374,8 +385,29 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]):
|
||||
/// defining, renaming or deleting one would replace or remove part of an item (a stored macro,
|
||||
/// script or view) and leave the rest behind. `force: true` lets the call go ahead.
|
||||
/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope
|
||||
/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and
|
||||
/// the name commands may change it.
|
||||
fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool {
|
||||
// The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not.
|
||||
stash::holds_item(wb, name)
|
||||
}
|
||||
|
||||
fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> {
|
||||
if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
|
||||
refuse_stash_name(&s.doc()?.wb, p, &name)?;
|
||||
let scope = match str_param(p, "scope") {
|
||||
None => None,
|
||||
Some("Workbook") => Some(None),
|
||||
@@ -461,7 +493,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
|
||||
// reported.
|
||||
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
|
||||
// A label that names one of GridCraft's own hidden data names would replace part of an item
|
||||
// (see `refuse_stash_name`).
|
||||
let force = bool_param(p, "force").unwrap_or(false);
|
||||
let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n);
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n)));
|
||||
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
|
||||
let n = made.len();
|
||||
edit(s, |cx| {
|
||||
@@ -742,6 +778,57 @@ mod tests {
|
||||
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_commands_leave_gridcraft_data_alone() {
|
||||
// Defining, renaming or deleting a name of a stored item's shape would replace or remove
|
||||
// one of its chunks and leave the rest behind: refused unless `force` is true.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let text = "m".repeat(9_000);
|
||||
stash::set(wb, "macro_1", &text).unwrap();
|
||||
wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true });
|
||||
// A legacy item is read whether or not it is hidden, so a visible one is data too.
|
||||
wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false });
|
||||
let names = |s: &Session| s.doc().unwrap().wb.names.clone();
|
||||
let before = names(&s);
|
||||
assert!(before.len() >= 3, "the item takes two chunks");
|
||||
for params in [
|
||||
json!({"name": "_gc_macro_1_0", "refersTo": "=1"}),
|
||||
json!({"name": "_SC_Watch", "refersTo": "=1"}),
|
||||
json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}),
|
||||
json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}),
|
||||
json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}),
|
||||
] {
|
||||
let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string();
|
||||
assert!(err.contains("force"), "{params}: {err}");
|
||||
}
|
||||
for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] {
|
||||
let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string();
|
||||
assert!(err.contains("force"), "{name}: {err}");
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap();
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap();
|
||||
assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"])));
|
||||
let after = names(&s);
|
||||
assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::<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]
|
||||
fn arabic_defined_names_count_characters_not_bytes() {
|
||||
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
|
||||
/// several calls is still running).
|
||||
fn not_grouping(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first".into()) } else { Ok(()) }
|
||||
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
|
||||
}
|
||||
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
|
||||
+227
-10
@@ -179,6 +179,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
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!(
|
||||
"pivot.delete",
|
||||
"Delete PivotTable",
|
||||
@@ -654,16 +663,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, anchor) = match target {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
let at = insert_sheet_before_active(cx);
|
||||
new_sheet_at = Some(at);
|
||||
(at, CellRef::new(2, 0))
|
||||
}
|
||||
@@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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> {
|
||||
let d = s.doc()?;
|
||||
let name = match str_param(p, "table") {
|
||||
@@ -2236,6 +2334,7 @@ mod tests {
|
||||
"data.refreshAll",
|
||||
"pivot.changeSource",
|
||||
"pivot.delete",
|
||||
"pivot.showDetails",
|
||||
];
|
||||
let hostile = [
|
||||
json!({}),
|
||||
@@ -2363,4 +2462,122 @@ mod tests {
|
||||
assert_eq!(l[0]["anchor"], "A3");
|
||||
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 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.
|
||||
//!
|
||||
//! 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::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::{DefinedName, Workbook};
|
||||
use serde_json::Value as Json;
|
||||
@@ -25,23 +33,106 @@ const FORMULA_UNITS: usize = 8_192;
|
||||
const MAX_KEY: usize = 200;
|
||||
/// The longest item: 4,194,304 characters.
|
||||
pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024;
|
||||
/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character
|
||||
/// takes seven (the three-byte marker and four digits).
|
||||
const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7;
|
||||
/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter.
|
||||
pub const ESCAPE: char = '\u{FDD0}';
|
||||
/// The 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).
|
||||
pub fn get(wb: &Workbook, key: &str) -> Option<String> {
|
||||
let mut chunks: BTreeMap<u64, &str> = BTreeMap::new();
|
||||
let mut legacy = None;
|
||||
let mut parts = Parts::default();
|
||||
for n in wb.names.iter() {
|
||||
match item_of(n) {
|
||||
Some((k, Some(i))) if k.eq_ignore_ascii_case(key) => {
|
||||
chunks.entry(i).or_insert(&n.formula);
|
||||
}
|
||||
Some((k, None)) if k.eq_ignore_ascii_case(key) => legacy = legacy.or(Some(&n.formula)),
|
||||
_ => {}
|
||||
if let Some((k, chunk)) = item_of(n)
|
||||
&& k.eq_ignore_ascii_case(key)
|
||||
{
|
||||
parts.add(chunk, &n.formula);
|
||||
}
|
||||
}
|
||||
assemble(&parts)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
if !chunks.contains_key(&0) {
|
||||
out = string_literals(legacy?);
|
||||
out = string_literals(parts.legacy?);
|
||||
} else {
|
||||
// Chunks run 0, 1, 2, …; anything after a gap isn't part of the item.
|
||||
for (want, (i, f)) in chunks.iter().enumerate() {
|
||||
@@ -49,14 +140,47 @@ pub fn get(wb: &Workbook, key: &str) -> Option<String> {
|
||||
break;
|
||||
}
|
||||
out.push_str(&string_literals(f));
|
||||
if out.len() > MAX_ITEM_CHARS.saturating_mul(4) {
|
||||
if out.len() > MAX_STORED_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
out = unescape(&out);
|
||||
}
|
||||
(out.chars().count() <= MAX_ITEM_CHARS).then_some(out)
|
||||
}
|
||||
|
||||
/// Whether `c` is stored escaped: XML can't carry it, or it is the marker.
|
||||
fn needs_escape(c: char) -> bool {
|
||||
c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four
|
||||
/// hex digits naming a character (a hand-made name) stays as it is.
|
||||
fn unescape(s: &str) -> String {
|
||||
if !s.contains(ESCAPE) {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(at) = rest.find(ESCAPE) {
|
||||
out.push_str(rest.get(..at).unwrap_or(""));
|
||||
let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or("");
|
||||
let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
match code.and_then(char::from_u32) {
|
||||
Some(c) => {
|
||||
out.push(c);
|
||||
rest = after.get(4..).unwrap_or("");
|
||||
}
|
||||
None => {
|
||||
out.push(ESCAPE);
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
|
||||
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
|
||||
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)
|
||||
)));
|
||||
}
|
||||
keys.insert(key.to_ascii_lowercase());
|
||||
// Two items under one key (names compare without case) would leave two hidden names of
|
||||
// one name, of which an xlsx file keeps one: a caller's mistake, refused.
|
||||
if !keys.insert(key.to_ascii_lowercase()) {
|
||||
return Err(EngineError::Other(format!("`{key}` is given twice.")));
|
||||
}
|
||||
for (i, formula) in chunk_formulas(text).into_iter().enumerate() {
|
||||
planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
|
||||
}
|
||||
@@ -110,18 +238,21 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
|
||||
set(wb, key, &v.to_string())
|
||||
}
|
||||
|
||||
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted.
|
||||
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
|
||||
let mut keys: Vec<String> = wb
|
||||
.names
|
||||
.iter()
|
||||
// A later chunk on its own is no item, as `get` reads it.
|
||||
.filter_map(|n| item_of(n).filter(|(_, chunk)| chunk.is_none_or(|i| i == 0)).map(|(k, _)| k.to_ascii_lowercase()))
|
||||
.filter(|k| k.starts_with(&prefix.to_ascii_lowercase()))
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
keys
|
||||
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
|
||||
/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
|
||||
/// where a [`get`] per key would take one each.
|
||||
pub fn keys(wb: &Workbook, prefix: &str) -> Listing<Vec<String>> {
|
||||
let mut found: Vec<String> = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).collect();
|
||||
let more = found.len().saturating_sub(MAX_ITEMS);
|
||||
found.truncate(MAX_ITEMS);
|
||||
Listing { found, more }
|
||||
}
|
||||
|
||||
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
|
||||
/// `_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>`.
|
||||
@@ -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
|
||||
/// 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> {
|
||||
let mut formulas = Vec::new();
|
||||
let (mut formula, mut units) = (String::new(), 0usize);
|
||||
@@ -187,14 +319,22 @@ pub fn chunk_formulas(text: &str) -> Vec<String> {
|
||||
*lit_units = 0;
|
||||
};
|
||||
for ch in text.chars() {
|
||||
if lit_units + ch.len_utf16() > LITERAL_UNITS {
|
||||
// An escaped character is the marker and four digits, kept in one literal.
|
||||
let escaped = needs_escape(ch);
|
||||
let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() };
|
||||
if lit_units + cost > LITERAL_UNITS {
|
||||
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
|
||||
}
|
||||
lit_units += ch.len_utf16();
|
||||
if ch == '"' {
|
||||
literal.push('"');
|
||||
lit_units += cost;
|
||||
if escaped {
|
||||
literal.push(ESCAPE);
|
||||
let _ = write!(literal, "{:04X}", u32::from(ch));
|
||||
} else {
|
||||
if ch == '"' {
|
||||
literal.push('"');
|
||||
}
|
||||
literal.push(ch);
|
||||
}
|
||||
literal.push(ch);
|
||||
}
|
||||
if lit_units > 0 {
|
||||
flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units);
|
||||
@@ -310,10 +450,10 @@ mod tests {
|
||||
// Only a later chunk: no item, not listed, and the legacy name is still read.
|
||||
wb.names.push(name("_gc_orphan_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "orphan"), None);
|
||||
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
|
||||
assert_eq!(keys(&wb, "orphan").found, Vec::<String>::new());
|
||||
wb.names.push(name("_sc_orphan", "\"old\""));
|
||||
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
|
||||
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
|
||||
assert_eq!(keys(&wb, "orphan").found, ["orphan"]);
|
||||
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
|
||||
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
|
||||
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
|
||||
@@ -361,7 +501,7 @@ mod tests {
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
assert!(keys(&wb, "").found.is_empty());
|
||||
// Writing the item would put a hidden name of the same name beside it, and an xlsx file
|
||||
// keeps only one of them: refused, with the names as they were.
|
||||
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
|
||||
@@ -375,6 +515,18 @@ mod tests {
|
||||
assert_eq!(wb.names, vec![own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_many_refuses_one_key_twice() {
|
||||
// Two items under one key, in any case, would leave two hidden names of one name.
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_other_0", "\"stays\""));
|
||||
let err = set_many(&mut wb, &[("k".into(), "a".into()), ("K".into(), "b".into())]).unwrap_err().to_string();
|
||||
assert!(err.contains("given twice"), "{err}");
|
||||
assert_eq!(wb.names.len(), 1, "nothing changed");
|
||||
set_many(&mut wb, &[("k".into(), "a".into()), ("k2".into(), "b".into())]).unwrap();
|
||||
assert_eq!((get(&wb, "k").as_deref(), get(&wb, "K2").as_deref(), get(&wb, "other").as_deref()), (Some("a"), Some("b"), Some("stays")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_number_with_leading_zeros_is_another_name() {
|
||||
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
|
||||
@@ -382,7 +534,7 @@ mod tests {
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_watch_00", "\"theirs\""));
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
assert!(keys(&wb, "").found.is_empty());
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
set(&mut wb, "watch", "ours").unwrap();
|
||||
@@ -398,12 +550,74 @@ mod tests {
|
||||
set(&mut wb, "macro_2", "b").unwrap();
|
||||
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
|
||||
set(&mut wb, "scripts", "{}").unwrap();
|
||||
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]);
|
||||
assert_eq!(keys(&wb, "macro_").found, ["macro_1", "macro_2"]);
|
||||
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
|
||||
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
|
||||
assert_eq!(item_key("Total"), None);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn items_round_trip_through_xlsx() {
|
||||
let mut wb = Workbook::new();
|
||||
@@ -415,5 +629,46 @@ mod tests {
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"])));
|
||||
|
||||
// Characters XML can't carry, and the escape marker itself, come back too, in text and
|
||||
// in JSON (which leaves U+FFFE and U+FFFF unescaped).
|
||||
let mut wb = Workbook::new();
|
||||
let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect();
|
||||
let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect();
|
||||
set(&mut wb, "odd", &text).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"});
|
||||
set_json(&mut wb, "json", &json).unwrap();
|
||||
// All escapes: each literal holds whole escapes, within the limits.
|
||||
let controls = "\u{1}".repeat(9_000);
|
||||
set(&mut wb, "controls", &controls).unwrap();
|
||||
excel_limits_hold(&wb);
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str()));
|
||||
assert_eq!(get_json(&back, "json"), Some(json));
|
||||
assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() {
|
||||
// Excel's limits hold however many characters are escaped, and the longest item of
|
||||
// characters that all need escaping still reads.
|
||||
for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] {
|
||||
let text = "\u{2}".repeat(len);
|
||||
let fs = chunk_formulas(&text);
|
||||
assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::<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}"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user