diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index feb548702..bbc66fa0c 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -14,6 +14,17 @@ use super::*; use crate::pivot as pv; use crate::selection::current_region; +mod charts; +mod create; +mod fields; +mod layout; +mod params; + +use charts::{insert_pivot_chart, recommended}; +use create::{insert_pivot, options, show_details, summarize_table}; +use fields::{add_field, calculated_field, fields, list, move_field, remove_field, value_settings}; +use layout::{change_source, collapse, delete, filter, grand_totals, group, layout, refresh, refresh_all, sort}; + pub fn specs() -> Vec { vec![ cmd!( @@ -252,1447 +263,5 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { Err(EngineError::Disabled("pivot".into(), "select a cell in a PivotTable (or pass `pivot`)".into())) } -fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> { - match v.map(str::to_ascii_lowercase) { - Some(a) if a == "rows" || a == "row" => Ok("rows"), - Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"), - Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"), - Some(a) if a == "filters" || a == "filter" => Ok("filters"), - Some(a) => Err(bad(cmd, format!("unknown area `{a}` (rows|columns|values|filters)"))), - None => Err(bad(cmd, "missing `area` (rows|columns|values|filters)")), - } -} - -fn func_param(v: Option<&str>) -> Option { - Some(match v?.to_ascii_lowercase().as_str() { - "sum" => PivotFunc::Sum, - "count" | "counta" => PivotFunc::Count, - "average" | "avg" | "mean" => PivotFunc::Average, - "max" => PivotFunc::Max, - "min" => PivotFunc::Min, - "product" => PivotFunc::Product, - "countnumbers" | "countnums" | "count numbers" => PivotFunc::CountNumbers, - "stddev" => PivotFunc::StdDev, - "stddevp" => PivotFunc::StdDevP, - "var" => PivotFunc::Var, - "varp" => PivotFunc::VarP, - _ => return None, - }) -} - -fn func_name(f: PivotFunc) -> &'static str { - match f { - PivotFunc::Sum => "sum", - PivotFunc::Count => "count", - PivotFunc::Average => "average", - PivotFunc::Max => "max", - PivotFunc::Min => "min", - PivotFunc::Product => "product", - PivotFunc::CountNumbers => "countNumbers", - PivotFunc::StdDev => "stdDev", - PivotFunc::StdDevP => "stdDevP", - PivotFunc::Var => "var", - PivotFunc::VarP => "varP", - } -} - -fn show_as_param(v: &str) -> Option { - Some(match v.to_ascii_lowercase().as_str() { - "normal" | "none" => PivotShowAs::Normal, - "percentofgrandtotal" => PivotShowAs::PercentOfGrandTotal, - "percentofcolumntotal" => PivotShowAs::PercentOfColumnTotal, - "percentofrowtotal" => PivotShowAs::PercentOfRowTotal, - "runningtotal" => PivotShowAs::RunningTotal, - "rank" => PivotShowAs::Rank, - _ => return None, - }) -} - -fn show_as_name(v: PivotShowAs) -> &'static str { - match v { - PivotShowAs::Normal => "normal", - PivotShowAs::PercentOfGrandTotal => "percentOfGrandTotal", - PivotShowAs::PercentOfColumnTotal => "percentOfColumnTotal", - PivotShowAs::PercentOfRowTotal => "percentOfRowTotal", - PivotShowAs::RunningTotal => "runningTotal", - PivotShowAs::Rank => "rank", - } -} - -fn layout_name(l: PivotLayout) -> &'static str { - match l { - PivotLayout::Compact => "compact", - PivotLayout::Outline => "outline", - PivotLayout::Tabular => "tabular", - } -} - -fn group_name(g: PivotDateGroup) -> &'static str { - match g { - PivotDateGroup::None => "none", - PivotDateGroup::Years => "years", - PivotDateGroup::Quarters => "quarters", - PivotDateGroup::Months => "months", - PivotDateGroup::Days => "days", - } -} - -fn sort_name(o: PivotSort) -> &'static str { - match o { - PivotSort::Asc => "asc", - PivotSort::Desc => "desc", - PivotSort::None => "none", - } -} - -// ---------------------------------------------------------------- layout editing (pure) - -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 { - 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) { - 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) -> Result { - 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 { - src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) -} - -/// The calculated field named `field`, if any. -fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> { - pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim())) -} - -/// The canonical name of a source or calculated field. -fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result { - match calc_field(pt, field) { - Some(c) if src.col(field).is_none() => Ok(c.name.clone()), - _ => canonical(cmd, src, field), - } -} - -fn unique_value_name(pt: &PivotTable, base: &str, except: Option) -> String { - let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n)); - if !taken(base) { - return base.to_string(); - } - (2..10_000).map(|k| format!("{base}{k}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) -} - -fn auto_name(f: PivotFunc, field: &str) -> String { - format!("{} of {field}", pv::func_caption(f)) -} - -/// Adds `field` to an area (moving it out of the other axis areas). -fn add_to_area( - cmd: &str, - pt: &mut PivotTable, - src: &pv::Source, - field: &str, - area: &str, - func: Option, - name: Option<&str>, - position: Option, -) -> Result<()> { - let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some(); - if is_calc && area != "values" { - return Err(other("A calculated field can only be placed in the Values area.")); - } - let field = canonical_any(cmd, pt, src, field)?; - match area { - "rows" | "columns" => { - 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 = 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())); - insert_axis_field(pt, area == "rows", existing, position); - } - "values" => { - if pt.values.len() >= pv::MAX_VALUE_FIELDS { - return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS))); - } - let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false); - let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count }); - let base = match name { - Some(n) if !n.trim().is_empty() => { - if src.col(n).is_some() { - return Err(other("PivotTable field name already exists.")); - } - n.to_string() - } - _ => auto_name(f, &field), - }; - let nm = unique_value_name(pt, &base, None); - let v = PivotValue { source_col: field, func: f, name: nm, show_as: PivotShowAs::Normal, number_format: None }; - let at = position.unwrap_or(pt.values.len()).min(pt.values.len()); - pt.values.insert(at, v); - } - _ => { - 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), - None => PivotFilter { source_col: field, selected: None }, - }; - let at = position.unwrap_or(pt.filters.len()).min(pt.filters.len()); - pt.filters.insert(at, entry); - } - } - Ok(()) -} - -/// Removes `field` from an area; returns the source field it referred to. -fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result { - match area { - "rows" | "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) - } - "values" => { - let i = pt - .values - .iter() - .position(|v| v.name.eq_ignore_ascii_case(field)) - .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(field))) - .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the values area")))?; - Ok(pt.values.remove(i).source_col) - } - _ => { - let i = pt - .filters - .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(field) && !in_axes(pt, &f.source_col)) - .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the filters area")))?; - Ok(pt.filters.remove(i).source_col) - } - } -} - -// ---------------------------------------------------------------- shared update - -/// Applies `f` to the targeted pivot and re-renders it, as one undo step. -fn update(s: &mut Session, p: &Json, f: impl FnOnce(&mut PivotTable, &pv::Source, &Workbook) -> Result) -> Result { - let (si, pi) = find_pivot(s, p)?; - let replace = bool_param(p, "replace").unwrap_or(false); - edit(s, |cx| { - let pt = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).cloned().ok_or_else(|| other("There's no such PivotTable."))?; - let src = pv::read_source(&cx.wb, &pt.source, si).map_err(other)?; - let mut new = pt.clone(); - let mut out = f(&mut new, &src, &cx.wb)?; - if let Some(slot) = cx.sheet_mut(si)?.pivots.get_mut(pi) { - *slot = new; - } - let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; - cx.structural = true; - if out.is_null() { - out = json!({}); - } - if let Some(m) = out.as_object_mut() { - m.insert("range".into(), json!(area.a1())); - } - Ok(out) - }) -} - -// ---------------------------------------------------------------- insert - -/// The default source: the table at the selection, else the selected block / current region. -fn default_source(s: &Session) -> Result { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - if let Some(t) = sh.table_at(d.selection.active) { - return Ok(t.name.clone()); - } - let sel = d.selection.current(); - let r = if sel.is_single() { - current_region(sh, d.selection.active) - } else { - let used = sh.used_range().unwrap_or(sel); - sel.intersection(&used).unwrap_or(sel) - }; - if r.is_single() && sh.value(r.start).is_empty() { - return Err(other("The reference isn't valid. Select a cell in a range with labeled columns, or pass `source`.")); - } - Ok(pv::range_text(&sh.name, r)) -} - -fn source_param(s: &Session, p: &Json) -> Result { - match str_param(p, "source").or_else(|| str_param(p, "range")) { - Some(t) if !t.trim().is_empty() => { - // Bare ranges are on the active sheet; store them with the sheet name. - let d = s.doc()?; - let t = t.trim().trim_start_matches('='); - if !t.contains('!') - && RangeRef::parse(t).is_some() - && let Some(sh) = d.wb.active() - && let Some(r) = RangeRef::parse(t) - { - return Ok(pv::range_text(&sh.name, r)); - } - Ok(t.to_string()) - } - _ => default_source(s), - } -} - -fn next_pivot_name(wb: &Workbook) -> String { - (1..) - .map(|n| format!("PivotTable{n}")) - .find(|n| !wb.sheets.iter().any(|s| s.pivots.iter().any(|p| p.name.eq_ignore_ascii_case(n)))) - .unwrap_or_default() -} - -fn next_pivot_id(wb: &Workbook) -> u32 { - let m = wb.sheets.iter().flat_map(|s| s.pivots.iter().map(|p| p.id)).max().unwrap_or(0); - wb.next_object_id().max(m.saturating_add(1)) -} - -fn field_list(v: Option<&Json>) -> Vec { - match v { - Some(Json::Array(a)) => a.iter().take(512).cloned().collect(), - Some(Json::String(s)) => vec![Json::String(s.clone())], - _ => vec![], - } -} - -fn insert_pivot(s: &mut Session, p: &Json) -> Result { - if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { - return Err(other("The workbook is protected and cannot be changed.")); - } - let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet(); - // Validate the source now (header names, size). - let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; - let name = match str_param(p, "name") { - Some(n) if !n.trim().is_empty() => { - let n = n.trim().to_string(); - if s.doc()?.wb.sheets.iter().any(|sh| sh.pivots.iter().any(|x| x.name.eq_ignore_ascii_case(&n))) { - return Err(other("A PivotTable with that name already exists.")); - } - n - } - _ => next_pivot_name(&s.doc()?.wb), - }; - let dest = str_param(p, "destination").or_else(|| str_param(p, "location")).unwrap_or("new").trim().to_string(); - // Existing sheet destination: (sheet index, anchor). - let target: Option<(usize, CellRef)> = if dest.eq_ignore_ascii_case("new") || dest.is_empty() { - None - } else { - let d = s.doc()?; - let (sh, body) = super::split_sheet(&dest); - let si = match sh { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("insert.pivotTable", format!("no sheet named `{n}`")))?, - None => active, - }; - let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; - Some((si, at)) - }; - let replace = bool_param(p, "replace").unwrap_or(false); - let rows = field_list(p.get("rows")); - let columns = field_list(p.get("columns")); - let values = field_list(p.get("values")); - let filters = field_list(p.get("filters")); - let mut new_sheet_at: Option = None; - let res = edit(s, |cx| { - let id = next_pivot_id(&cx.wb); - let (si, anchor) = match target { - Some(t) => t, - None => { - let at = insert_sheet_before_active(cx); - new_sheet_at = Some(at); - (at, CellRef::new(2, 0)) - } - }; - let mut pt = PivotTable { id, name: name.clone(), source: source.clone(), anchor, ..Default::default() }; - let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> { - for f in list { - match f { - Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?, - Json::Object(_) => { - let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?; - let func = match str_param(f, "func") { - Some(fname) => { - Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?) - } - None => None, - }; - add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?; - let by = group_param(str_param(f, "group")); - if let Some(by) = by.filter(|b| *b != PivotDateGroup::None) - && matches!(area, "rows" | "columns") - && src.col(n).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false) - && let Some(af) = pt.rows.iter_mut().chain(pt.columns.iter_mut()).find(|x| x.source_col.eq_ignore_ascii_case(n)) - { - af.date_group = by; - } - } - _ => return Err(bad("insert.pivotTable", "fields are names or {field, func?, name?}")), - } - } - Ok(()) - }; - add(&mut pt, &rows, "rows")?; - add(&mut pt, &columns, "columns")?; - add(&mut pt, &values, "values")?; - add(&mut pt, &filters, "filters")?; - let sh = cx.sheet_mut(si)?; - sh.pivots.push(pt); - let pi = sh.pivots.len() - 1; - let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; - cx.wb.set_active_sheet(si); - *cx.sel = crate::Selection::at(anchor); - cx.structural = true; - let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); - Ok(json!({ - "id": id, - "name": name, - "sheet": sheet_name, - "anchor": anchor.a1(), - "range": area.a1(), - "source": source, - "fields": src.headers, - })) - })?; - if let Some(at) = new_sheet_at - && let Ok(d) = s.doc_mut() - { - d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); - } - Ok(res) -} - -/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a -/// Show Details sheet); returns its index. -fn insert_sheet_before_active(cx: &mut Ctx) -> usize { - let at = cx.wb.active_sheet().min(cx.wb.sheets.len()); - let nm = cx.wb.next_sheet_name(); - cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); - for n in cx.wb.names.iter_mut() { - if let Some(sc) = n.scope.as_mut() - && *sc >= at - { - *sc += 1; - } - } - at -} - -/// Show Details (double-click a value cell): the source records behind it, as values with their -/// column's number format, under the source headers on a new sheet. -fn show_details(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let active = d.wb.active_sheet(); - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let pt = sh - .pivots - .iter() - .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) - .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; - let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); - // With the option off, a report that is out of date or a source that cannot be read is - // reported as the option; only a cell that is not a value cell keeps its own message. - if !pt.enable_show_details && !matches!(found, Ok(None)) { - return Err(other(SHOW_DETAILS_OFF)); - } - let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; - if d.wb.protected_structure { - return Err(other("The workbook is protected and cannot be changed.")); - } - let count = records.len(); - let mut new_sheet_at: Option = None; - let res = edit(s, |cx| { - let si = insert_sheet_before_active(cx); - new_sheet_at = Some(si); - let base = cx.wb.styles.get(StyleId::default()).clone(); - let fmts: Vec = src - .formats - .iter() - .map(|f| { - let mut st = base.clone(); - st.num_fmt = NumFmt::new(f); - cx.wb.styles.intern(st) - }) - .collect(); - // Columns wide enough for their text, as the PivotTable report's are (dates would show - // as ####); the first rows are enough to judge a column. - let font = base.font.clone(); - let widths: Vec = src - .headers - .iter() - .enumerate() - .map(|(c, h)| { - let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General"); - let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c))); - let widest = texts - .map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false)) - .fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max); - (widest + 14.0).min(320.0) - }) - .collect(); - let sh = cx.sheet_mut(si)?; - for (c, w) in widths.into_iter().enumerate() { - if w > sh.col_width(c as u32) { - sh.cols.entry(c as u32).or_default().size = Some(w); - } - } - for (c, h) in src.headers.iter().enumerate() { - sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); - } - for (r, i) in records.iter().enumerate() { - let Some(rec) = src.rows.get(*i) else { continue }; - for (c, v) in rec.iter().enumerate() { - let style = fmts.get(c).copied().unwrap_or_default(); - if v.is_empty() && style == StyleId::default() { - continue; - } - sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); - } - } - let name = sh.name.clone(); - cx.wb.set_active_sheet(si); - *cx.sel = crate::Selection::at(CellRef::new(0, 0)); - cx.structural = true; - Ok(json!({"sheet": name, "records": count})) - })?; - if let Some(at) = new_sheet_at - && let Ok(d) = s.doc_mut() - { - d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); - } - Ok(res) -} - -/// The message Show Details gives while Enable show details is off. -const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable."; - -/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not -/// refreshed. Without params it returns the current setting. -fn options(s: &mut Session, p: &Json) -> Result { - let (si, pi) = find_pivot(s, p)?; - let enable = bool_param(p, "enableShowDetails"); - if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() { - return Err(bad("pivot.options", "`enableShowDetails` must be true or false")); - } - let current = |s: &Session| -> Result { - let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; - Ok(json!({"enableShowDetails": pt.enable_show_details})) - }; - let Some(on) = enable else { return current(s) }; - if current(s)?["enableShowDetails"] == json!(on) { - return current(s); - } - edit(s, |cx| { - let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?; - pt.enable_show_details = on; - Ok(json!({"enableShowDetails": on})) - }) -} - -fn summarize_table(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let name = match str_param(p, "table") { - Some(n) => d.wb.table(n).and_then(|(si, ti)| d.wb.sheet(si).and_then(|sh| sh.tables.get(ti))).map(|t| t.name.clone()), - None => d.wb.active().and_then(|sh| sh.table_at(d.selection.active)).map(|t| t.name.clone()), - } - .ok_or_else(|| EngineError::Disabled("table.summarizePivot".into(), "select a cell in a table".into()))?; - let mut q = with(p, "source", json!(name)); - if let Some(m) = q.as_object_mut() { - m.remove("table"); - } - insert_pivot(s, &q) -} - -// ---------------------------------------------------------------- queries - -fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { - json!({ - "id": pt.id, - "name": pt.name, - "sheet": wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), - "anchor": pt.anchor.a1(), - "range": pt.last_range.map(|r| r.a1()), - "source": pt.source, - "rows": pt.rows.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), - "columns": pt.columns.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), - "values": pt.values.iter().map(|v| json!({"field": v.source_col, "func": func_name(v.func), "name": v.name, "showAs": show_as_name(v.show_as), "numberFormat": v.number_format})).collect::>(), - "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::>(), - "layout": layout_name(pt.layout), - "grandTotalsRows": pt.grand_totals_rows, - "grandTotalsCols": pt.grand_totals_cols, - "subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" }, - "style": pt.style, - "showHeaders": pt.show_headers, - "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), - "valuesOnRows": pt.values_on_rows, - "valuesPosition": pt.values_position, - "enableShowDetails": pt.enable_show_details, - }) -} - -fn fields(s: &mut Session, p: &Json) -> Result { - let (si, pi) = find_pivot(s, p)?; - let d = s.doc()?; - let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; - let mut out = pivot_json(&d.wb, si, pt); - let src = pv::read_source(&d.wb, &pt.source, si); - let list: Vec = match &src { - Ok(src) => src - .headers - .iter() - .enumerate() - .map(|(c, h)| { - let mut areas = vec![]; - if pt.rows.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { - areas.push("rows"); - } - if pt.columns.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { - areas.push("columns"); - } - if pt.values.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { - areas.push("values"); - } - if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) && !in_axes(pt, h) { - areas.push("filters"); - } - json!({ - "name": h, - "numeric": src.numeric.get(c).copied().unwrap_or(false), - "date": src.dates.get(c).copied().unwrap_or(false), - "samples": pv::distinct_labels(&d.wb, src, c, PivotDateGroup::None, 5), - "areas": areas, - }) - }) - .collect(), - Err(_) => vec![], - }; - let mut list = list; - for c in &pt.calculated_fields { - let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] }; - list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula})); - } - if let Some(m) = out.as_object_mut() { - m.insert("fields".into(), Json::Array(list)); - if let Ok(src) = &src { - m.insert("records".into(), json!(src.rows.len())); - } - if let Err(e) = src { - m.insert("error".into(), json!(e)); - } - } - Ok(out) -} - -fn list(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let mut v = vec![]; - for (si, sh) in d.wb.sheets.iter().enumerate() { - for pt in &sh.pivots { - v.push(pivot_json(&d.wb, si, pt)); - } - } - Ok(Json::Array(v)) -} - -// ---------------------------------------------------------------- field commands - -fn position_param(p: &Json) -> Option { - u32_param(p, "position").map(|v| v as usize) -} - -fn add_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.addField"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let area = area_param(C, str_param(p, "area"))?; - let func = match str_param(p, "func") { - Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), - None => None, - }; - let name = str_param(p, "name").map(str::to_string); - let pos = position_param(p); - update(s, p, |pt, src, _| { - add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?; - Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area})) - }) -} - -fn calculated_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.calculatedField"; - let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { - // Menu-style call: the Insert Calculated Field dialog. - let (si, pi) = find_pivot(s, p)?; - let d = s.doc()?; - let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; - let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into()); - let pivot = pt.name.clone(); - s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"}))); - return ok(); - }; - if name.is_empty() || name.chars().count() > 255 { - return Err(bad(C, "`name` must be 1 to 255 characters")); - } - let delete = bool_param(p, "delete").unwrap_or(false); - let formula = str_param(p, "formula").map(|f| { - let f = f.trim(); - if f.starts_with('=') { f.to_string() } else { format!("={f}") } - }); - let to_values = bool_param(p, "values").unwrap_or(true); - if !delete && formula.is_none() { - return Err(bad(C, "missing `formula`")); - } - update(s, p, |pt, src, _| { - let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name)); - if delete { - let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?; - let gone = pt.calculated_fields.remove(i); - pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name)); - return Ok(json!({"name": gone.name, "deleted": true})); - } - let formula = formula.clone().unwrap_or_default(); - if src.col(&name).is_some() { - return Err(other("PivotTable field name already exists.")); - } - let canon = match existing { - Some(i) => { - let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?; - c.formula = formula.clone(); - c.name.clone() - } - None => { - if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS { - return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS))); - } - pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() }); - name.clone() - } - }; - let set = crate::pivot_calc::CalcSet::new(pt, &src.headers); - let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?; - if let Some(e) = set.error(i) { - return Err(bad(C, e.to_string())); - } - if set.is_cyclic(i) { - return Err(bad(C, format!("`{canon}` refers to itself through its formula"))); - } - if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) { - add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?; - } - Ok(json!({ - "name": canon, - "formula": formula, - "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), - })) - }) -} - -fn remove_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.removeField"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let area = match str_param(p, "area") { - Some(a) => Some(area_param(C, Some(a))?), - None => None, - }; - update(s, p, |pt, _, _| { - match area { - Some(a) => { - remove_from_area(C, pt, &field, a)?; - } - None => { - let mut any = false; - for a in ["rows", "columns", "values", "filters"] { - any |= remove_from_area(C, pt, &field, a).is_ok(); - } - if !any { - return Err(bad(C, format!("`{field}` isn't in the PivotTable"))); - } - } - } - Ok(json!({})) - }) -} - -fn move_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.moveField"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let from = area_param(C, str_param(p, "from"))?; - 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 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); - shift_values(pt, rows, i, false); - insert_axis_field(pt, rows, f, pos); - } - "values" => { - let i = pt - .values - .iter() - .position(|v| v.name.eq_ignore_ascii_case(&field)) - .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(&field))) - .ok_or_else(|| bad(C, format!("`{field}` isn't in the values area")))?; - let v = pt.values.remove(i); - let at = pos.unwrap_or(pt.values.len()).min(pt.values.len()); - pt.values.insert(at, v); - } - _ => { - let i = pt - .filters - .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(&field)) - .ok_or_else(|| bad(C, format!("`{field}` isn't in the filters area")))?; - let f = pt.filters.remove(i); - let at = pos.unwrap_or(pt.filters.len()).min(pt.filters.len()); - pt.filters.insert(at, f); - } - } - return Ok(json!({})); - } - // Keep axis settings (sort, grouping) when moving between rows and columns. - let kept = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned(); - let hidden = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned(); - let src_field = remove_from_area(C, pt, &field, from)?; - add_to_area(C, pt, src, &src_field, to, None, None, pos)?; - if matches!(to, "rows" | "columns") { - let list = if to == "rows" { &mut pt.rows } else { &mut pt.columns }; - if let (Some(k), Some(slot)) = (kept, list.iter_mut().find(|f| f.source_col.eq_ignore_ascii_case(&src_field))) { - *slot = k; - } - if let Some(h) = hidden.filter(|h| h.selected.is_some()) - && !pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(&src_field)) - { - pt.filters.push(h); - } - } - Ok(json!({"field": src_field, "to": to})) - }) -} - -fn value_settings(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.valueSettings"; - let field = str_param(p, "field").map(str::to_string); - let index = u32_param(p, "index").map(|v| v as usize); - let func = match str_param(p, "func") { - Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), - None => None, - }; - let show = match str_param(p, "showAs") { - Some(v) => Some(show_as_param(v).ok_or_else(|| bad(C, format!("unknown showAs `{v}`")))?), - None => None, - }; - let name = str_param(p, "name").map(str::to_string); - let fmt = p.get("numberFormat").cloned(); - update(s, p, |pt, src, _| { - let i = match (index, &field) { - (Some(i), _) => i, - (None, Some(f)) => pt - .values - .iter() - .position(|v| v.name.eq_ignore_ascii_case(f)) - .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(f))) - .ok_or_else(|| bad(C, format!("`{f}` isn't in the values area")))?, - (None, None) => { - if pt.values.len() == 1 { - 0 - } else { - return Err(bad(C, "pass `field` or `index`")); - } - } - }; - let cur = pt.values.get(i).cloned().ok_or_else(|| bad(C, "no value field at that index"))?; - let mut v = cur.clone(); - if let Some(f) = func { - v.func = f; - // An automatic caption follows the function. - let auto = auto_name(cur.func, &cur.source_col); - if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) { - v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i)); - } - } - if let Some(n) = &name { - let n = n.trim(); - if n.is_empty() { - return Err(other("The name isn't valid.")); - } - if src.col(n).is_some() { - return Err(other("PivotTable field name already exists.")); - } - if pt.values.iter().enumerate().any(|(j, x)| j != i && x.name.eq_ignore_ascii_case(n)) { - return Err(other("PivotTable field name already exists.")); - } - v.name = n.to_string(); - } - if let Some(sa) = show { - v.show_as = sa; - } - match &fmt { - Some(Json::String(f)) if !f.is_empty() && f.len() <= 255 => v.number_format = Some(f.clone()), - Some(Json::Null) => v.number_format = None, - Some(Json::String(f)) if f.is_empty() => v.number_format = None, - Some(_) => return Err(bad(C, "`numberFormat` is a format code or null")), - None => {} - } - let name = v.name.clone(); - if let Some(slot) = pt.values.get_mut(i) { - *slot = v; - } - Ok(json!({"name": name})) - }) -} - -fn filter(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.filter"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let selected: Option> = match p.get("selected") { - None | Some(Json::Null) => None, - Some(Json::Array(a)) => { - if a.len() > 100_000 { - return Err(bad(C, "too many items")); - } - let v: Vec = a - .iter() - .map(|x| match x { - Json::String(s) => s.clone(), - Json::Null => "(blank)".into(), - other => other.to_string(), - }) - .collect(); - if v.is_empty() { - return Err(other("You must select at least one item.")); - } - Some(v) - } - Some(Json::String(s)) => Some(vec![s.clone()]), - Some(_) => return Err(bad(C, "`selected` is a list of item labels or null")), - }; - update(s, p, |pt, src, _| { - let field = canonical(C, src, &field)?; - let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); - match (i, selected) { - (Some(i), None) => { - if in_axes(pt, &field) { - pt.filters.remove(i); - } else if let Some(f) = pt.filters.get_mut(i) { - f.selected = None; - } - } - (Some(i), Some(sel)) => { - if let Some(f) = pt.filters.get_mut(i) { - f.selected = Some(sel); - } - } - (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }), - (None, None) => {} - } - Ok(json!({"field": field})) - }) -} - -fn axis_field<'a>(cmd: &str, pt: &'a mut PivotTable, field: &str) -> Result<&'a mut PivotField> { - pt.rows - .iter_mut() - .chain(pt.columns.iter_mut()) - .find(|f| f.source_col.eq_ignore_ascii_case(field)) - .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the rows or columns area"))) -} - -fn sort(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.sort"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let order = match str_param(p, "order").unwrap_or("asc").to_ascii_lowercase().as_str() { - "asc" | "ascending" | "a-z" => PivotSort::Asc, - "desc" | "descending" | "z-a" => PivotSort::Desc, - "none" | "manual" | "source" => PivotSort::None, - o => return Err(bad(C, format!("unknown order `{o}` (asc|desc|none)"))), - }; - update(s, p, |pt, _, _| { - axis_field(C, pt, &field)?.sort = order; - Ok(json!({})) - }) -} - -fn group_param(v: Option<&str>) -> Option { - Some(match v?.to_ascii_lowercase().as_str() { - "years" | "year" => PivotDateGroup::Years, - "quarters" | "quarter" => PivotDateGroup::Quarters, - "months" | "month" => PivotDateGroup::Months, - "days" | "day" => PivotDateGroup::Days, - "none" | "ungroup" => PivotDateGroup::None, - _ => return None, - }) -} - -fn group(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.group"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let raw = str_param(p, "by").unwrap_or("months"); - let by = group_param(Some(raw)).ok_or_else(|| bad(C, format!("unknown grouping `{raw}` (years|quarters|months|days|none)")))?; - update(s, p, |pt, src, _| { - if by != PivotDateGroup::None { - let c = src.col(&field).ok_or_else(|| bad(C, format!("no field `{field}` in the source data")))?; - if !src.numeric.get(c).copied().unwrap_or(false) { - return Err(other("Cannot group that selection.")); - } - } - let f = axis_field(C, pt, &field)?; - f.date_group = by; - f.collapsed_items.clear(); - Ok(json!({})) - }) -} - -fn collapse(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.collapse"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let on = bool_param(p, "collapse").unwrap_or(true); - let items: Option> = - p.get("items").and_then(Json::as_array).map(|a| a.iter().take(100_000).filter_map(|x| x.as_str().map(str::to_string)).collect()); - update(s, p, |pt, src, wb| { - let all = match &items { - Some(v) => v.clone(), - None => { - let f = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)); - let g = f.map(|f| f.date_group).unwrap_or_default(); - match src.col(&field) { - Some(c) => pv::distinct_labels(wb, src, c, g, 100_000), - None => vec![], - } - } - }; - let f = axis_field(C, pt, &field)?; - if on { - for it in all { - if !f.collapsed_items.iter().any(|x| x.eq_ignore_ascii_case(&it)) { - f.collapsed_items.push(it); - } - } - } else if items.is_none() { - f.collapsed_items.clear(); - } else { - f.collapsed_items.retain(|x| !all.iter().any(|a| a.eq_ignore_ascii_case(x))); - } - Ok(json!({"collapsed": f.collapsed_items})) - }) -} - -fn layout(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.layout"; - let lay = match str_param(p, "layout").map(|x| x.to_ascii_lowercase()) { - Some(l) if l == "compact" => Some(PivotLayout::Compact), - Some(l) if l == "outline" => Some(PivotLayout::Outline), - Some(l) if l == "tabular" => Some(PivotLayout::Tabular), - Some(l) => return Err(bad(C, format!("unknown layout `{l}` (compact|outline|tabular)"))), - None => None, - }; - let subtotals: Option<(bool, Option)> = match p.get("subtotals") { - Some(Json::Bool(b)) => Some((*b, None)), - Some(Json::String(m)) => match m.to_ascii_lowercase().as_str() { - "top" => Some((true, Some(true))), - "bottom" => Some((true, Some(false))), - "none" | "off" => Some((false, None)), - o => return Err(bad(C, format!("unknown subtotals `{o}` (top|bottom|none)"))), - }, - _ => None, - }; - let style = str_param(p, "style").map(str::to_string); - if style.as_ref().is_some_and(|s| s.len() > 64) { - return Err(bad(C, "style name too long")); - } - let gr = bool_param(p, "grandTotalsRows"); - let gc = bool_param(p, "grandTotalsCols"); - let hd = bool_param(p, "showHeaders"); - update(s, p, |pt, _, _| { - if let Some(l) = lay { - pt.layout = l; - if l == PivotLayout::Tabular { - pt.subtotals_top = false; - } - } - if let Some((on, top)) = subtotals { - pt.subtotals = on; - if let Some(t) = top { - pt.subtotals_top = t; - } - } - if let Some(st) = &style { - pt.style = st.clone(); - } - if let Some(v) = gr { - pt.grand_totals_rows = v; - } - if let Some(v) = gc { - pt.grand_totals_cols = v; - } - if let Some(v) = hd { - pt.show_headers = v; - } - Ok(json!({"layout": layout_name(pt.layout)})) - }) -} - -fn grand_totals(s: &mut Session, p: &Json) -> Result { - let (r, c) = match str_param(p, "mode").map(|m| m.to_ascii_lowercase()) { - Some(m) if m == "off" => (Some(false), Some(false)), - Some(m) if m == "on" => (Some(true), Some(true)), - Some(m) if m == "rows" => (Some(true), Some(false)), - Some(m) if m == "columns" || m == "cols" => (Some(false), Some(true)), - Some(m) => return Err(bad("pivot.grandTotals", format!("unknown mode `{m}` (off|on|rows|columns)"))), - None => (bool_param(p, "rows"), bool_param(p, "cols")), - }; - let mut q = p.as_object().cloned().unwrap_or_default(); - if let Some(v) = r { - q.insert("grandTotalsRows".into(), json!(v)); - } - if let Some(v) = c { - q.insert("grandTotalsCols".into(), json!(v)); - } - layout(s, &Json::Object(q)) -} - -fn refresh(s: &mut Session, p: &Json) -> Result { - update(s, p, |_, src, _| Ok(json!({"records": src.rows.len()}))) -} - -fn refresh_all(s: &mut Session, p: &Json) -> Result { - let replace = bool_param(p, "replace").unwrap_or(false); - edit(s, |cx| { - let mut n = 0; - let mut errors = vec![]; - for si in 0..cx.wb.sheets.len() { - let count = cx.wb.sheet(si).map(|sh| sh.pivots.len()).unwrap_or(0); - for pi in 0..count { - let name = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).map(|p| p.name.clone()).unwrap_or_default(); - match pv::refresh(&mut cx.wb, si, pi, replace) { - Ok(_) => n += 1, - Err(e) => errors.push(json!({"pivot": name, "error": e})), - } - } - } - cx.structural = n > 0; - Ok(json!({"refreshed": n, "errors": errors})) - }) -} - -fn change_source(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.changeSource"; - if str_param(p, "source").or_else(|| str_param(p, "range")).is_none() { - return Err(bad(C, "missing `source`")); - } - let source = source_param(s, p)?; - let (si, _) = find_pivot(s, p)?; - let src = pv::read_source(&s.doc()?.wb, &source, si).map_err(other)?; - update(s, p, move |pt, _, _| { - pt.source = source.clone(); - let mut dropped: Vec = vec![]; - let keep = |name: &str, dropped: &mut Vec| { - let ok = src.col(name).is_some(); - if !ok { - dropped.push(name.to_string()); - } - ok - }; - // 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})) - }) -} - -fn delete(s: &mut Session, p: &Json) -> Result { - let (si, pi) = find_pivot(s, p)?; - edit(s, |cx| { - // Its PivotCharts become ordinary charts. - let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone()))); - if let Some((sheet, name)) = link { - for i in 0..cx.wb.sheets.len() { - let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name)); - if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) { - for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) { - c.pivot = None; - } - } - } - } - pv::clear_output(&mut cx.wb, si, pi); - let sh = cx.sheet_mut(si)?; - if pi < sh.pivots.len() { - sh.pivots.remove(pi); - } - cx.structural = true; - Ok(json!({})) - }) -} - -// ---------------------------------------------------------------- PivotChart - -/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report. -fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { - if let Some(t) = str_param(p, "pivot") { - let d = s.doc()?; - let (sheet, addr) = match t.rsplit_once('!') { - Some((sh, a)) => { - let sh = sh.trim(); - let sh = gridcraft_core::unquote_sheet(sh); - (d.wb.sheet_index(&sh), a) - } - None => (Some(d.wb.active_sheet()), t), - }; - if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) { - let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c)); - return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable"))); - } - } - find_pivot(s, p) -} - -fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { - let (si, pi) = chart_pivot(s, p)?; - let d = s.doc()?; - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?; - let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?; - let out = pv::compute(&d.wb, pt, si).map_err(other)?; - if out.chart_rows.is_empty() || out.chart_cols.is_empty() { - return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart.")); - } - let series = pv::chart_series(&sh.name, area.start, &out); - let t = str_param(p, "type").unwrap_or("column"); - let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or("")); - let at = - cell_param(s, p, "at")?.unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); - let title = match p.get("title") { - Some(Json::String(t)) => Some(t.clone()), - Some(Json::Null) => None, - _ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }), - }; - let id = d.wb.next_object_id(); - let link = pv::chart_link(&sh.name, &pt.name); - let pivot_name = pt.name.clone(); - let n = series.len(); - let chart = Chart { - id, - kind, - anchor: Anchor { - cell: at, - dx: 0.0, - dy: 0.0, - width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32, - height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32, - mode: anchor_mode_param(p).unwrap_or_default(), - }, - title, - series, - legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, - data_labels: false, - gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), - style: 1, - x_title: None, - y_title: None, - source: None, - by_rows: false, - pivot: Some(link), - }; - edit(s, |cx| { - cx.sheet_mut(si)?.charts.push(chart); - Ok(json!({"chart": id, "pivot": pivot_name, "series": n})) - }) -} - -// ---------------------------------------------------------------- recommended - -fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec { - let n = src.headers.len(); - let h = |i: usize| src.headers.get(i).cloned().unwrap_or_default(); - let distinct = |i: usize| pv::distinct_labels(wb, src, i, PivotDateGroup::None, 1001).len(); - let records = src.rows.len().max(1); - let is_num = |i: usize| src.numeric.get(i).copied().unwrap_or(false); - let is_date = |i: usize| src.dates.get(i).copied().unwrap_or(false); - // Category columns: text with repeats. - let cats: Vec = (0..n) - .filter(|&i| { - !is_num(i) && { - let d = distinct(i); - d >= 1 && (d < records || records <= 2) && d <= 1000 - } - }) - .collect(); - let nums: Vec = (0..n).filter(|&i| is_num(i) && !is_date(i)).collect(); - let dates: Vec = (0..n).filter(|&i| is_date(i)).collect(); - let mut out = vec![]; - let mut push = |title: String, rows: Vec, cols: Vec, vals: Vec| { - if out.len() < 5 { - let idx = out.len(); - out.push(json!({"index": idx, "title": title, "rows": rows, "columns": cols, "values": vals})); - } - }; - if let (Some(&c), Some(&v)) = (cats.first(), nums.first()) { - push(format!("Sum of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "sum"})]); - } - if let (Some(&c), Some(&c2), Some(&v)) = (cats.first(), cats.get(1), nums.first()) { - push( - format!("Sum of {} by {} and {}", h(v), h(c), h(c2)), - vec![json!(h(c))], - vec![json!(h(c2))], - vec![json!({"field": h(v), "func": "sum"})], - ); - } - if let (Some(&d), Some(&v)) = (dates.first(), nums.first()) { - push( - format!("Sum of {} by {} (quarters)", h(v), h(d)), - vec![json!({"field": h(d), "group": "quarters"})], - vec![], - vec![json!({"field": h(v), "func": "sum"})], - ); - } - if let (Some(&c), Some(&v), Some(&v2)) = (cats.first(), nums.first(), nums.get(1)) { - push( - format!("Sum of {} and {} by {}", h(v), h(v2), h(c)), - vec![json!(h(c))], - vec![], - vec![json!({"field": h(v), "func": "sum"}), json!({"field": h(v2), "func": "sum"})], - ); - } - if let Some(&c) = cats.first() { - push(format!("Count of {} by {}", h(c), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(c), "func": "count"})]); - } - if let (Some(&c), Some(&v)) = (cats.get(1), nums.first()) { - push(format!("Average of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "average"})]); - } - if cats.is_empty() - && dates.is_empty() - && let Some(&v) = nums.first() - { - push(format!("Sum of {}", h(v)), vec![], vec![], vec![json!({"field": h(v), "func": "sum"})]); - } - out -} - -fn recommended(s: &mut Session, p: &Json) -> Result { - let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet(); - let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; - let list = suggestions(&s.doc()?.wb, &src); - let Some(apply) = p.get("apply").and_then(Json::as_u64) else { - return Ok(json!({"source": source, "suggestions": list})); - }; - let pick = list.get(apply as usize).cloned().ok_or_else(|| bad("insert.recommendedPivotTables", "no suggestion with that index"))?; - let mut q = json!({ - "source": source, - "rows": pick.get("rows").cloned().unwrap_or(json!([])), - "columns": pick.get("columns").cloned().unwrap_or(json!([])), - "values": pick.get("values").cloned().unwrap_or(json!([])), - }); - if let (Some(m), Some(d)) = (q.as_object_mut(), p.get("destination")) { - m.insert("destination".into(), d.clone()); - } - if let (Some(m), Some(r)) = (q.as_object_mut(), p.get("replace")) { - m.insert("replace".into(), r.clone()); - } - let mut res = insert_pivot(s, &q)?; - if let Some(m) = res.as_object_mut() { - m.insert("suggestion".into(), pick); - } - Ok(res) -} - #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/pivot/charts.rs b/crates/engine/src/cmd/pivot/charts.rs new file mode 100644 index 000000000..0a0d72ed4 --- /dev/null +++ b/crates/engine/src/cmd/pivot/charts.rs @@ -0,0 +1,174 @@ +use super::create::{insert_pivot, source_param}; +use super::*; + +// ---------------------------------------------------------------- PivotChart + +/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report. +fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { + if let Some(t) = str_param(p, "pivot") { + let d = s.doc()?; + let (sheet, addr) = match t.rsplit_once('!') { + Some((sh, a)) => { + let sh = sh.trim(); + let sh = gridcraft_core::unquote_sheet(sh); + (d.wb.sheet_index(&sh), a) + } + None => (Some(d.wb.active_sheet()), t), + }; + if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) { + let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c)); + return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable"))); + } + } + find_pivot(s, p) +} + +pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { + let (si, pi) = chart_pivot(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?; + let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?; + let out = pv::compute(&d.wb, pt, si).map_err(other)?; + if out.chart_rows.is_empty() || out.chart_cols.is_empty() { + return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart.")); + } + let series = pv::chart_series(&sh.name, area.start, &out); + let t = str_param(p, "type").unwrap_or("column"); + let kind = crate::cmd::insert::chart_kind(t, str_param(p, "subtype").unwrap_or("")); + let at = + cell_param(s, p, "at")?.unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); + let title = match p.get("title") { + Some(Json::String(t)) => Some(t.clone()), + Some(Json::Null) => None, + _ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }), + }; + let id = d.wb.next_object_id(); + let link = pv::chart_link(&sh.name, &pt.name); + let pivot_name = pt.name.clone(); + let n = series.len(); + let chart = Chart { + id, + kind, + anchor: Anchor { + cell: at, + dx: 0.0, + dy: 0.0, + width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32, + height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32, + mode: anchor_mode_param(p).unwrap_or_default(), + }, + title, + series, + legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, + data_labels: false, + gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), + style: 1, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: Some(link), + }; + edit(s, |cx| { + cx.sheet_mut(si)?.charts.push(chart); + Ok(json!({"chart": id, "pivot": pivot_name, "series": n})) + }) +} + +// ---------------------------------------------------------------- recommended + +fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec { + let n = src.headers.len(); + let h = |i: usize| src.headers.get(i).cloned().unwrap_or_default(); + let distinct = |i: usize| pv::distinct_labels(wb, src, i, PivotDateGroup::None, 1001).len(); + let records = src.rows.len().max(1); + let is_num = |i: usize| src.numeric.get(i).copied().unwrap_or(false); + let is_date = |i: usize| src.dates.get(i).copied().unwrap_or(false); + // Category columns: text with repeats. + let cats: Vec = (0..n) + .filter(|&i| { + !is_num(i) && { + let d = distinct(i); + d >= 1 && (d < records || records <= 2) && d <= 1000 + } + }) + .collect(); + let nums: Vec = (0..n).filter(|&i| is_num(i) && !is_date(i)).collect(); + let dates: Vec = (0..n).filter(|&i| is_date(i)).collect(); + let mut out = vec![]; + let mut push = |title: String, rows: Vec, cols: Vec, vals: Vec| { + if out.len() < 5 { + let idx = out.len(); + out.push(json!({"index": idx, "title": title, "rows": rows, "columns": cols, "values": vals})); + } + }; + if let (Some(&c), Some(&v)) = (cats.first(), nums.first()) { + push(format!("Sum of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "sum"})]); + } + if let (Some(&c), Some(&c2), Some(&v)) = (cats.first(), cats.get(1), nums.first()) { + push( + format!("Sum of {} by {} and {}", h(v), h(c), h(c2)), + vec![json!(h(c))], + vec![json!(h(c2))], + vec![json!({"field": h(v), "func": "sum"})], + ); + } + if let (Some(&d), Some(&v)) = (dates.first(), nums.first()) { + push( + format!("Sum of {} by {} (quarters)", h(v), h(d)), + vec![json!({"field": h(d), "group": "quarters"})], + vec![], + vec![json!({"field": h(v), "func": "sum"})], + ); + } + if let (Some(&c), Some(&v), Some(&v2)) = (cats.first(), nums.first(), nums.get(1)) { + push( + format!("Sum of {} and {} by {}", h(v), h(v2), h(c)), + vec![json!(h(c))], + vec![], + vec![json!({"field": h(v), "func": "sum"}), json!({"field": h(v2), "func": "sum"})], + ); + } + if let Some(&c) = cats.first() { + push(format!("Count of {} by {}", h(c), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(c), "func": "count"})]); + } + if let (Some(&c), Some(&v)) = (cats.get(1), nums.first()) { + push(format!("Average of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "average"})]); + } + if cats.is_empty() + && dates.is_empty() + && let Some(&v) = nums.first() + { + push(format!("Sum of {}", h(v)), vec![], vec![], vec![json!({"field": h(v), "func": "sum"})]); + } + out +} + +pub(super) fn recommended(s: &mut Session, p: &Json) -> Result { + let source = source_param(s, p)?; + let active = s.doc()?.wb.active_sheet(); + let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; + let list = suggestions(&s.doc()?.wb, &src); + let Some(apply) = p.get("apply").and_then(Json::as_u64) else { + return Ok(json!({"source": source, "suggestions": list})); + }; + let pick = list.get(apply as usize).cloned().ok_or_else(|| bad("insert.recommendedPivotTables", "no suggestion with that index"))?; + let mut q = json!({ + "source": source, + "rows": pick.get("rows").cloned().unwrap_or(json!([])), + "columns": pick.get("columns").cloned().unwrap_or(json!([])), + "values": pick.get("values").cloned().unwrap_or(json!([])), + }); + if let (Some(m), Some(d)) = (q.as_object_mut(), p.get("destination")) { + m.insert("destination".into(), d.clone()); + } + if let (Some(m), Some(r)) = (q.as_object_mut(), p.get("replace")) { + m.insert("replace".into(), r.clone()); + } + let mut res = insert_pivot(s, &q)?; + if let Some(m) = res.as_object_mut() { + m.insert("suggestion".into(), pick); + } + Ok(res) +} diff --git a/crates/engine/src/cmd/pivot/create.rs b/crates/engine/src/cmd/pivot/create.rs new file mode 100644 index 000000000..48770f3a0 --- /dev/null +++ b/crates/engine/src/cmd/pivot/create.rs @@ -0,0 +1,340 @@ +use super::fields::{add_to_area, in_axes}; +use super::params::{func_name, func_param, group_name, group_param, layout_name, show_as_name, sort_name}; +use super::*; + +// ---------------------------------------------------------------- insert + +/// The default source: the table at the selection, else the selected block / current region. +fn default_source(s: &Session) -> Result { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + if let Some(t) = sh.table_at(d.selection.active) { + return Ok(t.name.clone()); + } + let sel = d.selection.current(); + let r = if sel.is_single() { + current_region(sh, d.selection.active) + } else { + let used = sh.used_range().unwrap_or(sel); + sel.intersection(&used).unwrap_or(sel) + }; + if r.is_single() && sh.value(r.start).is_empty() { + return Err(other("The reference isn't valid. Select a cell in a range with labeled columns, or pass `source`.")); + } + Ok(pv::range_text(&sh.name, r)) +} + +pub(super) fn source_param(s: &Session, p: &Json) -> Result { + match str_param(p, "source").or_else(|| str_param(p, "range")) { + Some(t) if !t.trim().is_empty() => { + // Bare ranges are on the active sheet; store them with the sheet name. + let d = s.doc()?; + let t = t.trim().trim_start_matches('='); + if !t.contains('!') + && RangeRef::parse(t).is_some() + && let Some(sh) = d.wb.active() + && let Some(r) = RangeRef::parse(t) + { + return Ok(pv::range_text(&sh.name, r)); + } + Ok(t.to_string()) + } + _ => default_source(s), + } +} + +fn next_pivot_name(wb: &Workbook) -> String { + (1..) + .map(|n| format!("PivotTable{n}")) + .find(|n| !wb.sheets.iter().any(|s| s.pivots.iter().any(|p| p.name.eq_ignore_ascii_case(n)))) + .unwrap_or_default() +} + +fn next_pivot_id(wb: &Workbook) -> u32 { + let m = wb.sheets.iter().flat_map(|s| s.pivots.iter().map(|p| p.id)).max().unwrap_or(0); + wb.next_object_id().max(m.saturating_add(1)) +} + +fn field_list(v: Option<&Json>) -> Vec { + match v { + Some(Json::Array(a)) => a.iter().take(512).cloned().collect(), + Some(Json::String(s)) => vec![Json::String(s.clone())], + _ => vec![], + } +} + +pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { + return Err(other("The workbook is protected and cannot be changed.")); + } + let source = source_param(s, p)?; + let active = s.doc()?.wb.active_sheet(); + // Validate the source now (header names, size). + let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; + let name = match str_param(p, "name") { + Some(n) if !n.trim().is_empty() => { + let n = n.trim().to_string(); + if s.doc()?.wb.sheets.iter().any(|sh| sh.pivots.iter().any(|x| x.name.eq_ignore_ascii_case(&n))) { + return Err(other("A PivotTable with that name already exists.")); + } + n + } + _ => next_pivot_name(&s.doc()?.wb), + }; + let dest = str_param(p, "destination").or_else(|| str_param(p, "location")).unwrap_or("new").trim().to_string(); + // Existing sheet destination: (sheet index, anchor). + let target: Option<(usize, CellRef)> = if dest.eq_ignore_ascii_case("new") || dest.is_empty() { + None + } else { + let d = s.doc()?; + let (sh, body) = crate::cmd::split_sheet(&dest); + let si = match sh { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("insert.pivotTable", format!("no sheet named `{n}`")))?, + None => active, + }; + let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; + Some((si, at)) + }; + let replace = bool_param(p, "replace").unwrap_or(false); + let rows = field_list(p.get("rows")); + let columns = field_list(p.get("columns")); + let values = field_list(p.get("values")); + let filters = field_list(p.get("filters")); + let mut new_sheet_at: Option = None; + let res = edit(s, |cx| { + let id = next_pivot_id(&cx.wb); + let (si, anchor) = match target { + Some(t) => t, + None => { + let at = insert_sheet_before_active(cx); + new_sheet_at = Some(at); + (at, CellRef::new(2, 0)) + } + }; + let mut pt = PivotTable { id, name: name.clone(), source: source.clone(), anchor, ..Default::default() }; + let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> { + for f in list { + match f { + Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?, + Json::Object(_) => { + let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?; + let func = match str_param(f, "func") { + Some(fname) => { + Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?) + } + None => None, + }; + add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?; + let by = group_param(str_param(f, "group")); + if let Some(by) = by.filter(|b| *b != PivotDateGroup::None) + && matches!(area, "rows" | "columns") + && src.col(n).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false) + && let Some(af) = pt.rows.iter_mut().chain(pt.columns.iter_mut()).find(|x| x.source_col.eq_ignore_ascii_case(n)) + { + af.date_group = by; + } + } + _ => return Err(bad("insert.pivotTable", "fields are names or {field, func?, name?}")), + } + } + Ok(()) + }; + add(&mut pt, &rows, "rows")?; + add(&mut pt, &columns, "columns")?; + add(&mut pt, &values, "values")?; + add(&mut pt, &filters, "filters")?; + let sh = cx.sheet_mut(si)?; + sh.pivots.push(pt); + let pi = sh.pivots.len() - 1; + let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; + cx.wb.set_active_sheet(si); + *cx.sel = crate::Selection::at(anchor); + cx.structural = true; + let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); + Ok(json!({ + "id": id, + "name": name, + "sheet": sheet_name, + "anchor": anchor.a1(), + "range": area.a1(), + "source": source, + "fields": src.headers, + })) + })?; + if let Some(at) = new_sheet_at + && let Ok(d) = s.doc_mut() + { + d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(res) +} + +/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a +/// Show Details sheet); returns its index. +fn insert_sheet_before_active(cx: &mut Ctx) -> usize { + let at = cx.wb.active_sheet().min(cx.wb.sheets.len()); + let nm = cx.wb.next_sheet_name(); + cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); + for n in cx.wb.names.iter_mut() { + if let Some(sc) = n.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + at +} + +/// Show Details (double-click a value cell): the source records behind it, as values with their +/// column's number format, under the source headers on a new sheet. +pub(super) fn show_details(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let active = d.wb.active_sheet(); + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let pt = sh + .pivots + .iter() + .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) + .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + // With the option off, a report that is out of date or a source that cannot be read is + // reported as the option; only a cell that is not a value cell keeps its own message. + if !pt.enable_show_details && !matches!(found, Ok(None)) { + return Err(other(SHOW_DETAILS_OFF)); + } + let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; + if d.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } + let count = records.len(); + let mut new_sheet_at: Option = None; + let res = edit(s, |cx| { + let si = insert_sheet_before_active(cx); + new_sheet_at = Some(si); + let base = cx.wb.styles.get(StyleId::default()).clone(); + let fmts: Vec = src + .formats + .iter() + .map(|f| { + let mut st = base.clone(); + st.num_fmt = NumFmt::new(f); + cx.wb.styles.intern(st) + }) + .collect(); + // Columns wide enough for their text, as the PivotTable report's are (dates would show + // as ####); the first rows are enough to judge a column. + let font = base.font.clone(); + let widths: Vec = src + .headers + .iter() + .enumerate() + .map(|(c, h)| { + let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General"); + let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c))); + let widest = texts + .map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false)) + .fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max); + (widest + 14.0).min(320.0) + }) + .collect(); + let sh = cx.sheet_mut(si)?; + for (c, w) in widths.into_iter().enumerate() { + if w > sh.col_width(c as u32) { + sh.cols.entry(c as u32).or_default().size = Some(w); + } + } + for (c, h) in src.headers.iter().enumerate() { + sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); + } + for (r, i) in records.iter().enumerate() { + let Some(rec) = src.rows.get(*i) else { continue }; + for (c, v) in rec.iter().enumerate() { + let style = fmts.get(c).copied().unwrap_or_default(); + if v.is_empty() && style == StyleId::default() { + continue; + } + sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); + } + } + let name = sh.name.clone(); + cx.wb.set_active_sheet(si); + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(json!({"sheet": name, "records": count})) + })?; + if let Some(at) = new_sheet_at + && let Ok(d) = s.doc_mut() + { + d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(res) +} + +/// The message Show Details gives while Enable show details is off. +const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable."; + +/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not +/// refreshed. Without params it returns the current setting. +pub(super) fn options(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + let enable = bool_param(p, "enableShowDetails"); + if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() { + return Err(bad("pivot.options", "`enableShowDetails` must be true or false")); + } + let current = |s: &Session| -> Result { + let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + Ok(json!({"enableShowDetails": pt.enable_show_details})) + }; + let Some(on) = enable else { return current(s) }; + if current(s)?["enableShowDetails"] == json!(on) { + return current(s); + } + edit(s, |cx| { + let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?; + pt.enable_show_details = on; + Ok(json!({"enableShowDetails": on})) + }) +} + +pub(super) fn summarize_table(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let name = match str_param(p, "table") { + Some(n) => d.wb.table(n).and_then(|(si, ti)| d.wb.sheet(si).and_then(|sh| sh.tables.get(ti))).map(|t| t.name.clone()), + None => d.wb.active().and_then(|sh| sh.table_at(d.selection.active)).map(|t| t.name.clone()), + } + .ok_or_else(|| EngineError::Disabled("table.summarizePivot".into(), "select a cell in a table".into()))?; + let mut q = with(p, "source", json!(name)); + if let Some(m) = q.as_object_mut() { + m.remove("table"); + } + insert_pivot(s, &q) +} + +// ---------------------------------------------------------------- queries + +pub(super) fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { + json!({ + "id": pt.id, + "name": pt.name, + "sheet": wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), + "anchor": pt.anchor.a1(), + "range": pt.last_range.map(|r| r.a1()), + "source": pt.source, + "rows": pt.rows.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), + "columns": pt.columns.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), + "values": pt.values.iter().map(|v| json!({"field": v.source_col, "func": func_name(v.func), "name": v.name, "showAs": show_as_name(v.show_as), "numberFormat": v.number_format})).collect::>(), + "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::>(), + "layout": layout_name(pt.layout), + "grandTotalsRows": pt.grand_totals_rows, + "grandTotalsCols": pt.grand_totals_cols, + "subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" }, + "style": pt.style, + "showHeaders": pt.show_headers, + "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + "valuesOnRows": pt.values_on_rows, + "valuesPosition": pt.values_position, + "enableShowDetails": pt.enable_show_details, + }) +} diff --git a/crates/engine/src/cmd/pivot/fields.rs b/crates/engine/src/cmd/pivot/fields.rs new file mode 100644 index 000000000..344a60af3 --- /dev/null +++ b/crates/engine/src/cmd/pivot/fields.rs @@ -0,0 +1,541 @@ +use super::create::pivot_json; +use super::params::{area_param, func_param, position_param, show_as_param}; +use super::*; + +// ---------------------------------------------------------------- layout editing (pure) + +pub(super) 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. +pub(super) 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 { + 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) { + 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) -> Result { + 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`. +pub(super) fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result { + src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) +} + +/// The calculated field named `field`, if any. +fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> { + pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim())) +} + +/// The canonical name of a source or calculated field. +fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result { + match calc_field(pt, field) { + Some(c) if src.col(field).is_none() => Ok(c.name.clone()), + _ => canonical(cmd, src, field), + } +} + +fn unique_value_name(pt: &PivotTable, base: &str, except: Option) -> String { + let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n)); + if !taken(base) { + return base.to_string(); + } + (2..10_000).map(|k| format!("{base}{k}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} + +fn auto_name(f: PivotFunc, field: &str) -> String { + format!("{} of {field}", pv::func_caption(f)) +} + +/// Adds `field` to an area (moving it out of the other axis areas). +pub(super) fn add_to_area( + cmd: &str, + pt: &mut PivotTable, + src: &pv::Source, + field: &str, + area: &str, + func: Option, + name: Option<&str>, + position: Option, +) -> Result<()> { + let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some(); + if is_calc && area != "values" { + return Err(other("A calculated field can only be placed in the Values area.")); + } + let field = canonical_any(cmd, pt, src, field)?; + match area { + "rows" | "columns" => { + 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 = 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())); + insert_axis_field(pt, area == "rows", existing, position); + } + "values" => { + if pt.values.len() >= pv::MAX_VALUE_FIELDS { + return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS))); + } + let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false); + let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count }); + let base = match name { + Some(n) if !n.trim().is_empty() => { + if src.col(n).is_some() { + return Err(other("PivotTable field name already exists.")); + } + n.to_string() + } + _ => auto_name(f, &field), + }; + let nm = unique_value_name(pt, &base, None); + let v = PivotValue { source_col: field, func: f, name: nm, show_as: PivotShowAs::Normal, number_format: None }; + let at = position.unwrap_or(pt.values.len()).min(pt.values.len()); + pt.values.insert(at, v); + } + _ => { + 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), + None => PivotFilter { source_col: field, selected: None }, + }; + let at = position.unwrap_or(pt.filters.len()).min(pt.filters.len()); + pt.filters.insert(at, entry); + } + } + Ok(()) +} + +/// Removes `field` from an area; returns the source field it referred to. +fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result { + match area { + "rows" | "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) + } + "values" => { + let i = pt + .values + .iter() + .position(|v| v.name.eq_ignore_ascii_case(field)) + .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(field))) + .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the values area")))?; + Ok(pt.values.remove(i).source_col) + } + _ => { + let i = pt + .filters + .iter() + .position(|f| f.source_col.eq_ignore_ascii_case(field) && !in_axes(pt, &f.source_col)) + .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the filters area")))?; + Ok(pt.filters.remove(i).source_col) + } + } +} + +// ---------------------------------------------------------------- shared update + +/// Applies `f` to the targeted pivot and re-renders it, as one undo step. +pub(super) fn update(s: &mut Session, p: &Json, f: impl FnOnce(&mut PivotTable, &pv::Source, &Workbook) -> Result) -> Result { + let (si, pi) = find_pivot(s, p)?; + let replace = bool_param(p, "replace").unwrap_or(false); + edit(s, |cx| { + let pt = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).cloned().ok_or_else(|| other("There's no such PivotTable."))?; + let src = pv::read_source(&cx.wb, &pt.source, si).map_err(other)?; + let mut new = pt.clone(); + let mut out = f(&mut new, &src, &cx.wb)?; + if let Some(slot) = cx.sheet_mut(si)?.pivots.get_mut(pi) { + *slot = new; + } + let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; + cx.structural = true; + if out.is_null() { + out = json!({}); + } + if let Some(m) = out.as_object_mut() { + m.insert("range".into(), json!(area.a1())); + } + Ok(out) + }) +} + +pub(super) fn fields(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + let d = s.doc()?; + let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + let mut out = pivot_json(&d.wb, si, pt); + let src = pv::read_source(&d.wb, &pt.source, si); + let list: Vec = match &src { + Ok(src) => src + .headers + .iter() + .enumerate() + .map(|(c, h)| { + let mut areas = vec![]; + if pt.rows.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { + areas.push("rows"); + } + if pt.columns.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { + areas.push("columns"); + } + if pt.values.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { + areas.push("values"); + } + if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) && !in_axes(pt, h) { + areas.push("filters"); + } + json!({ + "name": h, + "numeric": src.numeric.get(c).copied().unwrap_or(false), + "date": src.dates.get(c).copied().unwrap_or(false), + "samples": pv::distinct_labels(&d.wb, src, c, PivotDateGroup::None, 5), + "areas": areas, + }) + }) + .collect(), + Err(_) => vec![], + }; + let mut list = list; + for c in &pt.calculated_fields { + let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] }; + list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula})); + } + if let Some(m) = out.as_object_mut() { + m.insert("fields".into(), Json::Array(list)); + if let Ok(src) = &src { + m.insert("records".into(), json!(src.rows.len())); + } + if let Err(e) = src { + m.insert("error".into(), json!(e)); + } + } + Ok(out) +} + +pub(super) fn list(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let mut v = vec![]; + for (si, sh) in d.wb.sheets.iter().enumerate() { + for pt in &sh.pivots { + v.push(pivot_json(&d.wb, si, pt)); + } + } + Ok(Json::Array(v)) +} + +pub(super) fn add_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.addField"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let area = area_param(C, str_param(p, "area"))?; + let func = match str_param(p, "func") { + Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), + None => None, + }; + let name = str_param(p, "name").map(str::to_string); + let pos = position_param(p); + update(s, p, |pt, src, _| { + add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?; + Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area})) + }) +} + +pub(super) fn calculated_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.calculatedField"; + let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { + // Menu-style call: the Insert Calculated Field dialog. + let (si, pi) = find_pivot(s, p)?; + let d = s.doc()?; + let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into()); + let pivot = pt.name.clone(); + s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"}))); + return ok(); + }; + if name.is_empty() || name.chars().count() > 255 { + return Err(bad(C, "`name` must be 1 to 255 characters")); + } + let delete = bool_param(p, "delete").unwrap_or(false); + let formula = str_param(p, "formula").map(|f| { + let f = f.trim(); + if f.starts_with('=') { f.to_string() } else { format!("={f}") } + }); + let to_values = bool_param(p, "values").unwrap_or(true); + if !delete && formula.is_none() { + return Err(bad(C, "missing `formula`")); + } + update(s, p, |pt, src, _| { + let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name)); + if delete { + let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?; + let gone = pt.calculated_fields.remove(i); + pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name)); + return Ok(json!({"name": gone.name, "deleted": true})); + } + let formula = formula.clone().unwrap_or_default(); + if src.col(&name).is_some() { + return Err(other("PivotTable field name already exists.")); + } + let canon = match existing { + Some(i) => { + let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?; + c.formula = formula.clone(); + c.name.clone() + } + None => { + if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS { + return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS))); + } + pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() }); + name.clone() + } + }; + let set = crate::pivot_calc::CalcSet::new(pt, &src.headers); + let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?; + if let Some(e) = set.error(i) { + return Err(bad(C, e.to_string())); + } + if set.is_cyclic(i) { + return Err(bad(C, format!("`{canon}` refers to itself through its formula"))); + } + if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) { + add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?; + } + Ok(json!({ + "name": canon, + "formula": formula, + "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + })) + }) +} + +pub(super) fn remove_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.removeField"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let area = match str_param(p, "area") { + Some(a) => Some(area_param(C, Some(a))?), + None => None, + }; + update(s, p, |pt, _, _| { + match area { + Some(a) => { + remove_from_area(C, pt, &field, a)?; + } + None => { + let mut any = false; + for a in ["rows", "columns", "values", "filters"] { + any |= remove_from_area(C, pt, &field, a).is_ok(); + } + if !any { + return Err(bad(C, format!("`{field}` isn't in the PivotTable"))); + } + } + } + Ok(json!({})) + }) +} + +pub(super) fn move_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.moveField"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let from = area_param(C, str_param(p, "from"))?; + 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 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); + shift_values(pt, rows, i, false); + insert_axis_field(pt, rows, f, pos); + } + "values" => { + let i = pt + .values + .iter() + .position(|v| v.name.eq_ignore_ascii_case(&field)) + .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(&field))) + .ok_or_else(|| bad(C, format!("`{field}` isn't in the values area")))?; + let v = pt.values.remove(i); + let at = pos.unwrap_or(pt.values.len()).min(pt.values.len()); + pt.values.insert(at, v); + } + _ => { + let i = pt + .filters + .iter() + .position(|f| f.source_col.eq_ignore_ascii_case(&field)) + .ok_or_else(|| bad(C, format!("`{field}` isn't in the filters area")))?; + let f = pt.filters.remove(i); + let at = pos.unwrap_or(pt.filters.len()).min(pt.filters.len()); + pt.filters.insert(at, f); + } + } + return Ok(json!({})); + } + // Keep axis settings (sort, grouping) when moving between rows and columns. + let kept = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned(); + let hidden = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned(); + let src_field = remove_from_area(C, pt, &field, from)?; + add_to_area(C, pt, src, &src_field, to, None, None, pos)?; + if matches!(to, "rows" | "columns") { + let list = if to == "rows" { &mut pt.rows } else { &mut pt.columns }; + if let (Some(k), Some(slot)) = (kept, list.iter_mut().find(|f| f.source_col.eq_ignore_ascii_case(&src_field))) { + *slot = k; + } + if let Some(h) = hidden.filter(|h| h.selected.is_some()) + && !pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(&src_field)) + { + pt.filters.push(h); + } + } + Ok(json!({"field": src_field, "to": to})) + }) +} + +pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.valueSettings"; + let field = str_param(p, "field").map(str::to_string); + let index = u32_param(p, "index").map(|v| v as usize); + let func = match str_param(p, "func") { + Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), + None => None, + }; + let show = match str_param(p, "showAs") { + Some(v) => Some(show_as_param(v).ok_or_else(|| bad(C, format!("unknown showAs `{v}`")))?), + None => None, + }; + let name = str_param(p, "name").map(str::to_string); + let fmt = p.get("numberFormat").cloned(); + update(s, p, |pt, src, _| { + let i = match (index, &field) { + (Some(i), _) => i, + (None, Some(f)) => pt + .values + .iter() + .position(|v| v.name.eq_ignore_ascii_case(f)) + .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(f))) + .ok_or_else(|| bad(C, format!("`{f}` isn't in the values area")))?, + (None, None) => { + if pt.values.len() == 1 { + 0 + } else { + return Err(bad(C, "pass `field` or `index`")); + } + } + }; + let cur = pt.values.get(i).cloned().ok_or_else(|| bad(C, "no value field at that index"))?; + let mut v = cur.clone(); + if let Some(f) = func { + v.func = f; + // An automatic caption follows the function. + let auto = auto_name(cur.func, &cur.source_col); + if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) { + v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i)); + } + } + if let Some(n) = &name { + let n = n.trim(); + if n.is_empty() { + return Err(other("The name isn't valid.")); + } + if src.col(n).is_some() { + return Err(other("PivotTable field name already exists.")); + } + if pt.values.iter().enumerate().any(|(j, x)| j != i && x.name.eq_ignore_ascii_case(n)) { + return Err(other("PivotTable field name already exists.")); + } + v.name = n.to_string(); + } + if let Some(sa) = show { + v.show_as = sa; + } + match &fmt { + Some(Json::String(f)) if !f.is_empty() && f.len() <= 255 => v.number_format = Some(f.clone()), + Some(Json::Null) => v.number_format = None, + Some(Json::String(f)) if f.is_empty() => v.number_format = None, + Some(_) => return Err(bad(C, "`numberFormat` is a format code or null")), + None => {} + } + let name = v.name.clone(); + if let Some(slot) = pt.values.get_mut(i) { + *slot = v; + } + Ok(json!({"name": name})) + }) +} diff --git a/crates/engine/src/cmd/pivot/layout.rs b/crates/engine/src/cmd/pivot/layout.rs new file mode 100644 index 000000000..b3582ea46 --- /dev/null +++ b/crates/engine/src/cmd/pivot/layout.rs @@ -0,0 +1,289 @@ +use super::create::source_param; +use super::fields::{canonical, in_axes, shift_values, update}; +use super::params::{group_param, layout_name}; +use super::*; + +pub(super) fn filter(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.filter"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let selected: Option> = match p.get("selected") { + None | Some(Json::Null) => None, + Some(Json::Array(a)) => { + if a.len() > 100_000 { + return Err(bad(C, "too many items")); + } + let v: Vec = a + .iter() + .map(|x| match x { + Json::String(s) => s.clone(), + Json::Null => "(blank)".into(), + other => other.to_string(), + }) + .collect(); + if v.is_empty() { + return Err(other("You must select at least one item.")); + } + Some(v) + } + Some(Json::String(s)) => Some(vec![s.clone()]), + Some(_) => return Err(bad(C, "`selected` is a list of item labels or null")), + }; + update(s, p, |pt, src, _| { + let field = canonical(C, src, &field)?; + let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); + match (i, selected) { + (Some(i), None) => { + if in_axes(pt, &field) { + pt.filters.remove(i); + } else if let Some(f) = pt.filters.get_mut(i) { + f.selected = None; + } + } + (Some(i), Some(sel)) => { + if let Some(f) = pt.filters.get_mut(i) { + f.selected = Some(sel); + } + } + (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }), + (None, None) => {} + } + Ok(json!({"field": field})) + }) +} + +fn axis_field<'a>(cmd: &str, pt: &'a mut PivotTable, field: &str) -> Result<&'a mut PivotField> { + pt.rows + .iter_mut() + .chain(pt.columns.iter_mut()) + .find(|f| f.source_col.eq_ignore_ascii_case(field)) + .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the rows or columns area"))) +} + +pub(super) fn sort(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.sort"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let order = match str_param(p, "order").unwrap_or("asc").to_ascii_lowercase().as_str() { + "asc" | "ascending" | "a-z" => PivotSort::Asc, + "desc" | "descending" | "z-a" => PivotSort::Desc, + "none" | "manual" | "source" => PivotSort::None, + o => return Err(bad(C, format!("unknown order `{o}` (asc|desc|none)"))), + }; + update(s, p, |pt, _, _| { + axis_field(C, pt, &field)?.sort = order; + Ok(json!({})) + }) +} + +pub(super) fn group(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.group"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let raw = str_param(p, "by").unwrap_or("months"); + let by = group_param(Some(raw)).ok_or_else(|| bad(C, format!("unknown grouping `{raw}` (years|quarters|months|days|none)")))?; + update(s, p, |pt, src, _| { + if by != PivotDateGroup::None { + let c = src.col(&field).ok_or_else(|| bad(C, format!("no field `{field}` in the source data")))?; + if !src.numeric.get(c).copied().unwrap_or(false) { + return Err(other("Cannot group that selection.")); + } + } + let f = axis_field(C, pt, &field)?; + f.date_group = by; + f.collapsed_items.clear(); + Ok(json!({})) + }) +} + +pub(super) fn collapse(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.collapse"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let on = bool_param(p, "collapse").unwrap_or(true); + let items: Option> = + p.get("items").and_then(Json::as_array).map(|a| a.iter().take(100_000).filter_map(|x| x.as_str().map(str::to_string)).collect()); + update(s, p, |pt, src, wb| { + let all = match &items { + Some(v) => v.clone(), + None => { + let f = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)); + let g = f.map(|f| f.date_group).unwrap_or_default(); + match src.col(&field) { + Some(c) => pv::distinct_labels(wb, src, c, g, 100_000), + None => vec![], + } + } + }; + let f = axis_field(C, pt, &field)?; + if on { + for it in all { + if !f.collapsed_items.iter().any(|x| x.eq_ignore_ascii_case(&it)) { + f.collapsed_items.push(it); + } + } + } else if items.is_none() { + f.collapsed_items.clear(); + } else { + f.collapsed_items.retain(|x| !all.iter().any(|a| a.eq_ignore_ascii_case(x))); + } + Ok(json!({"collapsed": f.collapsed_items})) + }) +} + +pub(super) fn layout(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.layout"; + let lay = match str_param(p, "layout").map(|x| x.to_ascii_lowercase()) { + Some(l) if l == "compact" => Some(PivotLayout::Compact), + Some(l) if l == "outline" => Some(PivotLayout::Outline), + Some(l) if l == "tabular" => Some(PivotLayout::Tabular), + Some(l) => return Err(bad(C, format!("unknown layout `{l}` (compact|outline|tabular)"))), + None => None, + }; + let subtotals: Option<(bool, Option)> = match p.get("subtotals") { + Some(Json::Bool(b)) => Some((*b, None)), + Some(Json::String(m)) => match m.to_ascii_lowercase().as_str() { + "top" => Some((true, Some(true))), + "bottom" => Some((true, Some(false))), + "none" | "off" => Some((false, None)), + o => return Err(bad(C, format!("unknown subtotals `{o}` (top|bottom|none)"))), + }, + _ => None, + }; + let style = str_param(p, "style").map(str::to_string); + if style.as_ref().is_some_and(|s| s.len() > 64) { + return Err(bad(C, "style name too long")); + } + let gr = bool_param(p, "grandTotalsRows"); + let gc = bool_param(p, "grandTotalsCols"); + let hd = bool_param(p, "showHeaders"); + update(s, p, |pt, _, _| { + if let Some(l) = lay { + pt.layout = l; + if l == PivotLayout::Tabular { + pt.subtotals_top = false; + } + } + if let Some((on, top)) = subtotals { + pt.subtotals = on; + if let Some(t) = top { + pt.subtotals_top = t; + } + } + if let Some(st) = &style { + pt.style = st.clone(); + } + if let Some(v) = gr { + pt.grand_totals_rows = v; + } + if let Some(v) = gc { + pt.grand_totals_cols = v; + } + if let Some(v) = hd { + pt.show_headers = v; + } + Ok(json!({"layout": layout_name(pt.layout)})) + }) +} + +pub(super) fn grand_totals(s: &mut Session, p: &Json) -> Result { + let (r, c) = match str_param(p, "mode").map(|m| m.to_ascii_lowercase()) { + Some(m) if m == "off" => (Some(false), Some(false)), + Some(m) if m == "on" => (Some(true), Some(true)), + Some(m) if m == "rows" => (Some(true), Some(false)), + Some(m) if m == "columns" || m == "cols" => (Some(false), Some(true)), + Some(m) => return Err(bad("pivot.grandTotals", format!("unknown mode `{m}` (off|on|rows|columns)"))), + None => (bool_param(p, "rows"), bool_param(p, "cols")), + }; + let mut q = p.as_object().cloned().unwrap_or_default(); + if let Some(v) = r { + q.insert("grandTotalsRows".into(), json!(v)); + } + if let Some(v) = c { + q.insert("grandTotalsCols".into(), json!(v)); + } + layout(s, &Json::Object(q)) +} + +pub(super) fn refresh(s: &mut Session, p: &Json) -> Result { + update(s, p, |_, src, _| Ok(json!({"records": src.rows.len()}))) +} + +pub(super) fn refresh_all(s: &mut Session, p: &Json) -> Result { + let replace = bool_param(p, "replace").unwrap_or(false); + edit(s, |cx| { + let mut n = 0; + let mut errors = vec![]; + for si in 0..cx.wb.sheets.len() { + let count = cx.wb.sheet(si).map(|sh| sh.pivots.len()).unwrap_or(0); + for pi in 0..count { + let name = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).map(|p| p.name.clone()).unwrap_or_default(); + match pv::refresh(&mut cx.wb, si, pi, replace) { + Ok(_) => n += 1, + Err(e) => errors.push(json!({"pivot": name, "error": e})), + } + } + } + cx.structural = n > 0; + Ok(json!({"refreshed": n, "errors": errors})) + }) +} + +pub(super) fn change_source(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.changeSource"; + if str_param(p, "source").or_else(|| str_param(p, "range")).is_none() { + return Err(bad(C, "missing `source`")); + } + let source = source_param(s, p)?; + let (si, _) = find_pivot(s, p)?; + let src = pv::read_source(&s.doc()?.wb, &source, si).map_err(other)?; + update(s, p, move |pt, _, _| { + pt.source = source.clone(); + let mut dropped: Vec = vec![]; + let keep = |name: &str, dropped: &mut Vec| { + let ok = src.col(name).is_some(); + if !ok { + dropped.push(name.to_string()); + } + ok + }; + // 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})) + }) +} + +pub(super) fn delete(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + edit(s, |cx| { + // Its PivotCharts become ordinary charts. + let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone()))); + if let Some((sheet, name)) = link { + for i in 0..cx.wb.sheets.len() { + let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name)); + if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) { + for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) { + c.pivot = None; + } + } + } + } + pv::clear_output(&mut cx.wb, si, pi); + let sh = cx.sheet_mut(si)?; + if pi < sh.pivots.len() { + sh.pivots.remove(pi); + } + cx.structural = true; + Ok(json!({})) + }) +} diff --git a/crates/engine/src/cmd/pivot/params.rs b/crates/engine/src/cmd/pivot/params.rs new file mode 100644 index 000000000..8aa098479 --- /dev/null +++ b/crates/engine/src/cmd/pivot/params.rs @@ -0,0 +1,111 @@ +use super::*; + +pub(super) fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> { + match v.map(str::to_ascii_lowercase) { + Some(a) if a == "rows" || a == "row" => Ok("rows"), + Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"), + Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"), + Some(a) if a == "filters" || a == "filter" => Ok("filters"), + Some(a) => Err(bad(cmd, format!("unknown area `{a}` (rows|columns|values|filters)"))), + None => Err(bad(cmd, "missing `area` (rows|columns|values|filters)")), + } +} + +pub(super) fn func_param(v: Option<&str>) -> Option { + Some(match v?.to_ascii_lowercase().as_str() { + "sum" => PivotFunc::Sum, + "count" | "counta" => PivotFunc::Count, + "average" | "avg" | "mean" => PivotFunc::Average, + "max" => PivotFunc::Max, + "min" => PivotFunc::Min, + "product" => PivotFunc::Product, + "countnumbers" | "countnums" | "count numbers" => PivotFunc::CountNumbers, + "stddev" => PivotFunc::StdDev, + "stddevp" => PivotFunc::StdDevP, + "var" => PivotFunc::Var, + "varp" => PivotFunc::VarP, + _ => return None, + }) +} + +pub(super) fn func_name(f: PivotFunc) -> &'static str { + match f { + PivotFunc::Sum => "sum", + PivotFunc::Count => "count", + PivotFunc::Average => "average", + PivotFunc::Max => "max", + PivotFunc::Min => "min", + PivotFunc::Product => "product", + PivotFunc::CountNumbers => "countNumbers", + PivotFunc::StdDev => "stdDev", + PivotFunc::StdDevP => "stdDevP", + PivotFunc::Var => "var", + PivotFunc::VarP => "varP", + } +} + +pub(super) fn show_as_param(v: &str) -> Option { + Some(match v.to_ascii_lowercase().as_str() { + "normal" | "none" => PivotShowAs::Normal, + "percentofgrandtotal" => PivotShowAs::PercentOfGrandTotal, + "percentofcolumntotal" => PivotShowAs::PercentOfColumnTotal, + "percentofrowtotal" => PivotShowAs::PercentOfRowTotal, + "runningtotal" => PivotShowAs::RunningTotal, + "rank" => PivotShowAs::Rank, + _ => return None, + }) +} + +pub(super) fn show_as_name(v: PivotShowAs) -> &'static str { + match v { + PivotShowAs::Normal => "normal", + PivotShowAs::PercentOfGrandTotal => "percentOfGrandTotal", + PivotShowAs::PercentOfColumnTotal => "percentOfColumnTotal", + PivotShowAs::PercentOfRowTotal => "percentOfRowTotal", + PivotShowAs::RunningTotal => "runningTotal", + PivotShowAs::Rank => "rank", + } +} + +pub(super) fn layout_name(l: PivotLayout) -> &'static str { + match l { + PivotLayout::Compact => "compact", + PivotLayout::Outline => "outline", + PivotLayout::Tabular => "tabular", + } +} + +pub(super) fn group_name(g: PivotDateGroup) -> &'static str { + match g { + PivotDateGroup::None => "none", + PivotDateGroup::Years => "years", + PivotDateGroup::Quarters => "quarters", + PivotDateGroup::Months => "months", + PivotDateGroup::Days => "days", + } +} + +pub(super) fn sort_name(o: PivotSort) -> &'static str { + match o { + PivotSort::Asc => "asc", + PivotSort::Desc => "desc", + PivotSort::None => "none", + } +} + +// ---------------------------------------------------------------- field commands + +pub(super) fn position_param(p: &Json) -> Option { + u32_param(p, "position").map(|v| v as usize) +} + +pub(super) fn group_param(v: Option<&str>) -> Option { + Some(match v?.to_ascii_lowercase().as_str() { + "years" | "year" => PivotDateGroup::Years, + "quarters" | "quarter" => PivotDateGroup::Quarters, + "months" | "month" => PivotDateGroup::Months, + "days" | "day" => PivotDateGroup::Days, + "none" | "ungroup" => PivotDateGroup::None, + _ => return None, + }) +}