refactor(engine): move cmd/whatif.rs items to cmd/whatif/

This commit is contained in:
aiongg
2026-10-26 20:43:39 +00:00
parent 506bc8a0f2
commit 4b3fd23458
4 changed files with 730 additions and 713 deletions
+12 -713
View File
@@ -16,6 +16,18 @@ use serde_json::{Value as Json, json};
use super::*;
mod advanced_filter;
mod consolidate;
mod scenarios;
use advanced_filter::advanced_filter;
#[cfg(test)]
pub(crate) use advanced_filter::criterion_matches;
use consolidate::consolidate;
#[cfg(test)]
pub(crate) use scenarios::{SCENARIO_NAME, load_scenarios};
use scenarios::{scenario_add, scenario_delete, scenario_list, scenario_show, scenario_summary};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -338,297 +350,6 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"value": r.x, "result": value_json(&result), "iterations": r.iterations, "converged": r.converged}))
}
// ---------------------------------------------------------------- scenarios
/// Hidden workbook-scope name holding the scenarios as a quoted JSON string.
pub(crate) const SCENARIO_NAME: &str = "_sc_scenarios";
const MAX_SCENARIO_CELLS: usize = 32;
const MAX_SCENARIOS: usize = 1000;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct Scenario {
pub name: String,
pub sheet: String,
pub changing: String,
pub values: Vec<Json>,
#[serde(default)]
pub comment: String,
}
pub(crate) fn load_scenarios(wb: &Workbook) -> Vec<Scenario> {
let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)) else { return vec![] };
serde_json::from_str(&string_literals(&n.formula)).unwrap_or_default()
}
/// Concatenates the string literals of a `"a"&"b""c"` formula.
fn string_literals(f: &str) -> String {
let mut out = String::new();
let mut it = f.chars().peekable();
let mut in_str = false;
while let Some(c) = it.next() {
if !in_str {
if c == '"' {
in_str = true;
}
continue;
}
if c == '"' {
if it.peek() == Some(&'"') {
it.next();
out.push('"');
} else {
in_str = false;
}
} else {
out.push(c);
}
}
out
}
fn store_scenarios(wb: &mut Workbook, list: &[Scenario]) {
wb.names.retain(|n| !(n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)));
if list.is_empty() {
return;
}
let text = serde_json::to_string(list).unwrap_or_else(|_| "[]".into());
wb.names.push(DefinedName {
name: SCENARIO_NAME.into(),
scope: None,
// Excel caps string literals at 255 characters: store `"…"&"…"` chunks.
formula: text
.chars()
.collect::<Vec<char>>()
.chunks(250)
.map(|c| format!("\"{}\"", c.iter().collect::<String>().replace('"', "\"\"")))
.collect::<Vec<_>>()
.join("&"),
comment: "GridCraft Scenario Manager data".into(),
hidden: true,
});
}
fn scenario_json(sc: &Scenario) -> Json {
json!({"name": sc.name, "sheet": sc.sheet, "changing": sc.changing, "values": sc.values, "comment": sc.comment})
}
fn scenario_list(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let sheet = str_param(p, "sheet").map(str::to_string).unwrap_or_else(|| d.wb.active().map(|s| s.name.clone()).unwrap_or_default());
let list: Vec<Json> =
load_scenarios(&d.wb).iter().filter(|sc| sheet == "*" || sc.sheet.eq_ignore_ascii_case(&sheet)).map(scenario_json).collect();
Ok(json!({"scenarios": list}))
}
fn scenario_add(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioAdd";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string();
if name.chars().count() > 255 {
return Err(bad(CMD, "the name is too long (255 characters max)"));
}
let changing = str_param(p, "changing").ok_or_else(|| bad(CMD, "missing `changing`"))?.to_string();
let d = s.doc()?;
let (si, cells) = cell_list(&d.wb, &changing, CMD, MAX_SCENARIO_CELLS)?;
if cells.is_empty() {
return Err(bad(CMD, "no changing cells"));
}
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let values: Vec<Json> = match p.get("values").and_then(Json::as_array) {
Some(a) => {
if a.len() != cells.len() {
return Err(bad(CMD, format!("{} values for {} changing cells", a.len(), cells.len())));
}
a.clone()
}
None => cells.iter().map(|c| value_json(&sh.value(*c))).collect(),
};
let changing = cells.iter().map(|c| c.a1()).collect::<Vec<_>>().join(",");
let sc = Scenario { name: name.clone(), sheet: sh.name.clone(), changing, values, comment: str_param(p, "comment").unwrap_or("").to_string() };
let replace = bool_param(p, "replace").unwrap_or(false);
let mut list = load_scenarios(&d.wb);
match list.iter().position(|x| x.name.eq_ignore_ascii_case(&name) && x.sheet.eq_ignore_ascii_case(&sc.sheet)) {
Some(i) if replace => {
if let Some(slot) = list.get_mut(i) {
*slot = sc.clone();
}
}
Some(_) => return Err(EngineError::Other(format!("A scenario named '{name}' already exists on this sheet."))),
None => {
if list.len() >= MAX_SCENARIOS {
return Err(bad(CMD, "too many scenarios"));
}
list.push(sc.clone());
}
}
edit(s, |cx| {
store_scenarios(&mut cx.wb, &list);
Ok(())
})?;
Ok(scenario_json(&sc))
}
fn find_scenario(wb: &Workbook, p: &Json, cmd: &str) -> Result<(Vec<Scenario>, usize)> {
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing `name`"))?;
let list = load_scenarios(wb);
let active = wb.active().map(|s| s.name.clone()).unwrap_or_default();
let i = list
.iter()
.position(|x| x.name.eq_ignore_ascii_case(name) && x.sheet.eq_ignore_ascii_case(&active))
.or_else(|| list.iter().position(|x| x.name.eq_ignore_ascii_case(name)))
.ok_or_else(|| bad(cmd, format!("no scenario named `{name}`")))?;
Ok((list, i))
}
fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioShow";
let d = s.doc()?;
let (list, i) = find_scenario(&d.wb, p, CMD)?;
let sc = list.get(i).cloned().ok_or_else(|| bad(CMD, "no such scenario"))?;
let si = d.wb.sheet_index(&sc.sheet).ok_or_else(|| bad(CMD, format!("the scenario's sheet `{}` no longer exists", sc.sheet)))?;
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
edit(s, |cx| {
for (c, v) in cells.iter().zip(sc.values.iter()) {
// Stored numbers are values, not typed text: entering them through the parser would
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
let cell = match v.as_f64() {
Some(n) => {
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
Some(Cell { value: Value::Number(n), formula: None, style })
}
None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?,
};
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.set_cell(*c, cell),
None => {
sh.remove_cell(*c);
}
}
cx.touch(si, *c);
}
Ok(())
})?;
Ok(scenario_json(&sc))
}
fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let (mut list, i) = find_scenario(&d.wb, p, "data.scenarioDelete")?;
let removed = list.remove(i);
edit(s, |cx| {
store_scenarios(&mut cx.wb, &list);
Ok(())
})?;
Ok(json!({"deleted": removed.name}))
}
fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioSummary";
let d = s.doc()?;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let scenarios: Vec<Scenario> = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect();
if scenarios.is_empty() {
return Err(EngineError::Other("There are no scenarios on this sheet.".into()));
}
let results: Vec<CellRef> = match str_param(p, "resultCells").map(str::trim).filter(|t| !t.is_empty()) {
Some(t) => {
let (rsi, cells) = cell_list(&d.wb, t, CMD, 255)?;
if rsi != si {
return Err(bad(CMD, "result cells must be on the scenarios' sheet"));
}
cells
}
None => vec![],
};
// Changing cells of every scenario, first-seen order; per scenario its values.
let mut changing: Vec<CellRef> = Vec::new();
let mut per: Vec<HashMap<CellRef, Value>> = Vec::new();
for sc in &scenarios {
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
let mut m = HashMap::new();
for (c, v) in cells.iter().zip(sc.values.iter()) {
if !changing.contains(c) {
changing.push(*c);
}
m.insert(*c, json_value(v, &d.wb));
}
per.push(m);
}
let current: Vec<Value> = changing.iter().map(|c| sh.value(*c)).collect();
let current_results: Vec<Value> = results.iter().map(|c| sh.value(*c)).collect();
let mut trial = Trial::new(d);
let reads: Vec<(usize, CellRef)> = results.iter().map(|c| (si, *c)).collect();
let scenario_results: Vec<Vec<Value>> = per
.iter()
.map(|m| {
let sets: Vec<(usize, CellRef, Value)> = m.iter().map(|(c, v)| (si, *c, v.clone())).collect();
trial.run(&sets, &reads)
})
.collect();
let fmt_of = |c: CellRef| d.wb.styles.get(sh.style_id(c)).num_fmt.clone();
let changing_fmts: Vec<_> = changing.iter().map(|c| fmt_of(*c)).collect();
let result_fmts: Vec<_> = results.iter().map(|c| fmt_of(*c)).collect();
let name = (1..)
.map(|n| if n == 1 { "Scenario Summary".to_string() } else { format!("Scenario Summary {n}") })
.find(|n| d.wb.sheet_index(n).is_none())
.unwrap_or_else(|| "Scenario Summary".into());
let names: Vec<String> = scenarios.iter().map(|x| x.name.clone()).collect();
let new_index = edit(s, |cx| {
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
let mut sheet = Sheet::new(name.clone());
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
};
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
for (k, n) in names.iter().enumerate() {
put(&mut sheet, 2, 3 + k as u32, Value::text(n.as_str()), bold);
}
put(&mut sheet, 3, 1, Value::text("Changing Cells:"), bold);
let mut row = 4u32;
for (i, c) in changing.iter().enumerate() {
let fmt = changing_fmts.get(i).cloned().unwrap_or_default();
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
let cur = current.get(i).cloned().unwrap_or_default();
put(&mut sheet, row, 2, cur.clone(), st);
for (k, m) in per.iter().enumerate() {
put(&mut sheet, row, 3 + k as u32, m.get(c).cloned().unwrap_or_else(|| cur.clone()), st);
}
row += 1;
}
if !results.is_empty() {
put(&mut sheet, row, 1, Value::text("Result Cells:"), bold);
row += 1;
for (i, c) in results.iter().enumerate() {
let fmt = result_fmts.get(i).cloned().unwrap_or_default();
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
put(&mut sheet, row, 2, current_results.get(i).cloned().unwrap_or_default(), st);
for (k, r) in scenario_results.iter().enumerate() {
put(&mut sheet, row, 3 + k as u32, r.get(i).cloned().unwrap_or_default(), st);
}
row += 1;
}
}
put(
&mut sheet,
row + 1,
1,
Value::text("Notes: Current Values column represents values of changing cells at time Scenario Summary Report was created."),
StyleId::DEFAULT,
);
sheet.cols.insert(1, gridcraft_model::LineInfo { size: Some(110.0), ..Default::default() });
sheet.cols.insert(2, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
cx.wb.sheets.push(std::sync::Arc::new(sheet));
cx.structural = true;
Ok(cx.wb.sheets.len() - 1)
})?;
s.doc_mut()?.wb_switch_sheet(new_index);
Ok(json!({"sheet": name, "scenarios": names.len(), "changingCells": changing.len(), "resultCells": results.len()}))
}
// ---------------------------------------------------------------- data tables
const MAX_TABLE_CELLS: u64 = 100_000;
@@ -709,427 +430,5 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"range": body.a1(), "cells": n, "mode": mode, "static": true}))
}
// ---------------------------------------------------------------- advanced filter
#[derive(Clone, Debug)]
enum Cond {
/// Column offset in the list, comparison operator and operand text.
Compare(u32, Value),
/// A computed criterion: formula relative to the first data row, evaluated at `at`.
Computed(gridcraft_formula::Expr, CellRef),
}
/// Splits `">=10"` into (`">="`, `"10"`); text without an operator means "begins with".
fn split_op(t: &str) -> (&str, &str) {
for op in ["<>", ">=", "<=", "=", ">", "<"] {
if let Some(rest) = t.strip_prefix(op) {
return (op, rest);
}
}
("begins", t)
}
/// Does a list value match one criteria cell's value (Excel's advanced-filter semantics)?
pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool {
let crit_text = match crit {
Value::Empty => return true,
Value::Number(n) => return v.as_f64().is_some_and(|x| (x - n).abs() <= 1e-12 * n.abs().max(1.0)),
Value::Bool(b) => return matches!(v, Value::Bool(x) if x == b),
Value::Error(e) => return v.as_error() == Some(*e),
Value::Text(t) => t.to_string(),
Value::Array(_) => return false,
};
let (op, operand) = split_op(&crit_text);
if operand.is_empty() {
return match op {
"=" => v.is_empty() || text.is_empty(),
"<>" => !(v.is_empty() || text.is_empty()),
_ => true,
};
}
let num = gridcraft_core::parse::parse_number_text(operand);
let lower_text = text.to_lowercase();
let lower_op = operand.to_lowercase();
use std::cmp::Ordering;
let cmp: Option<Ordering> = match (v, num) {
(Value::Number(a), Some(b)) => a.partial_cmp(&b),
(Value::Number(_), None) => None,
(Value::Empty, _) => None,
(_, Some(_)) if op != "begins" => None,
_ => Some(gridcraft_core::compare_text(text, operand)),
};
let wild = |pat: &str| super::edit::wildcard(&lower_text, pat);
match op {
"=" => {
if num.is_none() && operand.contains(['*', '?']) {
wild(&lower_op)
} else if num.is_none() {
lower_text == lower_op
} else {
cmp == Some(Ordering::Equal)
}
}
"<>" => {
if num.is_none() && operand.contains(['*', '?']) {
!wild(&lower_op)
} else if num.is_none() {
lower_text != lower_op
} else {
cmp != Some(Ordering::Equal)
}
}
">" => cmp == Some(Ordering::Greater),
"<" => cmp == Some(Ordering::Less),
">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)),
"<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)),
_ => {
// Plain text criterion: begins with (wildcards allowed); a plain number equals.
match (v, num) {
(Value::Number(a), Some(b)) => (a - b).abs() <= 1e-12 * b.abs().max(1.0),
_ => !v.is_empty() && wild(&format!("{lower_op}*")),
}
}
}
}
fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.advancedFilter";
let d = s.doc()?;
let (si, list) = match str_param(p, "range") {
Some(t) => parse_range(&d.wb, t, CMD)?,
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
(d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active))
}
};
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
if list.height() < 1 || list.count() > 50_000_000 {
return Err(bad(CMD, "the list range is too large"));
}
if let Some((_, crit)) = criteria
&& (crit.width() > 1024 || crit.height() > 10_000)
{
return Err(bad(CMD, "the criteria range is too large"));
}
let wb = &d.wb;
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
let headers: Vec<String> =
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
let first_data = list.start.row + 1;
// Criteria rows → conditions.
let mut rows: Vec<Vec<Cond>> = Vec::new();
if let Some((csi, crit)) = criteria {
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
}
}
rows.push(conds);
}
}
let matches_row = |row: u32| -> bool {
if rows.is_empty() {
return true;
}
rows.iter().any(|conds| {
conds.iter().all(|c| match c {
Cond::Compare(off, crit) => {
let at = CellRef::new(row, list.start.col + off);
let v = sh.value(at);
let text = crate::display::cell_text(wb, sh, at);
criterion_matches(&v, &text, crit)
}
Cond::Computed(e, at) => {
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
matches!(v.scalar().to_bool(), Ok(true))
}
})
})
};
let unique = bool_param(p, "unique").unwrap_or(false);
let mut seen: HashSet<Vec<String>> = HashSet::new();
let mut keep: Vec<(u32, bool)> = Vec::new();
for row in first_data..=list.end.row {
let mut ok = matches_row(row);
if ok && unique {
let key: Vec<String> =
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(row, c)).to_lowercase()).collect();
ok = seen.insert(key);
}
keep.push((row, ok));
}
let matched = keep.iter().filter(|(_, k)| *k).count();
if let Some(t) = str_param(p, "copyTo") {
let (dsi, dest) = parse_range(wb, t, CMD)?;
let dest = dest.start;
let mut rows_out: Vec<u32> = vec![list.start.row];
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
let end =
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
let out_range = RangeRef::new(dest, end);
if dsi == si && out_range.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let cells: Vec<(CellRef, Cell)> = rows_out
.iter()
.enumerate()
.flat_map(|(i, row)| {
(list.start.col..=list.end.col).map(move |c| {
let src = CellRef::new(*row, c);
// The full output footprint was checked above.
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
let style = sh.style_id(src);
(to, Cell { value: sh.value(src), formula: None, style })
})
})
.collect();
edit(s, |cx| {
let shm = cx.sheet_mut(dsi)?;
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
for c in &old {
shm.remove_cell(*c);
}
for (c, cell) in cells {
shm.set_cell(c, cell);
}
for c in old {
cx.touch(dsi, c);
}
for c in out_range.iter() {
cx.touch(dsi, c);
}
Ok(())
})?;
return Ok(json!({"matched": matched, "range": out_range.a1()}));
}
let hidden = keep.iter().filter(|(_, k)| !*k).count();
edit(s, |cx| {
let shm = cx.sheet_mut(si)?;
for (row, k) in &keep {
if *k {
if let Some(e) = shm.rows.get_mut(row) {
e.hidden = false;
if *e == gridcraft_model::LineInfo::default() {
shm.rows.remove(row);
}
}
} else {
shm.rows.entry(*row).or_default().hidden = true;
}
}
cx.structural = true;
Ok(())
})?;
Ok(json!({"matched": matched, "hiddenRows": hidden}))
}
// ---------------------------------------------------------------- consolidate
#[derive(Clone, Copy, Debug, Default)]
struct Agg {
count: u64,
nums: u64,
sum: f64,
product: f64,
min: f64,
max: f64,
}
impl Agg {
fn add(&mut self, v: &Value) {
if v.is_empty() {
return;
}
self.count += 1;
if let Value::Number(n) = v {
if self.nums == 0 {
self.min = *n;
self.max = *n;
self.product = 1.0;
}
self.nums += 1;
self.sum += n;
self.product *= n;
self.min = self.min.min(*n);
self.max = self.max.max(*n);
}
}
fn result(&self, func: &str) -> Value {
match func {
"count" => Value::number(self.count as f64),
"countNums" | "countnums" => Value::number(self.nums as f64),
"average" => {
if self.nums == 0 {
Value::Error(gridcraft_core::CellError::Div0)
} else {
Value::number(self.sum / self.nums as f64)
}
}
"max" => Value::number(if self.nums == 0 { 0.0 } else { self.max }),
"min" => Value::number(if self.nums == 0 { 0.0 } else { self.min }),
"product" => Value::number(if self.nums == 0 { 0.0 } else { self.product }),
_ => Value::number(self.sum),
}
}
}
/// A row/column key: a position or a label (matched case-insensitively).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum Key {
Pos(u32),
Label(String),
}
const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000;
fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.consolidate";
let func = str_param(p, "function").unwrap_or("sum").to_string();
if !["sum", "count", "countNums", "countnums", "average", "max", "min", "product"].contains(&func.as_str()) {
return Err(bad(CMD, format!("unknown function `{func}`")));
}
let top = bool_param(p, "topRow").unwrap_or(false);
let left = bool_param(p, "leftColumn").unwrap_or(false);
let d = s.doc()?;
let srcs: Vec<String> = p
.get("sources")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
.or_else(|| str_param(p, "sources").map(|t| t.split(';').map(str::to_string).collect()))
.unwrap_or_default();
if srcs.is_empty() {
return Err(bad(CMD, "give at least one source range in `sources`"));
}
if srcs.len() > 255 {
return Err(bad(CMD, "too many sources (255 max)"));
}
let (dsi, dest) = match str_param(p, "destination") {
Some(t) => {
let (si, r) = parse_range(&d.wb, t, CMD)?;
(si, r.start)
}
None => (d.wb.active_sheet(), d.selection.active),
};
let mut row_keys: Vec<Key> = Vec::new();
let mut col_keys: Vec<Key> = Vec::new();
let mut row_label: HashMap<Key, String> = HashMap::new();
let mut col_label: HashMap<Key, String> = HashMap::new();
let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new();
let mut total: u64 = 0;
let index_of = |keys: &mut Vec<Key>, k: Key| -> usize {
match keys.iter().position(|x| *x == k) {
Some(i) => i,
None => {
keys.push(k);
keys.len() - 1
}
}
};
for t in &srcs {
let (si, r) = parse_range(&d.wb, t, CMD)?;
total = total.saturating_add(r.count());
if total > MAX_CONSOLIDATE_CELLS {
return Err(bad(CMD, "the sources are too large"));
}
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let r0 = r.start.row + top as u32;
let c0 = r.start.col + left as u32;
if r0 > r.end.row || c0 > r.end.col {
continue;
}
let label = |c: CellRef| crate::display::cell_text(&d.wb, sh, c).trim().to_string();
// Column keys of this source.
let mut cols: Vec<(u32, usize)> = Vec::new();
for col in c0..=r.end.col {
let k = if top {
let l = label(CellRef::new(r.start.row, col));
if l.is_empty() {
continue;
}
let k = Key::Label(l.to_lowercase());
col_label.entry(k.clone()).or_insert(l);
k
} else {
Key::Pos(col - c0)
};
cols.push((col, index_of(&mut col_keys, k)));
}
for row in r0..=r.end.row {
let k = if left {
let l = label(CellRef::new(row, r.start.col));
if l.is_empty() {
continue;
}
let k = Key::Label(l.to_lowercase());
row_label.entry(k.clone()).or_insert(l);
k
} else {
Key::Pos(row - r0)
};
let ri = index_of(&mut row_keys, k);
for (col, ci) in &cols {
aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col)));
}
}
}
let (out_r0, out_c0) = (dest.row + top as u32, dest.col + left as u32);
if out_r0 as u64 + row_keys.len() as u64 > gridcraft_core::MAX_ROWS as u64
|| out_c0 as u64 + col_keys.len() as u64 > gridcraft_core::MAX_COLS as u64
{
return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into()));
}
let mut cells: Vec<(CellRef, Value)> = Vec::new();
if top {
for (ci, k) in col_keys.iter().enumerate() {
cells.push((CellRef::new(dest.row, out_c0 + ci as u32), Value::text(col_label.get(k).cloned().unwrap_or_default())));
}
}
if left {
for (ri, k) in row_keys.iter().enumerate() {
cells.push((CellRef::new(out_r0 + ri as u32, dest.col), Value::text(row_label.get(k).cloned().unwrap_or_default())));
}
}
for ri in 0..row_keys.len() {
for ci in 0..col_keys.len() {
let v = aggs.get(&(ri, ci)).map(|a| if a.count == 0 { Value::Empty } else { a.result(&func) }).unwrap_or_default();
cells.push((CellRef::new(out_r0 + ri as u32, out_c0 + ci as u32), v));
}
}
let end = CellRef::new(
(out_r0 + row_keys.len() as u32).saturating_sub(1).max(dest.row),
(out_c0 + col_keys.len() as u32).saturating_sub(1).max(dest.col),
);
edit(s, |cx| {
for (c, v) in cells {
let sh = cx.sheet_mut(dsi)?;
let style = sh.style_id(c);
if v.is_empty() && style == StyleId::DEFAULT {
sh.remove_cell(c);
} else {
sh.set_cell(c, Cell { value: v, formula: None, style });
}
cx.touch(dsi, c);
}
Ok(())
})?;
Ok(json!({"range": RangeRef::new(dest, end).a1(), "rows": row_keys.len(), "columns": col_keys.len()}))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,231 @@
use super::*;
// ---------------------------------------------------------------- advanced filter
#[derive(Clone, Debug)]
enum Cond {
/// Column offset in the list, comparison operator and operand text.
Compare(u32, Value),
/// A computed criterion: formula relative to the first data row, evaluated at `at`.
Computed(gridcraft_formula::Expr, CellRef),
}
/// Splits `">=10"` into (`">="`, `"10"`); text without an operator means "begins with".
fn split_op(t: &str) -> (&str, &str) {
for op in ["<>", ">=", "<=", "=", ">", "<"] {
if let Some(rest) = t.strip_prefix(op) {
return (op, rest);
}
}
("begins", t)
}
/// Does a list value match one criteria cell's value (Excel's advanced-filter semantics)?
pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool {
let crit_text = match crit {
Value::Empty => return true,
Value::Number(n) => return v.as_f64().is_some_and(|x| (x - n).abs() <= 1e-12 * n.abs().max(1.0)),
Value::Bool(b) => return matches!(v, Value::Bool(x) if x == b),
Value::Error(e) => return v.as_error() == Some(*e),
Value::Text(t) => t.to_string(),
Value::Array(_) => return false,
};
let (op, operand) = split_op(&crit_text);
if operand.is_empty() {
return match op {
"=" => v.is_empty() || text.is_empty(),
"<>" => !(v.is_empty() || text.is_empty()),
_ => true,
};
}
let num = gridcraft_core::parse::parse_number_text(operand);
let lower_text = text.to_lowercase();
let lower_op = operand.to_lowercase();
use std::cmp::Ordering;
let cmp: Option<Ordering> = match (v, num) {
(Value::Number(a), Some(b)) => a.partial_cmp(&b),
(Value::Number(_), None) => None,
(Value::Empty, _) => None,
(_, Some(_)) if op != "begins" => None,
_ => Some(gridcraft_core::compare_text(text, operand)),
};
let wild = |pat: &str| crate::cmd::edit::wildcard(&lower_text, pat);
match op {
"=" => {
if num.is_none() && operand.contains(['*', '?']) {
wild(&lower_op)
} else if num.is_none() {
lower_text == lower_op
} else {
cmp == Some(Ordering::Equal)
}
}
"<>" => {
if num.is_none() && operand.contains(['*', '?']) {
!wild(&lower_op)
} else if num.is_none() {
lower_text != lower_op
} else {
cmp != Some(Ordering::Equal)
}
}
">" => cmp == Some(Ordering::Greater),
"<" => cmp == Some(Ordering::Less),
">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)),
"<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)),
_ => {
// Plain text criterion: begins with (wildcards allowed); a plain number equals.
match (v, num) {
(Value::Number(a), Some(b)) => (a - b).abs() <= 1e-12 * b.abs().max(1.0),
_ => !v.is_empty() && wild(&format!("{lower_op}*")),
}
}
}
}
pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.advancedFilter";
let d = s.doc()?;
let (si, list) = match str_param(p, "range") {
Some(t) => parse_range(&d.wb, t, CMD)?,
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
(d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active))
}
};
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
if list.height() < 1 || list.count() > 50_000_000 {
return Err(bad(CMD, "the list range is too large"));
}
if let Some((_, crit)) = criteria
&& (crit.width() > 1024 || crit.height() > 10_000)
{
return Err(bad(CMD, "the criteria range is too large"));
}
let wb = &d.wb;
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
let headers: Vec<String> =
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
let first_data = list.start.row + 1;
// Criteria rows → conditions.
let mut rows: Vec<Vec<Cond>> = Vec::new();
if let Some((csi, crit)) = criteria {
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
}
}
rows.push(conds);
}
}
let matches_row = |row: u32| -> bool {
if rows.is_empty() {
return true;
}
rows.iter().any(|conds| {
conds.iter().all(|c| match c {
Cond::Compare(off, crit) => {
let at = CellRef::new(row, list.start.col + off);
let v = sh.value(at);
let text = crate::display::cell_text(wb, sh, at);
criterion_matches(&v, &text, crit)
}
Cond::Computed(e, at) => {
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
matches!(v.scalar().to_bool(), Ok(true))
}
})
})
};
let unique = bool_param(p, "unique").unwrap_or(false);
let mut seen: HashSet<Vec<String>> = HashSet::new();
let mut keep: Vec<(u32, bool)> = Vec::new();
for row in first_data..=list.end.row {
let mut ok = matches_row(row);
if ok && unique {
let key: Vec<String> =
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(row, c)).to_lowercase()).collect();
ok = seen.insert(key);
}
keep.push((row, ok));
}
let matched = keep.iter().filter(|(_, k)| *k).count();
if let Some(t) = str_param(p, "copyTo") {
let (dsi, dest) = parse_range(wb, t, CMD)?;
let dest = dest.start;
let mut rows_out: Vec<u32> = vec![list.start.row];
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
let end =
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
let out_range = RangeRef::new(dest, end);
if dsi == si && out_range.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let cells: Vec<(CellRef, Cell)> = rows_out
.iter()
.enumerate()
.flat_map(|(i, row)| {
(list.start.col..=list.end.col).map(move |c| {
let src = CellRef::new(*row, c);
// The full output footprint was checked above.
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
let style = sh.style_id(src);
(to, Cell { value: sh.value(src), formula: None, style })
})
})
.collect();
edit(s, |cx| {
let shm = cx.sheet_mut(dsi)?;
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
for c in &old {
shm.remove_cell(*c);
}
for (c, cell) in cells {
shm.set_cell(c, cell);
}
for c in old {
cx.touch(dsi, c);
}
for c in out_range.iter() {
cx.touch(dsi, c);
}
Ok(())
})?;
return Ok(json!({"matched": matched, "range": out_range.a1()}));
}
let hidden = keep.iter().filter(|(_, k)| !*k).count();
edit(s, |cx| {
let shm = cx.sheet_mut(si)?;
for (row, k) in &keep {
if *k {
if let Some(e) = shm.rows.get_mut(row) {
e.hidden = false;
if *e == gridcraft_model::LineInfo::default() {
shm.rows.remove(row);
}
}
} else {
shm.rows.entry(*row).or_default().hidden = true;
}
}
cx.structural = true;
Ok(())
})?;
Ok(json!({"matched": matched, "hiddenRows": hidden}))
}
+193
View File
@@ -0,0 +1,193 @@
use super::*;
// ---------------------------------------------------------------- consolidate
#[derive(Clone, Copy, Debug, Default)]
struct Agg {
count: u64,
nums: u64,
sum: f64,
product: f64,
min: f64,
max: f64,
}
impl Agg {
fn add(&mut self, v: &Value) {
if v.is_empty() {
return;
}
self.count += 1;
if let Value::Number(n) = v {
if self.nums == 0 {
self.min = *n;
self.max = *n;
self.product = 1.0;
}
self.nums += 1;
self.sum += n;
self.product *= n;
self.min = self.min.min(*n);
self.max = self.max.max(*n);
}
}
fn result(&self, func: &str) -> Value {
match func {
"count" => Value::number(self.count as f64),
"countNums" | "countnums" => Value::number(self.nums as f64),
"average" => {
if self.nums == 0 {
Value::Error(gridcraft_core::CellError::Div0)
} else {
Value::number(self.sum / self.nums as f64)
}
}
"max" => Value::number(if self.nums == 0 { 0.0 } else { self.max }),
"min" => Value::number(if self.nums == 0 { 0.0 } else { self.min }),
"product" => Value::number(if self.nums == 0 { 0.0 } else { self.product }),
_ => Value::number(self.sum),
}
}
}
/// A row/column key: a position or a label (matched case-insensitively).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum Key {
Pos(u32),
Label(String),
}
const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000;
pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.consolidate";
let func = str_param(p, "function").unwrap_or("sum").to_string();
if !["sum", "count", "countNums", "countnums", "average", "max", "min", "product"].contains(&func.as_str()) {
return Err(bad(CMD, format!("unknown function `{func}`")));
}
let top = bool_param(p, "topRow").unwrap_or(false);
let left = bool_param(p, "leftColumn").unwrap_or(false);
let d = s.doc()?;
let srcs: Vec<String> = p
.get("sources")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
.or_else(|| str_param(p, "sources").map(|t| t.split(';').map(str::to_string).collect()))
.unwrap_or_default();
if srcs.is_empty() {
return Err(bad(CMD, "give at least one source range in `sources`"));
}
if srcs.len() > 255 {
return Err(bad(CMD, "too many sources (255 max)"));
}
let (dsi, dest) = match str_param(p, "destination") {
Some(t) => {
let (si, r) = parse_range(&d.wb, t, CMD)?;
(si, r.start)
}
None => (d.wb.active_sheet(), d.selection.active),
};
let mut row_keys: Vec<Key> = Vec::new();
let mut col_keys: Vec<Key> = Vec::new();
let mut row_label: HashMap<Key, String> = HashMap::new();
let mut col_label: HashMap<Key, String> = HashMap::new();
let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new();
let mut total: u64 = 0;
let index_of = |keys: &mut Vec<Key>, k: Key| -> usize {
match keys.iter().position(|x| *x == k) {
Some(i) => i,
None => {
keys.push(k);
keys.len() - 1
}
}
};
for t in &srcs {
let (si, r) = parse_range(&d.wb, t, CMD)?;
total = total.saturating_add(r.count());
if total > MAX_CONSOLIDATE_CELLS {
return Err(bad(CMD, "the sources are too large"));
}
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let r0 = r.start.row + top as u32;
let c0 = r.start.col + left as u32;
if r0 > r.end.row || c0 > r.end.col {
continue;
}
let label = |c: CellRef| crate::display::cell_text(&d.wb, sh, c).trim().to_string();
// Column keys of this source.
let mut cols: Vec<(u32, usize)> = Vec::new();
for col in c0..=r.end.col {
let k = if top {
let l = label(CellRef::new(r.start.row, col));
if l.is_empty() {
continue;
}
let k = Key::Label(l.to_lowercase());
col_label.entry(k.clone()).or_insert(l);
k
} else {
Key::Pos(col - c0)
};
cols.push((col, index_of(&mut col_keys, k)));
}
for row in r0..=r.end.row {
let k = if left {
let l = label(CellRef::new(row, r.start.col));
if l.is_empty() {
continue;
}
let k = Key::Label(l.to_lowercase());
row_label.entry(k.clone()).or_insert(l);
k
} else {
Key::Pos(row - r0)
};
let ri = index_of(&mut row_keys, k);
for (col, ci) in &cols {
aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col)));
}
}
}
let (out_r0, out_c0) = (dest.row + top as u32, dest.col + left as u32);
if out_r0 as u64 + row_keys.len() as u64 > gridcraft_core::MAX_ROWS as u64
|| out_c0 as u64 + col_keys.len() as u64 > gridcraft_core::MAX_COLS as u64
{
return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into()));
}
let mut cells: Vec<(CellRef, Value)> = Vec::new();
if top {
for (ci, k) in col_keys.iter().enumerate() {
cells.push((CellRef::new(dest.row, out_c0 + ci as u32), Value::text(col_label.get(k).cloned().unwrap_or_default())));
}
}
if left {
for (ri, k) in row_keys.iter().enumerate() {
cells.push((CellRef::new(out_r0 + ri as u32, dest.col), Value::text(row_label.get(k).cloned().unwrap_or_default())));
}
}
for ri in 0..row_keys.len() {
for ci in 0..col_keys.len() {
let v = aggs.get(&(ri, ci)).map(|a| if a.count == 0 { Value::Empty } else { a.result(&func) }).unwrap_or_default();
cells.push((CellRef::new(out_r0 + ri as u32, out_c0 + ci as u32), v));
}
}
let end = CellRef::new(
(out_r0 + row_keys.len() as u32).saturating_sub(1).max(dest.row),
(out_c0 + col_keys.len() as u32).saturating_sub(1).max(dest.col),
);
edit(s, |cx| {
for (c, v) in cells {
let sh = cx.sheet_mut(dsi)?;
let style = sh.style_id(c);
if v.is_empty() && style == StyleId::DEFAULT {
sh.remove_cell(c);
} else {
sh.set_cell(c, Cell { value: v, formula: None, style });
}
cx.touch(dsi, c);
}
Ok(())
})?;
Ok(json!({"range": RangeRef::new(dest, end).a1(), "rows": row_keys.len(), "columns": col_keys.len()}))
}
+294
View File
@@ -0,0 +1,294 @@
use super::*;
// ---------------------------------------------------------------- scenarios
/// Hidden workbook-scope name holding the scenarios as a quoted JSON string.
pub(crate) const SCENARIO_NAME: &str = "_sc_scenarios";
const MAX_SCENARIO_CELLS: usize = 32;
const MAX_SCENARIOS: usize = 1000;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct Scenario {
pub name: String,
pub sheet: String,
pub changing: String,
pub values: Vec<Json>,
#[serde(default)]
pub comment: String,
}
pub(crate) fn load_scenarios(wb: &Workbook) -> Vec<Scenario> {
let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)) else { return vec![] };
serde_json::from_str(&string_literals(&n.formula)).unwrap_or_default()
}
/// Concatenates the string literals of a `"a"&"b""c"` formula.
fn string_literals(f: &str) -> String {
let mut out = String::new();
let mut it = f.chars().peekable();
let mut in_str = false;
while let Some(c) = it.next() {
if !in_str {
if c == '"' {
in_str = true;
}
continue;
}
if c == '"' {
if it.peek() == Some(&'"') {
it.next();
out.push('"');
} else {
in_str = false;
}
} else {
out.push(c);
}
}
out
}
fn store_scenarios(wb: &mut Workbook, list: &[Scenario]) {
wb.names.retain(|n| !(n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)));
if list.is_empty() {
return;
}
let text = serde_json::to_string(list).unwrap_or_else(|_| "[]".into());
wb.names.push(DefinedName {
name: SCENARIO_NAME.into(),
scope: None,
// Excel caps string literals at 255 characters: store `"…"&"…"` chunks.
formula: text
.chars()
.collect::<Vec<char>>()
.chunks(250)
.map(|c| format!("\"{}\"", c.iter().collect::<String>().replace('"', "\"\"")))
.collect::<Vec<_>>()
.join("&"),
comment: "GridCraft Scenario Manager data".into(),
hidden: true,
});
}
fn scenario_json(sc: &Scenario) -> Json {
json!({"name": sc.name, "sheet": sc.sheet, "changing": sc.changing, "values": sc.values, "comment": sc.comment})
}
pub(super) fn scenario_list(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let sheet = str_param(p, "sheet").map(str::to_string).unwrap_or_else(|| d.wb.active().map(|s| s.name.clone()).unwrap_or_default());
let list: Vec<Json> =
load_scenarios(&d.wb).iter().filter(|sc| sheet == "*" || sc.sheet.eq_ignore_ascii_case(&sheet)).map(scenario_json).collect();
Ok(json!({"scenarios": list}))
}
pub(super) fn scenario_add(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioAdd";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string();
if name.chars().count() > 255 {
return Err(bad(CMD, "the name is too long (255 characters max)"));
}
let changing = str_param(p, "changing").ok_or_else(|| bad(CMD, "missing `changing`"))?.to_string();
let d = s.doc()?;
let (si, cells) = cell_list(&d.wb, &changing, CMD, MAX_SCENARIO_CELLS)?;
if cells.is_empty() {
return Err(bad(CMD, "no changing cells"));
}
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let values: Vec<Json> = match p.get("values").and_then(Json::as_array) {
Some(a) => {
if a.len() != cells.len() {
return Err(bad(CMD, format!("{} values for {} changing cells", a.len(), cells.len())));
}
a.clone()
}
None => cells.iter().map(|c| value_json(&sh.value(*c))).collect(),
};
let changing = cells.iter().map(|c| c.a1()).collect::<Vec<_>>().join(",");
let sc = Scenario { name: name.clone(), sheet: sh.name.clone(), changing, values, comment: str_param(p, "comment").unwrap_or("").to_string() };
let replace = bool_param(p, "replace").unwrap_or(false);
let mut list = load_scenarios(&d.wb);
match list.iter().position(|x| x.name.eq_ignore_ascii_case(&name) && x.sheet.eq_ignore_ascii_case(&sc.sheet)) {
Some(i) if replace => {
if let Some(slot) = list.get_mut(i) {
*slot = sc.clone();
}
}
Some(_) => return Err(EngineError::Other(format!("A scenario named '{name}' already exists on this sheet."))),
None => {
if list.len() >= MAX_SCENARIOS {
return Err(bad(CMD, "too many scenarios"));
}
list.push(sc.clone());
}
}
edit(s, |cx| {
store_scenarios(&mut cx.wb, &list);
Ok(())
})?;
Ok(scenario_json(&sc))
}
fn find_scenario(wb: &Workbook, p: &Json, cmd: &str) -> Result<(Vec<Scenario>, usize)> {
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing `name`"))?;
let list = load_scenarios(wb);
let active = wb.active().map(|s| s.name.clone()).unwrap_or_default();
let i = list
.iter()
.position(|x| x.name.eq_ignore_ascii_case(name) && x.sheet.eq_ignore_ascii_case(&active))
.or_else(|| list.iter().position(|x| x.name.eq_ignore_ascii_case(name)))
.ok_or_else(|| bad(cmd, format!("no scenario named `{name}`")))?;
Ok((list, i))
}
pub(super) fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioShow";
let d = s.doc()?;
let (list, i) = find_scenario(&d.wb, p, CMD)?;
let sc = list.get(i).cloned().ok_or_else(|| bad(CMD, "no such scenario"))?;
let si = d.wb.sheet_index(&sc.sheet).ok_or_else(|| bad(CMD, format!("the scenario's sheet `{}` no longer exists", sc.sheet)))?;
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
edit(s, |cx| {
for (c, v) in cells.iter().zip(sc.values.iter()) {
// Stored numbers are values, not typed text: entering them through the parser would
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
let cell = match v.as_f64() {
Some(n) => {
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
Some(Cell { value: Value::Number(n), formula: None, style })
}
None => crate::cmd::edit::input_to_cell(&crate::cmd::edit::json_to_input(v), si, *c, &mut cx.wb)?,
};
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.set_cell(*c, cell),
None => {
sh.remove_cell(*c);
}
}
cx.touch(si, *c);
}
Ok(())
})?;
Ok(scenario_json(&sc))
}
pub(super) fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let (mut list, i) = find_scenario(&d.wb, p, "data.scenarioDelete")?;
let removed = list.remove(i);
edit(s, |cx| {
store_scenarios(&mut cx.wb, &list);
Ok(())
})?;
Ok(json!({"deleted": removed.name}))
}
pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "data.scenarioSummary";
let d = s.doc()?;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let scenarios: Vec<Scenario> = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect();
if scenarios.is_empty() {
return Err(EngineError::Other("There are no scenarios on this sheet.".into()));
}
let results: Vec<CellRef> = match str_param(p, "resultCells").map(str::trim).filter(|t| !t.is_empty()) {
Some(t) => {
let (rsi, cells) = cell_list(&d.wb, t, CMD, 255)?;
if rsi != si {
return Err(bad(CMD, "result cells must be on the scenarios' sheet"));
}
cells
}
None => vec![],
};
// Changing cells of every scenario, first-seen order; per scenario its values.
let mut changing: Vec<CellRef> = Vec::new();
let mut per: Vec<HashMap<CellRef, Value>> = Vec::new();
for sc in &scenarios {
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
let mut m = HashMap::new();
for (c, v) in cells.iter().zip(sc.values.iter()) {
if !changing.contains(c) {
changing.push(*c);
}
m.insert(*c, json_value(v, &d.wb));
}
per.push(m);
}
let current: Vec<Value> = changing.iter().map(|c| sh.value(*c)).collect();
let current_results: Vec<Value> = results.iter().map(|c| sh.value(*c)).collect();
let mut trial = Trial::new(d);
let reads: Vec<(usize, CellRef)> = results.iter().map(|c| (si, *c)).collect();
let scenario_results: Vec<Vec<Value>> = per
.iter()
.map(|m| {
let sets: Vec<(usize, CellRef, Value)> = m.iter().map(|(c, v)| (si, *c, v.clone())).collect();
trial.run(&sets, &reads)
})
.collect();
let fmt_of = |c: CellRef| d.wb.styles.get(sh.style_id(c)).num_fmt.clone();
let changing_fmts: Vec<_> = changing.iter().map(|c| fmt_of(*c)).collect();
let result_fmts: Vec<_> = results.iter().map(|c| fmt_of(*c)).collect();
let name = (1..)
.map(|n| if n == 1 { "Scenario Summary".to_string() } else { format!("Scenario Summary {n}") })
.find(|n| d.wb.sheet_index(n).is_none())
.unwrap_or_else(|| "Scenario Summary".into());
let names: Vec<String> = scenarios.iter().map(|x| x.name.clone()).collect();
let new_index = edit(s, |cx| {
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
let mut sheet = Sheet::new(name.clone());
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
};
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
for (k, n) in names.iter().enumerate() {
put(&mut sheet, 2, 3 + k as u32, Value::text(n.as_str()), bold);
}
put(&mut sheet, 3, 1, Value::text("Changing Cells:"), bold);
let mut row = 4u32;
for (i, c) in changing.iter().enumerate() {
let fmt = changing_fmts.get(i).cloned().unwrap_or_default();
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
let cur = current.get(i).cloned().unwrap_or_default();
put(&mut sheet, row, 2, cur.clone(), st);
for (k, m) in per.iter().enumerate() {
put(&mut sheet, row, 3 + k as u32, m.get(c).cloned().unwrap_or_else(|| cur.clone()), st);
}
row += 1;
}
if !results.is_empty() {
put(&mut sheet, row, 1, Value::text("Result Cells:"), bold);
row += 1;
for (i, c) in results.iter().enumerate() {
let fmt = result_fmts.get(i).cloned().unwrap_or_default();
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
put(&mut sheet, row, 2, current_results.get(i).cloned().unwrap_or_default(), st);
for (k, r) in scenario_results.iter().enumerate() {
put(&mut sheet, row, 3 + k as u32, r.get(i).cloned().unwrap_or_default(), st);
}
row += 1;
}
}
put(
&mut sheet,
row + 1,
1,
Value::text("Notes: Current Values column represents values of changing cells at time Scenario Summary Report was created."),
StyleId::DEFAULT,
);
sheet.cols.insert(1, gridcraft_model::LineInfo { size: Some(110.0), ..Default::default() });
sheet.cols.insert(2, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
cx.wb.sheets.push(std::sync::Arc::new(sheet));
cx.structural = true;
Ok(cx.wb.sheets.len() - 1)
})?;
s.doc_mut()?.wb_switch_sheet(new_index);
Ok(json!({"sheet": name, "scenarios": names.len(), "changingCells": changing.len(), "resultCells": results.len()}))
}