mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 12:00:01 -06:00
546 lines
24 KiB
Rust
546 lines
24 KiB
Rust
use super::create::pivot_json;
|
|
use super::params::{area_param, func_param, position_param, show_as_param};
|
|
use super::{
|
|
Json, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotShowAs, PivotTable, PivotValue, Result, Session, Workbook, bad,
|
|
bool_param, edit, find_pivot, json, ok, other, pv, str_param, u32_param,
|
|
};
|
|
|
|
// ---------------------------------------------------------------- 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) struct AreaPlacement<'a> {
|
|
pub cmd: &'a str,
|
|
pub field: &'a str,
|
|
pub area: &'a str,
|
|
pub func: Option<PivotFunc>,
|
|
pub name: Option<&'a str>,
|
|
pub position: Option<usize>,
|
|
}
|
|
|
|
pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: AreaPlacement<'_>) -> Result<()> {
|
|
let AreaPlacement { cmd, field, area, func, name, position } = placement;
|
|
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(pt, src, AreaPlacement { cmd: C, field: &field, area, func, name: name.as_deref(), position: 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(pt, src, AreaPlacement { cmd: C, field: &canon, area: "values", func: Some(PivotFunc::Sum), name: None, position: 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(pt, src, AreaPlacement { cmd: C, field: &src_field, area: to, func: None, name: None, position: 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}))
|
|
})
|
|
}
|