mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-11 16:34:47 -06:00
Merge main into #780
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/insert.rs # crates/engine/src/tests.rs
This commit is contained in:
+159
-41
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn col_param(v: Option<&Json>, base: u32) -> Option<u32> {
|
||||
match v? {
|
||||
Json::String(s) => gridcraft_core::letters_to_col(s.trim_start_matches('$')),
|
||||
Json::Number(n) => n.as_u64().map(|o| base + o as u32),
|
||||
Json::Number(n) => n.as_u64().and_then(|o| u32::try_from(o).ok()).and_then(|o| base.checked_add(o)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -198,27 +198,29 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
|
||||
let by_cols = str_param(p, "orientation") == Some("columns");
|
||||
let case = bool_param(p, "matchCase").unwrap_or(false);
|
||||
let keys: Vec<SortKey> = p
|
||||
.get("keys")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|k| {
|
||||
let col = if by_cols {
|
||||
k.get("row").and_then(Json::as_u64).map(|r| r as u32 - 1).or_else(|| col_param(k.get("column"), r.start.row))?
|
||||
} else {
|
||||
col_param(k.get("column"), r.start.col)?
|
||||
};
|
||||
Some(SortKey {
|
||||
col,
|
||||
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
|
||||
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
|
||||
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut keys: Vec<SortKey> = Vec::new();
|
||||
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
|
||||
let col = if by_cols {
|
||||
let row = match k.get("row").and_then(Json::as_u64) {
|
||||
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
|
||||
Some(n) => {
|
||||
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
|
||||
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
row.or_else(|| col_param(k.get("column"), r.start.row))
|
||||
} else {
|
||||
col_param(k.get("column"), r.start.col)
|
||||
};
|
||||
let Some(col) = col else { continue };
|
||||
keys.push(SortKey {
|
||||
col,
|
||||
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
|
||||
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
|
||||
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
|
||||
});
|
||||
}
|
||||
if keys.is_empty() {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
|
||||
return ok();
|
||||
@@ -454,14 +456,24 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if has {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden.
|
||||
let filtered: Vec<u32> = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect();
|
||||
sh.autofilter = None;
|
||||
for info in sh.rows.values_mut() {
|
||||
info.hidden = false;
|
||||
for r in filtered {
|
||||
if let Some(info) = sh.rows.get_mut(&r) {
|
||||
info.hidden = false;
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(json!({"filter": false}))
|
||||
});
|
||||
}
|
||||
install_filter(s, p)
|
||||
}
|
||||
|
||||
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
|
||||
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let mut r = data_range(s, p)?;
|
||||
// A selected header row filters the data below it (#201).
|
||||
if p.get("range").is_none() && r.height() == 1 {
|
||||
@@ -484,11 +496,16 @@ fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let af = match &sh.autofilter {
|
||||
Some(a) => a.clone(),
|
||||
None => {
|
||||
toggle_filter(s, &json!({}))?;
|
||||
install_filter(s, &json!({}))?;
|
||||
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
|
||||
}
|
||||
};
|
||||
let col = col_param(p.get("column"), af.range.start.col).ok_or_else(|| bad("data.filterBy", "missing `column`"))?;
|
||||
let col = match col_param(p.get("column"), af.range.start.col) {
|
||||
Some(c) => c,
|
||||
// A number too large to be a column is outside the filter range, not a missing parameter.
|
||||
None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")),
|
||||
None => return Err(bad("data.filterBy", "missing `column`")),
|
||||
};
|
||||
if col < af.range.start.col || col > af.range.end.col {
|
||||
return Err(bad("data.filterBy", "column outside the filter range"));
|
||||
}
|
||||
@@ -570,19 +587,28 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(af) = sh.autofilter.clone() else { return };
|
||||
let r = af.range;
|
||||
let mut hide = Vec::new();
|
||||
// Precompute per-column numbers for top10/average.
|
||||
let mut col_stats: std::collections::HashMap<u32, (Vec<f64>, f64)> = std::collections::HashMap::new();
|
||||
for (off, c) in &af.criteria {
|
||||
// Precompute per-criterion top10 threshold and average once, not per row.
|
||||
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
|
||||
for (i, (off, c)) in af.criteria.iter().enumerate() {
|
||||
if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {
|
||||
let col = r.start.col + off;
|
||||
let nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
|
||||
let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
|
||||
let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };
|
||||
col_stats.insert(*off, (nums, avg));
|
||||
let thr = match c {
|
||||
FilterCriterion::Top10 { bottom, percent, count } => {
|
||||
nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
|
||||
let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
|
||||
let k = k.clamp(1, nums.len().max(1));
|
||||
nums.get(k - 1).copied()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
col_stats.insert(i, (thr, avg));
|
||||
}
|
||||
}
|
||||
for row in r.start.row + 1..=r.end.row {
|
||||
let mut visible = true;
|
||||
for (off, crit) in &af.criteria {
|
||||
for (i, (off, crit)) in af.criteria.iter().enumerate() {
|
||||
let c = CellRef::new(row, r.start.col + off);
|
||||
let v = sh.value(c);
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
@@ -604,18 +630,14 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
None => x,
|
||||
}
|
||||
}
|
||||
FilterCriterion::Top10 { bottom, percent, count } => match (v.as_f64(), col_stats.get(off)) {
|
||||
(Some(n), Some((nums, _))) => {
|
||||
let mut sorted = nums.clone();
|
||||
sorted.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
|
||||
let k = if *percent { ((sorted.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
|
||||
let k = k.clamp(1, sorted.len().max(1));
|
||||
let thr = sorted.get(k - 1).copied().unwrap_or(n);
|
||||
FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {
|
||||
(Some(n), Some((thr, _))) => {
|
||||
let thr = thr.unwrap_or(n);
|
||||
if *bottom { n <= thr } else { n >= thr }
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) {
|
||||
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {
|
||||
(Some(n), Some((_, avg))) => {
|
||||
if *above {
|
||||
n > *avg
|
||||
@@ -896,7 +918,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
}
|
||||
|
||||
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
|
||||
fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
pub(super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
for dv in validations.iter_mut() {
|
||||
for cut in cuts {
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
|
||||
@@ -1046,6 +1068,18 @@ fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
|
||||
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
|
||||
pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
|
||||
if first == to {
|
||||
return f.to_string();
|
||||
}
|
||||
let moved = formula_at(f, first, to);
|
||||
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
|
||||
let eq = if f.starts_with('=') { "=" } else { "" };
|
||||
if has_relative { format!("{eq}{moved}") } else { f.to_string() }
|
||||
}
|
||||
|
||||
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
|
||||
let Some(a) = a else { return false };
|
||||
match op {
|
||||
@@ -1129,6 +1163,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
{
|
||||
e.collapsed = false;
|
||||
}
|
||||
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -1278,6 +1313,38 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "B1:C3"})).unwrap();
|
||||
for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] {
|
||||
let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}");
|
||||
}
|
||||
let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap();
|
||||
assert_eq!(ok["hiddenRows"], json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
|
||||
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap();
|
||||
s.execute("selection.set", sel.clone()).unwrap();
|
||||
s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}");
|
||||
assert!(sh.tables[0].filter_button, "{sel}");
|
||||
assert!(!sh.is_row_hidden(1), "{sel}");
|
||||
assert!(sh.is_row_hidden(2), "{sel}");
|
||||
assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
|
||||
let mut s = Session::new();
|
||||
@@ -1300,6 +1367,45 @@ mod tests {
|
||||
assert!(!ok(&mut s, "B1", "150"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
|
||||
s.execute("data.group", json!({"rows": "2:4"})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
|
||||
let hidden = |s: &Session| -> Vec<u32> {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
|
||||
};
|
||||
let before = hidden(&s);
|
||||
assert_eq!(before, [1, 2, 3, 9]);
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none());
|
||||
assert_eq!(hidden(&s), before);
|
||||
assert_eq!(row_state(&s, 4..=4), [(false, true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "E1"})).unwrap();
|
||||
// The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice.
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::<Vec<_>>(), [2, 9]);
|
||||
}
|
||||
|
||||
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
|
||||
fn nested_rows() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -1375,6 +1481,18 @@ mod tests {
|
||||
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_sort_rejects_row_key_zero_without_panicking() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||||
for row in [0u64, 1 << 32] {
|
||||
let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]});
|
||||
let err = s.execute("data.sort", params).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split() {
|
||||
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
|
||||
|
||||
@@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.selection.active,
|
||||
};
|
||||
let input = str_param(p, "input")
|
||||
.or_else(|| str_param(p, "value"))
|
||||
.map(str::to_string)
|
||||
.or_else(|| p.get("value").map(json_to_input))
|
||||
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
|
||||
// Both spellings take a string, number or boolean, as typed (#697).
|
||||
let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
|
||||
let array = bool_param(p, "array").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
if let Some(sh) = d.wb.sheet(sheet)
|
||||
@@ -420,14 +417,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
let sel = &mut d.selection;
|
||||
// Extension moves the far corner, not the active cell.
|
||||
let from = if extend {
|
||||
let r = sel.current();
|
||||
let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row };
|
||||
let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col };
|
||||
CellRef::new(far_row, far_col)
|
||||
} else {
|
||||
sel.active
|
||||
};
|
||||
let from = if extend { sel.moving_end() } else { sel.active };
|
||||
// Skip hidden rows/columns and step over merged areas.
|
||||
let mut to = if jmp {
|
||||
jump(sheet, from, dr.signum(), dc.signum())
|
||||
@@ -1111,6 +1101,20 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The copied cell's validation rule replaces whatever rule the destination had.
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
|
||||
if let Some(mut dv) = rule {
|
||||
// Relative references move with the rule, as pasted formulas do.
|
||||
let first = dv.ranges.first().map_or(sc, |r| r.start);
|
||||
dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest);
|
||||
dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest));
|
||||
dv.ranges = vec![RangeRef::new(dest, dest)];
|
||||
sh.validations.push(dv);
|
||||
}
|
||||
}
|
||||
match what {
|
||||
What::Comments => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
@@ -1583,6 +1587,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cell_set_takes_numbers_for_input_and_value() {
|
||||
// #697: a JSON number for `input` used to clear the cell instead of entering it.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(123.5));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(77.0));
|
||||
assert_eq!(get(&mut s, "A3")["value"], json!(true));
|
||||
assert_eq!(get(&mut s, "A4")["value"], json!(200.5));
|
||||
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -701,6 +701,12 @@ fn stash_get(wb: &Workbook, key: &str) -> Json {
|
||||
.unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty.
|
||||
fn stash_map(wb: &Workbook, key: &str) -> Json {
|
||||
let v = stash_get(wb, key);
|
||||
if v.is_object() { v } else { json!({}) }
|
||||
}
|
||||
|
||||
fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
|
||||
let text = v.to_string();
|
||||
let formula = format!("\"{}\"", text.replace('"', "\"\""));
|
||||
@@ -723,7 +729,7 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
|
||||
|
||||
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut views = stash_get(&d.wb, VIEWS_NAME);
|
||||
let mut views = stash_map(&d.wb, VIEWS_NAME);
|
||||
if let Some(name) = str_param(p, "save") {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
|
||||
@@ -743,6 +749,17 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(z) = v["zoom"].as_u64() {
|
||||
s.execute("view.zoom", json!({"percent": z}))?;
|
||||
}
|
||||
if let Some(f) = v.get("freeze") {
|
||||
let freeze = match (f[0].as_u64(), f[1].as_u64()) {
|
||||
(Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()),
|
||||
_ => None,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
cx.sheet_mut(si)?.freeze = freeze;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
} else if let Some(name) = str_param(p, "delete") {
|
||||
if let Some(o) = views.as_object_mut() {
|
||||
o.remove(name);
|
||||
@@ -812,7 +829,7 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = cx.sheet_mut(si)?;
|
||||
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
|
||||
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
|
||||
if r.start.row != t.range.start.row {
|
||||
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
|
||||
return Err(EngineError::Other(
|
||||
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
|
||||
));
|
||||
@@ -938,7 +955,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad("automate.newScript", "`commands` must be an array"));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
|
||||
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
|
||||
all[name.as_str()] = cmds.clone();
|
||||
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
|
||||
Ok(json!({"name": name}))
|
||||
@@ -977,7 +994,7 @@ fn run_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();
|
||||
edit(s, |cx| {
|
||||
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
|
||||
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
|
||||
if let Some(o) = all.as_object_mut() {
|
||||
o.remove(&name);
|
||||
}
|
||||
@@ -1052,8 +1069,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null).
|
||||
fn object_params(p: &Json) -> Json {
|
||||
Json::Object(p.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn theme_colors(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut params = p.clone();
|
||||
let mut params = object_params(p);
|
||||
if params.get("name").is_none() {
|
||||
params["name"] = json!(s.doc()?.wb.theme.name.clone());
|
||||
}
|
||||
@@ -1074,7 +1096,7 @@ fn background(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Null)
|
||||
});
|
||||
}
|
||||
let mut params = p.clone();
|
||||
let mut params = object_params(p);
|
||||
params["alt"] = json!("Sheet background");
|
||||
params["at"] = json!("A1");
|
||||
s.execute("insert.picture", params)
|
||||
@@ -1090,7 +1112,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"outline" => "outside",
|
||||
_ => "all",
|
||||
};
|
||||
let mut params = p.clone();
|
||||
let mut params = object_params(p);
|
||||
params["preset"] = json!(preset);
|
||||
s.execute("home.borders", params)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return ok();
|
||||
};
|
||||
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
|
||||
let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
|
||||
let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
|
||||
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
|
||||
let imported =
|
||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||
@@ -75,7 +76,11 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
s.close_document(0);
|
||||
}
|
||||
let i = s.add_document(DocState::new(wb, path, title));
|
||||
let mut doc = DocState::new(wb, path, title);
|
||||
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);
|
||||
}
|
||||
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();
|
||||
if !shown.is_empty() {
|
||||
@@ -105,11 +110,18 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return super::print::export_pdf(s, &json!({"path": path}));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let bytes = crate::io::save_bytes(&d.wb, &path)?;
|
||||
// The document's own file keeps the format its content had when opened; any other path
|
||||
// goes by its extension. Relative paths to linked workbooks are rewritten for the new folder.
|
||||
let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
|
||||
let rebased = crate::io::source_dir(&path).and_then(|f| gridcraft_xlsx::rebase_links(&d.wb, &f));
|
||||
let wb = rebased.as_ref().unwrap_or(&d.wb);
|
||||
let bytes = match kind {
|
||||
Some(kind) => crate::io::save_bytes_as(wb, &path, kind)?,
|
||||
None => crate::io::save_bytes(wb, &path)?,
|
||||
};
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx);
|
||||
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
|
||||
if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) {
|
||||
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
|
||||
d.saved = d.wb.clone();
|
||||
d.path = Some(path.clone());
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Forecast Sheet",
|
||||
["Data", "Forecast"],
|
||||
None,
|
||||
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
|
||||
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
|
||||
has_doc,
|
||||
forecast_sheet
|
||||
)]
|
||||
@@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
|
||||
return Err(EngineError::Other("There are no values to forecast.".into()));
|
||||
}
|
||||
let end = match p.get("end") {
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
|
||||
Value::Number(end) => Some(end),
|
||||
_ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))),
|
||||
},
|
||||
_ => None,
|
||||
let time_param = |key: &str| -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
Some(Json::Number(n)) => Ok(n.as_f64()),
|
||||
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
|
||||
Value::Number(x) => Ok(Some(x)),
|
||||
_ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))),
|
||||
},
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(_)) => Ok(None),
|
||||
Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))),
|
||||
}
|
||||
};
|
||||
let end = time_param("end")?;
|
||||
// Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the
|
||||
// usual forecast after the history.
|
||||
let fit = match time_param("forecastStart")? {
|
||||
None => rows.len(),
|
||||
Some(start) => {
|
||||
let last_t = times.last().copied().unwrap_or(f64::NAN);
|
||||
let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
|
||||
if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) {
|
||||
rows.len()
|
||||
} else {
|
||||
let i = times
|
||||
.iter()
|
||||
.position(|t| tol(*t, start))
|
||||
.ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?;
|
||||
if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) {
|
||||
return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into()));
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
};
|
||||
let cap = max_periods(rows.len());
|
||||
let periods = match end {
|
||||
@@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Columns: A timeline, B values, C forecast, D/E bounds.
|
||||
let n = rows.len() as u32;
|
||||
let last = n + 1; // 1-based row of the last history point
|
||||
let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to
|
||||
let end_row = last + periods; // 1-based row of the last forecast
|
||||
let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col));
|
||||
let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col));
|
||||
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
|
||||
if let Some(a) = opts.aggregate {
|
||||
tail.push_str(&format!(",{a}"));
|
||||
@@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
put(&mut sheet, r, 1, v.clone(), vstyle);
|
||||
}
|
||||
}
|
||||
// The last history point starts the forecast lines, so they join the values.
|
||||
formula(&mut sheet, n, 2, format!("=B{last}"), vstyle);
|
||||
// The last point the model is fit to starts the forecast lines, so they join the values.
|
||||
let join = fit_last - 1; // 0-based row
|
||||
formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle);
|
||||
if opts.confidence.is_some() {
|
||||
formula(&mut sheet, n, 3, format!("=C{last}"), vstyle);
|
||||
formula(&mut sheet, n, 4, format!("=C{last}"), vstyle);
|
||||
formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle);
|
||||
formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle);
|
||||
}
|
||||
let (tl, vl) = (hist(0), hist(1));
|
||||
let forecast_row = |sheet: &mut Sheet, r: u32| {
|
||||
formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
|
||||
if let Some(c) = opts.confidence {
|
||||
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
|
||||
formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
|
||||
formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
|
||||
}
|
||||
};
|
||||
// Hindcast: forecasts over the actuals from the start to the last history point.
|
||||
for r in (fit_last + 1)..=last {
|
||||
forecast_row(&mut sheet, r);
|
||||
}
|
||||
for (k, t) in future.iter().enumerate() {
|
||||
let r = last + k as u32 + 1; // 1-based
|
||||
put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
|
||||
formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
|
||||
if let Some(c) = opts.confidence {
|
||||
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
|
||||
formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
|
||||
formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
|
||||
}
|
||||
forecast_row(&mut sheet, r);
|
||||
}
|
||||
if opts.statistics {
|
||||
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
|
||||
@@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Ok(d) = s.doc_mut() {
|
||||
d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods}))
|
||||
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit}))
|
||||
}
|
||||
|
||||
/// Forecast rows needed to reach `end` (rounded up to whole steps).
|
||||
@@ -545,4 +580,39 @@ mod tests {
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_start_hindcasts_over_the_actuals() {
|
||||
let mut s = session_with_history(); // t = 1..16 in A2:A17
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap();
|
||||
assert_eq!(r["fitted"], 12);
|
||||
// Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19.
|
||||
assert_eq!(formula(&s, 0, "C13"), "B13");
|
||||
assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13"));
|
||||
assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)");
|
||||
assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)");
|
||||
// The exact seasonal series is recovered, and the actual is still shown next to it.
|
||||
let c14 = value(&s, 0, "C14").as_f64().unwrap();
|
||||
assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3
|
||||
assert!(value(&s, 0, "B14").as_f64().is_some());
|
||||
// Start at the last point or one step later: as without forecastStart.
|
||||
for start in [json!(16), json!(17)] {
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap();
|
||||
assert_eq!(r["fitted"], 16);
|
||||
}
|
||||
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_start_invalid_values_are_errors() {
|
||||
let mut s = session_with_history();
|
||||
let sheets = s.doc().unwrap().wb.sheets.len();
|
||||
for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] {
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
|
||||
// Blank or null behaves like no start; empty params unchanged.
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap();
|
||||
assert_eq!(r["fitted"], 16);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +574,16 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let m = RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col));
|
||||
if !m.is_single() {
|
||||
sh.merges.push(m);
|
||||
// As below: only the first value of each row survives.
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -953,6 +963,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
e.custom = true;
|
||||
}
|
||||
}
|
||||
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -992,9 +1003,7 @@ pub fn chars_to_points(c: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn points_to_chars(px: f64) -> f64 {
|
||||
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
|
||||
}
|
||||
pub use gridcraft_model::points_to_chars;
|
||||
|
||||
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
|
||||
/// widths for the default font; the UI refines with real measurement when it autofits).
|
||||
@@ -1069,6 +1078,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
|
||||
}
|
||||
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -1112,6 +1122,19 @@ fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_across_keeps_the_first_value_of_each_row() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(A1:B2)"})).unwrap();
|
||||
s.execute("home.mergeAcross", json!({"range": "A1:B2"})).unwrap();
|
||||
let v = |s: &mut crate::Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
|
||||
assert_eq!(v(&mut s, "B1"), Json::Null);
|
||||
assert_eq!(v(&mut s, "B2"), Json::Null);
|
||||
assert_eq!(v(&mut s, "D1"), json!(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_steps() {
|
||||
let v = gridcraft_core::Value::Number(1.5);
|
||||
|
||||
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Link",
|
||||
["Insert", "Links"],
|
||||
Some("Cmd+K"),
|
||||
"{cell?, target: \"https://…\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
|
||||
"{cell?, target: \"https://…\" | \"Book.xlsx\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
|
||||
has_doc,
|
||||
insert_link
|
||||
),
|
||||
@@ -249,7 +249,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
|
||||
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
|
||||
if !valid_table_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
||||
@@ -358,6 +358,9 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
"totals" => {
|
||||
let v = on.unwrap_or(!t.totals_row);
|
||||
if v != t.totals_row {
|
||||
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
|
||||
}
|
||||
t.totals_row = v;
|
||||
if v {
|
||||
t.range.end.row += 1;
|
||||
@@ -457,14 +460,19 @@ fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
|
||||
/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a
|
||||
/// structured reference, and the writer would rename the table on save.
|
||||
fn valid_table_name(name: &str) -> bool {
|
||||
name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
|
||||
&& CellRef::parse(name).is_none()
|
||||
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
|
||||
}
|
||||
|
||||
fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ti) = find_table(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
|
||||
if name.is_empty()
|
||||
|| name.contains(' ')
|
||||
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
|| gridcraft_core::CellRef::parse(&name).is_some()
|
||||
{
|
||||
if !valid_table_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) {
|
||||
@@ -479,6 +487,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
|
||||
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
|
||||
let shadowed = cx.wb.name(&old, i).is_some();
|
||||
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
|
||||
for (c, f) in keys {
|
||||
let Some(e) = f.expr() else { continue };
|
||||
@@ -487,7 +497,7 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
st.table = name.clone();
|
||||
gridcraft_formula::Expr::Struct(st)
|
||||
}
|
||||
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
|
||||
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
|
||||
o => o,
|
||||
});
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
@@ -1165,6 +1175,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let text = str_param(p, "text").map(str::to_string);
|
||||
let tooltip = str_param(p, "tooltip").map(str::to_string);
|
||||
let external = links_outside(&s.doc()?.wb, sheet, &target);
|
||||
edit(s, |cx| {
|
||||
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
|
||||
st.font.color = Color::Theme(10, 0);
|
||||
@@ -1176,7 +1187,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
sh.set_style(at, link_style);
|
||||
}
|
||||
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone() });
|
||||
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
|
||||
cx.touch(sheet, at);
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -1207,6 +1218,24 @@ fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
|
||||
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
|
||||
/// Page") is outside the workbook and saved as a relationship.
|
||||
fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
|
||||
let t = target.trim();
|
||||
if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
|
||||
return false;
|
||||
}
|
||||
let (on, body) = split_sheet(t);
|
||||
// Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
|
||||
// and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
|
||||
let on_sheet = on.is_none_or(|name| {
|
||||
wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
|
||||
|| (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
|
||||
});
|
||||
!(on_sheet && RangeRef::parse(body).is_some())
|
||||
}
|
||||
|
||||
fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
@@ -1454,20 +1483,20 @@ fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let n = sh.cond_formats.len();
|
||||
// The count is taken again after each step: a delete shortens the list.
|
||||
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
|
||||
&& i < n
|
||||
&& i < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.remove(i);
|
||||
}
|
||||
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
|
||||
&& i > 0
|
||||
&& i < n
|
||||
&& i < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.swap(i, i - 1);
|
||||
}
|
||||
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
|
||||
&& i + 1 < n
|
||||
&& i + 1 < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.swap(i, i + 1);
|
||||
}
|
||||
@@ -1500,3 +1529,23 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"shape": id}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manage_rules_delete_then_move() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for v in ["1", "2"] {
|
||||
let rule = json!({"type": "cellIs", "operator": "greaterThan", "value": v, "preset": "lightRedFill"});
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
|
||||
}
|
||||
// Deleting first left two rules' worth of indices for the move: it panicked.
|
||||
s.execute("home.manageRules", json!({"delete": 0, "moveUp": 1})).unwrap();
|
||||
s.execute("home.manageRules", json!({"delete": 0, "moveDown": 0})).unwrap();
|
||||
let rules = s.execute("home.manageRules", json!({})).unwrap();
|
||||
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
+290
-21
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Field",
|
||||
[],
|
||||
None,
|
||||
"{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
|
||||
"{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
|
||||
has_doc,
|
||||
move_field
|
||||
),
|
||||
@@ -333,6 +333,71 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool {
|
||||
pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field))
|
||||
}
|
||||
|
||||
/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more).
|
||||
const VALUES_FIELD: &str = "Σ Values";
|
||||
|
||||
/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name.
|
||||
fn is_values_field(src: &pv::Source, field: &str) -> bool {
|
||||
let f = field.trim();
|
||||
f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none())
|
||||
}
|
||||
|
||||
/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of
|
||||
/// Rows (`rows`) or Columns.
|
||||
fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) {
|
||||
if pt.values_on_rows != rows {
|
||||
return;
|
||||
}
|
||||
let Some(p) = pt.values_position else { return };
|
||||
// Σ Values stays just ahead of the field it preceded.
|
||||
let p = match inserted {
|
||||
true if i <= p => p + 1,
|
||||
false if i < p => p - 1,
|
||||
_ => p,
|
||||
};
|
||||
let n = if rows { pt.rows.len() } else { pt.columns.len() };
|
||||
pt.values_position = (p < n).then_some(p);
|
||||
}
|
||||
|
||||
/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry.
|
||||
fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option<PivotField> {
|
||||
let mut first = None;
|
||||
for rows in [true, false] {
|
||||
loop {
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break };
|
||||
let f = list.remove(i);
|
||||
shift_values(pt, rows, i, false);
|
||||
first.get_or_insert(f);
|
||||
}
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end).
|
||||
fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option<usize>) {
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let at = position.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(at, f);
|
||||
shift_values(pt, rows, at, true);
|
||||
}
|
||||
|
||||
/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end).
|
||||
/// In Columns it always comes after the last column field: the column layout doesn't place it
|
||||
/// anywhere else yet.
|
||||
fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option<usize>) -> Result<Json> {
|
||||
let current = if pt.values_on_rows { "rows" } else { "columns" };
|
||||
if from != current {
|
||||
return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area")));
|
||||
}
|
||||
if !matches!(to, "rows" | "columns") {
|
||||
return Err(other("The field you are moving cannot be placed in that PivotTable area."));
|
||||
}
|
||||
pt.values_on_rows = to == "rows";
|
||||
pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len());
|
||||
Ok(json!({"field": VALUES_FIELD, "to": to}))
|
||||
}
|
||||
|
||||
/// The canonical source field name for `field`.
|
||||
fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
|
||||
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
|
||||
@@ -384,21 +449,11 @@ fn add_to_area(
|
||||
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
|
||||
return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS)));
|
||||
}
|
||||
let existing = pt
|
||||
.rows
|
||||
.iter()
|
||||
.chain(pt.columns.iter())
|
||||
.find(|f| f.source_col.eq_ignore_ascii_case(&field))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
|
||||
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
|
||||
// A report filter that selects nothing in particular goes away; a selection stays
|
||||
// as the field's hidden items.
|
||||
pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none()));
|
||||
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
|
||||
let at = position.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(at, existing);
|
||||
insert_axis_field(pt, area == "rows", existing, position);
|
||||
}
|
||||
"values" => {
|
||||
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
|
||||
@@ -421,8 +476,7 @@ fn add_to_area(
|
||||
pt.values.insert(at, v);
|
||||
}
|
||||
_ => {
|
||||
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
take_from_axes(pt, &field);
|
||||
let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
|
||||
let entry = match existing {
|
||||
Some(i) => pt.filters.remove(i),
|
||||
@@ -439,12 +493,14 @@ fn add_to_area(
|
||||
fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result<String> {
|
||||
match area {
|
||||
"rows" | "columns" => {
|
||||
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
|
||||
let rows = area == "rows";
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let i = list
|
||||
.iter()
|
||||
.position(|f| f.source_col.eq_ignore_ascii_case(field))
|
||||
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?;
|
||||
let f = list.remove(i);
|
||||
shift_values(pt, rows, i, false);
|
||||
pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col));
|
||||
Ok(f.source_col)
|
||||
}
|
||||
@@ -699,6 +755,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
||||
"style": pt.style,
|
||||
"showHeaders": pt.show_headers,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
"valuesOnRows": pt.values_on_rows,
|
||||
"valuesPosition": pt.values_position,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -890,18 +948,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let to = area_param(C, str_param(p, "to"))?;
|
||||
let pos = position_param(p);
|
||||
update(s, p, |pt, src, _| {
|
||||
if is_values_field(src, &field) {
|
||||
return move_values_field(C, pt, from, to, pos);
|
||||
}
|
||||
if from == to {
|
||||
// Reorder within the area.
|
||||
match from {
|
||||
"rows" | "columns" => {
|
||||
let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns };
|
||||
let rows = from == "rows";
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let i = list
|
||||
.iter()
|
||||
.position(|f| f.source_col.eq_ignore_ascii_case(&field))
|
||||
.ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?;
|
||||
let f = list.remove(i);
|
||||
let at = pos.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(at, f);
|
||||
shift_values(pt, rows, i, false);
|
||||
insert_axis_field(pt, rows, f, pos);
|
||||
}
|
||||
"values" => {
|
||||
let i = pt
|
||||
@@ -1270,8 +1332,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
ok
|
||||
};
|
||||
pt.rows.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
pt.columns.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
// Axis fields leave one by one so Σ Values keeps its place among the rest.
|
||||
for rows in [true, false] {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let Some(f) = list.get(i) else { break };
|
||||
if keep(&f.source_col, &mut dropped) {
|
||||
i += 1;
|
||||
} else {
|
||||
list.remove(i);
|
||||
shift_values(pt, rows, i, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
pt.values.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
pt.filters.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
Ok(json!({"source": source, "dropped": dropped}))
|
||||
@@ -1722,6 +1796,167 @@ mod tests {
|
||||
assert_eq!(text(&s, "A5"), "Apples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_on_rows_layouts() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap();
|
||||
assert_eq!(text(&s, "C3"), "Sum of Units");
|
||||
// Σ Values after Region: each region holds one line per value field.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert!(p.values_on_rows);
|
||||
assert_eq!(p.values_position, None);
|
||||
let r = p.last_range.unwrap();
|
||||
assert_eq!(r.width(), 2);
|
||||
// header + 3 × (region + 2 values) + 2 grand totals
|
||||
assert_eq!(r.height(), 1 + 9 + 2);
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(val(&s, "B4"), Value::Empty);
|
||||
assert_eq!(text(&s, "A5"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A6"), "Sum of Units");
|
||||
assert_eq!(val(&s, "B6"), Value::Number(33.0));
|
||||
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
|
||||
assert_eq!(text(&s, "A14"), "Total Sum of Units");
|
||||
assert_eq!(val(&s, "B14"), Value::Number(72.0));
|
||||
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(&s, "A3"), "Region");
|
||||
assert_eq!(text(&s, "B3"), "Values");
|
||||
assert_eq!(text(&s, "C3"), "Total");
|
||||
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
|
||||
// Σ Values before Region: the value fields are the outer items.
|
||||
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(0));
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B4"), Value::Empty);
|
||||
assert_eq!(text(&s, "A5"), "East");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A8"), "Sum of Units");
|
||||
assert_eq!(text(&s, "A9"), "East");
|
||||
assert_eq!(val(&s, "B9"), Value::Number(33.0));
|
||||
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(72.0));
|
||||
// Adding a row field in front keeps Σ Values ahead of Region.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(1));
|
||||
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(0));
|
||||
// No row field: one line per value field and no grand total.
|
||||
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
|
||||
let r = pt(&s).last_range.unwrap();
|
||||
assert_eq!(r.height(), 1 + 2);
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
|
||||
assert_eq!(text(&s, "A5"), "Sum of Units");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(72.0));
|
||||
// Back to Columns.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert!(!pt(&s).values_on_rows);
|
||||
assert_eq!(text(&s, "A3"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "B3"), "Sum of Units");
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_item_puts_values_in_the_values_column() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
|
||||
s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap();
|
||||
for layout in ["tabular", "outline"] {
|
||||
s.execute("pivot.layout", json!({"layout": layout})).unwrap();
|
||||
assert_eq!(text(&s, "C3"), "Values", "{layout}");
|
||||
let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap();
|
||||
let first = if layout == "tabular" { east } else { east + 1 };
|
||||
assert_eq!(text(&s, &format!("B{first}")), "", "{layout}");
|
||||
assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}");
|
||||
assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}");
|
||||
assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_stay_last_in_columns() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert!(!p.values_on_rows);
|
||||
assert_eq!(p.values_position, None);
|
||||
// Σ Values to Rows and back with a position: still last in Columns.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, None);
|
||||
assert_eq!(text(&s, "B5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C5"), "Sum of Units");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_source_keeps_values_ahead_of_its_field() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
|
||||
// The new source has no Region: Σ Values stays ahead of Product.
|
||||
s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert_eq!(p.rows.len(), 1);
|
||||
assert_eq!(p.values_position, Some(0));
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "A5"), "Apples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_on_rows_between_row_fields() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
|
||||
// Compact: East, then per value field its products, then per-value subtotals of East.
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(text(&s, "A5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "A6"), "Apples");
|
||||
assert_eq!(val(&s, "B6"), Value::Number(160.0));
|
||||
assert_eq!(text(&s, "A9"), "Sum of Units");
|
||||
assert_eq!(text(&s, "A10"), "Apples");
|
||||
assert_eq!(val(&s, "B10"), Value::Number(16.0));
|
||||
assert_eq!(text(&s, "A13"), "East Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A14"), "East Sum of Units");
|
||||
assert_eq!(val(&s, "B14"), Value::Number(33.0));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
|
||||
// Outline: one column per level, "Values" heads the Σ Values column.
|
||||
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
|
||||
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
|
||||
assert_eq!(text(&s, "A3"), "Region");
|
||||
assert_eq!(text(&s, "B3"), "Values");
|
||||
assert_eq!(text(&s, "C3"), "Product");
|
||||
assert_eq!(text(&s, "B5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C6"), "Apples");
|
||||
assert_eq!(val(&s, "D6"), Value::Number(160.0));
|
||||
// Tabular: labels start on their parent's row; totals per value field below each group.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(text(&s, "B4"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C4"), "Apples");
|
||||
assert_eq!(val(&s, "D4"), Value::Number(160.0));
|
||||
assert_eq!(text(&s, "B7"), "Sum of Units");
|
||||
assert_eq!(text(&s, "C7"), "Apples");
|
||||
assert_eq!(val(&s, "D7"), Value::Number(16.0));
|
||||
assert_eq!(text(&s, "A10"), "East Sum of Sales");
|
||||
assert_eq!(val(&s, "D10"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A11"), "East Sum of Units");
|
||||
let r = pt(&s).last_range.unwrap();
|
||||
let last = r.end.row + 1;
|
||||
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
|
||||
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
|
||||
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_after_source_edits() {
|
||||
let mut s = sales();
|
||||
@@ -2039,4 +2274,38 @@ mod tests {
|
||||
assert!(e.contains("field name is not valid"), "{e}");
|
||||
assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_value_columns_are_refused() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut records: Vec<Json> = vec![json!(["Cat", "Sub", "V"])];
|
||||
for r in 0..64 {
|
||||
for c in 0..64 {
|
||||
records.push(json!([format!("c{r}"), format!("s{c}"), 1.0]));
|
||||
}
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
|
||||
let req = |n: usize| {
|
||||
let values: Vec<Json> = (0..n).map(|_| json!({"field": "V"})).collect();
|
||||
json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values})
|
||||
};
|
||||
let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string();
|
||||
assert!(e.contains("will not fit"), "{e}");
|
||||
assert!(s.execute("insert.pivotTable", req(128)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_column_items_count_as_one_column_group() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut records: Vec<Json> = vec![json!(["Grp", "Sub", "V"])];
|
||||
records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0])));
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
|
||||
let values: Vec<Json> = (0..256).map(|_| json!({"field": "V"})).collect();
|
||||
s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap();
|
||||
s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap();
|
||||
// 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit.
|
||||
s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ pub struct HfContext {
|
||||
|
||||
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
|
||||
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
|
||||
let now = gridcraft_calc::now_serial();
|
||||
let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial());
|
||||
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
|
||||
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
|
||||
(date, time)
|
||||
@@ -1027,6 +1027,21 @@ fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
|
||||
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
|
||||
}
|
||||
|
||||
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
|
||||
fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
|
||||
let mut ext = 0.0;
|
||||
let mut col = c.col + 1;
|
||||
while col < MAX_COLS && ext < 20_000.0 {
|
||||
let nc = CellRef::new(c.row, col);
|
||||
if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross {
|
||||
break;
|
||||
}
|
||||
ext += sh.col_width(col);
|
||||
col += 1;
|
||||
}
|
||||
ext
|
||||
}
|
||||
|
||||
fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f32, f32), merged: bool, merges: &[RangeRef], limits: (f32, f32)) {
|
||||
let (x, y, w, h) = rect;
|
||||
let sh = b.sh;
|
||||
@@ -1110,8 +1125,11 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h,
|
||||
};
|
||||
// Overflow into empty neighbours (single-line text, not merged).
|
||||
let (mut clip_l, mut clip_r) = (x, x + w);
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w {
|
||||
// Center Across Selection spans the empty Center Across cells to its right; the text stays within that run.
|
||||
let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0;
|
||||
let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 };
|
||||
let (mut clip_l, mut clip_r) = (x, x + w + across);
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
|
||||
let need = tw + 2.0 * pad + indent - w;
|
||||
let grow = |dir: i64, mut need: f32| -> f32 {
|
||||
let mut ext = 0.0;
|
||||
@@ -1183,7 +1201,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
let lw = gridcraft_pdf::text_width(font, size, line);
|
||||
let lx = match h_align {
|
||||
HAlign::Right => x + w - pad - lw - indent,
|
||||
HAlign::Center => x + (w - lw) / 2.0,
|
||||
HAlign::Center => x + (w + across - lw) / 2.0,
|
||||
_ => x + pad + indent,
|
||||
};
|
||||
let ly = first_baseline + i as f32 * line_h;
|
||||
@@ -1765,6 +1783,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_date_follows_the_workbook_date_system() {
|
||||
let mut wb = Workbook::new();
|
||||
let date_1900 = hf_date_time(&wb).0;
|
||||
wb.date_system = gridcraft_core::DateSystem::D1904;
|
||||
assert_eq!(hf_date_time(&wb).0, date_1900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() {
|
||||
let mut s = s();
|
||||
@@ -2046,6 +2072,23 @@ mod tests {
|
||||
assert!(s.execute("file.exportPdf", json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() {
|
||||
let mut s = s();
|
||||
for params in [json!({}), json!({"path": ""}), json!({"path": null})] {
|
||||
let r = s.execute("file.exportPdf", params).unwrap();
|
||||
assert!(r.get("path").is_none(), "no file is written without a path");
|
||||
let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
}
|
||||
assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog");
|
||||
// Headless callers (CLI, MCP) get the same bytes, not an error.
|
||||
assert!(s.execute_headless("file.exportPdf", json!({})).is_ok());
|
||||
// With no workbook open it is a clear error, not a panic.
|
||||
let mut none = Session::new();
|
||||
assert!(none.execute("file.exportPdf", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_unit() {
|
||||
let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100);
|
||||
@@ -2063,6 +2106,23 @@ mod tests {
|
||||
assert!(w.len() > 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_across_spans_following_empty_center_across_cells() {
|
||||
let mut styles = StyleTable::default();
|
||||
let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() });
|
||||
let mut sh = Sheet::new("S");
|
||||
for col in 0..3 {
|
||||
sh.set_style(CellRef::new(0, col), across);
|
||||
}
|
||||
sh.set_value(CellRef::new(0, 0), Value::Text("Title".into()));
|
||||
let a1 = CellRef::new(0, 0);
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2));
|
||||
sh.set_value(CellRef::new(0, 2), Value::Text("x".into()));
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1));
|
||||
let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))];
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_wrapped_text_prints_rotated() {
|
||||
// Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text.
|
||||
|
||||
@@ -249,10 +249,14 @@ fn cancel_copy_mode(s: &mut Session) {
|
||||
|
||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
if at.checked_add(count).is_none_or(|end| end > limit) {
|
||||
let cmd = if axis == Axis::Rows { "home.insertRows" } else { "home.insertColumns" };
|
||||
return Err(bad(cmd, "the inserted lines would extend past the end of the worksheet"));
|
||||
}
|
||||
check_lines_protection(s, p, axis, at, count, true)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// Refuse to push data off the sheet.
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
@@ -288,27 +292,65 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the
|
||||
/// last line to the first so deleting one never shifts the next. A comma-separated list
|
||||
/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form.
|
||||
fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
|
||||
let key = if axis == Axis::Rows { "rows" } else { "cols" };
|
||||
let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
// Deleting past the last line only removes up to the end of the worksheet.
|
||||
let count = count.min(limit.saturating_sub(at)).max(1);
|
||||
vec![(at, at.saturating_add(count - 1))]
|
||||
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
|
||||
t.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
} else {
|
||||
s.doc()?.selection.ranges.iter().map(|r| if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()
|
||||
};
|
||||
let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect();
|
||||
spans.sort_unstable();
|
||||
let mut merged: Vec<(u32, u32)> = Vec::new();
|
||||
for (a, b) in spans {
|
||||
match merged.last_mut() {
|
||||
Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect())
|
||||
}
|
||||
|
||||
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
let areas = delete_areas(s, p, axis)?;
|
||||
for &(at, count) in &areas {
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
|
||||
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
|
||||
};
|
||||
sh.take_cells(block);
|
||||
match axis {
|
||||
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
|
||||
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
|
||||
// Last area first, so the earlier ones keep their positions.
|
||||
for (at, count) in areas {
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
|
||||
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
|
||||
};
|
||||
sh.take_cells(block);
|
||||
match axis {
|
||||
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
|
||||
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
|
||||
+1043
-67
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,14 @@ fn known(s: &Session, w: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Longest word we generate suggestions for. Candidate text grows with the square of the word length, so longer
|
||||
/// tokens (no real word is this long) get no suggestions instead of an unbounded allocation.
|
||||
const MAX_SUGGEST_LEN: usize = 64;
|
||||
|
||||
fn edits1(w: &str) -> Vec<String> {
|
||||
if w.chars().count() > MAX_SUGGEST_LEN {
|
||||
return Vec::new();
|
||||
}
|
||||
let chars: Vec<char> = w.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let letters = "abcdefghijklmnopqrstuvwxyz";
|
||||
@@ -177,3 +184,16 @@ fn thesaurus(_: &mut Session, p: &Json) -> Result<Json> {
|
||||
related.truncate(20);
|
||||
Ok(json!(related))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edits1_is_capped_for_very_long_words() {
|
||||
assert_eq!(edits1("helo").len(), 54 * 4 + 25);
|
||||
assert_eq!(edits1(&"a".repeat(MAX_SUGGEST_LEN)).len(), 54 * MAX_SUGGEST_LEN + 25);
|
||||
assert!(edits1(&"a".repeat(MAX_SUGGEST_LEN + 1)).is_empty());
|
||||
assert!(edits1(&"a".repeat(1_000_000)).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user