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

This commit is contained in:
aiongg
2026-10-26 20:43:39 +00:00
parent 73c52d49b7
commit 99f0be5f29
6 changed files with 1466 additions and 1442 deletions
File diff suppressed because it is too large Load Diff
+174
View File
@@ -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<Json> {
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<Json> {
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<usize> = (0..n)
.filter(|&i| {
!is_num(i) && {
let d = distinct(i);
d >= 1 && (d < records || records <= 2) && d <= 1000
}
})
.collect();
let nums: Vec<usize> = (0..n).filter(|&i| is_num(i) && !is_date(i)).collect();
let dates: Vec<usize> = (0..n).filter(|&i| is_date(i)).collect();
let mut out = vec![];
let mut push = |title: String, rows: Vec<Json>, cols: Vec<Json>, vals: Vec<Json>| {
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<Json> {
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)
}
+340
View File
@@ -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<String> {
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<String> {
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<Json> {
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<Json> {
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<usize> = 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<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let active = d.wb.active_sheet();
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let pt = sh
.pivots
.iter()
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
// 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<usize> = None;
let res = edit(s, |cx| {
let si = insert_sheet_before_active(cx);
new_sheet_at = Some(si);
let base = cx.wb.styles.get(StyleId::default()).clone();
let fmts: Vec<StyleId> = src
.formats
.iter()
.map(|f| {
let mut st = base.clone();
st.num_fmt = NumFmt::new(f);
cx.wb.styles.intern(st)
})
.collect();
// Columns wide enough for their text, as the PivotTable report's are (dates would show
// as ####); the first rows are enough to judge a column.
let font = base.font.clone();
let widths: Vec<f32> = src
.headers
.iter()
.enumerate()
.map(|(c, h)| {
let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General");
let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c)));
let widest = texts
.map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false))
.fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max);
(widest + 14.0).min(320.0)
})
.collect();
let sh = cx.sheet_mut(si)?;
for (c, w) in widths.into_iter().enumerate() {
if w > sh.col_width(c as u32) {
sh.cols.entry(c as u32).or_default().size = Some(w);
}
}
for (c, h) in src.headers.iter().enumerate() {
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
}
for (r, i) in records.iter().enumerate() {
let Some(rec) = src.rows.get(*i) else { continue };
for (c, v) in rec.iter().enumerate() {
let style = fmts.get(c).copied().unwrap_or_default();
if v.is_empty() && style == StyleId::default() {
continue;
}
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
}
}
let name = sh.name.clone();
cx.wb.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<Json> {
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<Json> {
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<Json> {
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::<Vec<_>>(),
"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::<Vec<_>>(),
"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::<Vec<_>>(),
"filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::<Vec<_>>(),
"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::<Vec<_>>(),
"valuesOnRows": pt.values_on_rows,
"valuesPosition": pt.values_position,
"enableShowDetails": pt.enable_show_details,
})
}
+541
View File
@@ -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<PivotField> {
let mut first = None;
for rows in [true, false] {
loop {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break };
let f = list.remove(i);
shift_values(pt, rows, i, false);
first.get_or_insert(f);
}
}
first
}
/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end).
fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option<usize>) {
let list = if rows { &mut pt.rows } else { &mut pt.columns };
let at = position.unwrap_or(list.len()).min(list.len());
list.insert(at, f);
shift_values(pt, rows, at, true);
}
/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end).
/// In Columns it always comes after the last column field: the column layout doesn't place it
/// anywhere else yet.
fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option<usize>) -> Result<Json> {
let current = if pt.values_on_rows { "rows" } else { "columns" };
if from != current {
return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area")));
}
if !matches!(to, "rows" | "columns") {
return Err(other("The field you are moving cannot be placed in that PivotTable area."));
}
pt.values_on_rows = to == "rows";
pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len());
Ok(json!({"field": VALUES_FIELD, "to": to}))
}
/// The canonical source field name for `field`.
pub(super) fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
}
/// 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<String> {
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<usize>) -> 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<PivotFunc>,
name: Option<&str>,
position: Option<usize>,
) -> 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<String> {
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<Json>) -> Result<Json> {
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<Json> {
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<Json> = 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<Json> {
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<Json> {
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<Json> {
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::<Vec<_>>(),
}))
})
}
pub(super) fn remove_field(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
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<Json> {
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}))
})
}
+289
View File
@@ -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<Json> {
const C: &str = "pivot.filter";
let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string();
let selected: Option<Vec<String>> = 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<String> = 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<Json> {
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<Json> {
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<Json> {
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<Vec<String>> =
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<Json> {
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<bool>)> = 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<Json> {
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<Json> {
update(s, p, |_, src, _| Ok(json!({"records": src.rows.len()})))
}
pub(super) fn refresh_all(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
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<String> = vec![];
let keep = |name: &str, dropped: &mut Vec<String>| {
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<Json> {
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!({}))
})
}
+111
View File
@@ -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<PivotFunc> {
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<PivotShowAs> {
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<usize> {
u32_param(p, "position").map(|v| v as usize)
}
pub(super) fn group_param(v: Option<&str>) -> Option<PivotDateGroup> {
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,
})
}