mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 20:26:48 -06:00
refactor(engine): move cmd/insert.rs items to cmd/insert/
This commit is contained in:
+13
-1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,420 @@
|
||||
use super::*;
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
return target_ranges(s, p);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.selection.ranges.len() > 1 {
|
||||
return Ok(d.selection.ranges.clone());
|
||||
}
|
||||
Ok(vec![block(s, p)?])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- charts
|
||||
|
||||
pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
|
||||
match (t, sub) {
|
||||
("column", "stacked") => ChartKind::ColumnStacked,
|
||||
("column", "stacked100") => ChartKind::ColumnStacked100,
|
||||
("bar", "stacked") => ChartKind::BarStacked,
|
||||
("bar", "stacked100") => ChartKind::BarStacked100,
|
||||
("bar", _) => ChartKind::BarClustered,
|
||||
("line", "markers") => ChartKind::LineMarkers,
|
||||
("line", "stacked") => ChartKind::LineStacked,
|
||||
("line", _) => ChartKind::Line,
|
||||
("pie", _) => ChartKind::Pie,
|
||||
("doughnut", _) => ChartKind::Doughnut,
|
||||
("area", "stacked") => ChartKind::AreaStacked,
|
||||
("area", "stacked100") => ChartKind::AreaStacked100,
|
||||
("area", _) => ChartKind::Area,
|
||||
("scatter", "lines") => ChartKind::ScatterLines,
|
||||
("scatter", _) => ChartKind::Scatter,
|
||||
("bubble", _) => ChartKind::Bubble,
|
||||
("radar", _) => ChartKind::Radar,
|
||||
("histogram", _) => ChartKind::Histogram,
|
||||
("waterfall", _) => ChartKind::Waterfall,
|
||||
("funnel", _) => ChartKind::Funnel,
|
||||
("treemap", _) => ChartKind::Treemap,
|
||||
("sunburst", _) => ChartKind::Sunburst,
|
||||
("boxWhisker", _) => ChartKind::BoxWhisker,
|
||||
("stock", _) => ChartKind::Stock,
|
||||
("combo", _) => ChartKind::Combo,
|
||||
_ => ChartKind::ColumnClustered,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds series from a block: first row/column as names/categories when they're text.
|
||||
pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series> {
|
||||
let q = gridcraft_formula::quote_sheet(&sh.name);
|
||||
let abs = |r: RangeRef| {
|
||||
format!(
|
||||
"{q}!${}${}:${}${}",
|
||||
gridcraft_core::col_to_letters(r.start.col),
|
||||
r.start.row + 1,
|
||||
gridcraft_core::col_to_letters(r.end.col),
|
||||
r.end.row + 1
|
||||
)
|
||||
};
|
||||
let abs1 = |c: CellRef| format!("{q}!${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
|
||||
let is_text = |c: CellRef| sh.value(c).is_text() || sh.value(c).is_empty();
|
||||
let header_row = r.height() > 1
|
||||
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
|
||||
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
|
||||
// A blank top-left cell above a header row marks the first column as the category labels even
|
||||
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
|
||||
// plotted as a series of its own (#287).
|
||||
let blank_corner = header_row && sh.value(r.start).is_empty();
|
||||
let label_col =
|
||||
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
|
||||
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
|
||||
let mut out = Vec::new();
|
||||
if !by_rows {
|
||||
for c in data.start.col..=data.end.col {
|
||||
out.push(Series {
|
||||
name: header_row.then(|| abs1(CellRef::new(r.start.row, c))),
|
||||
categories: label_col.then(|| abs(RangeRef::new(CellRef::new(data.start.row, r.start.col), CellRef::new(data.end.row, r.start.col)))),
|
||||
values: abs(RangeRef::new(CellRef::new(data.start.row, c), CellRef::new(data.end.row, c))),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for row in data.start.row..=data.end.row {
|
||||
out.push(Series {
|
||||
name: label_col.then(|| abs1(CellRef::new(row, r.start.col))),
|
||||
categories: header_row
|
||||
.then(|| abs(RangeRef::new(CellRef::new(r.start.row, data.start.col), CellRef::new(r.start.row, data.end.col)))),
|
||||
values: abs(RangeRef::new(CellRef::new(row, data.start.col), CellRef::new(row, data.end.col))),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
|
||||
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
|
||||
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
|
||||
if areas.len() == 1 {
|
||||
let by_rows = first.width() > first.height() + 1;
|
||||
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
|
||||
}
|
||||
let valid_shape = first.width() == 1
|
||||
&& first.height() > 1
|
||||
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
|
||||
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
|
||||
if !valid_shape {
|
||||
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
|
||||
}
|
||||
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
|
||||
let mut series = Vec::with_capacity(areas.len() - 1);
|
||||
for area in &areas[1..] {
|
||||
let mut area_series = series_from_range(sh, *area, false);
|
||||
let Some(mut item) = area_series.pop() else {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
};
|
||||
if item.name.is_none() {
|
||||
return Err(bad("insert.chart", "multi-area series columns require headers"));
|
||||
}
|
||||
item.categories = category.clone();
|
||||
series.push(item);
|
||||
}
|
||||
Ok((bounds, false, series))
|
||||
}
|
||||
|
||||
/// A scatter plot of a block without category labels takes its first column (or row) as the X
|
||||
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
|
||||
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
|
||||
if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() {
|
||||
let x = series.remove(0).values;
|
||||
for se in series.iter_mut() {
|
||||
se.categories = Some(x.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most cells a chart's source block may span (bounds the series it builds).
|
||||
const MAX_CHART_CELLS: u64 = 1_000_000;
|
||||
|
||||
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
|
||||
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
|
||||
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
|
||||
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
|
||||
/// series → clustered and stacked columns and bars, line.
|
||||
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
|
||||
let ranges: Vec<RangeRef> = series
|
||||
.iter()
|
||||
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
|
||||
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let points = ranges.first().map_or(0, RangeRef::count);
|
||||
let (mut any_number, mut non_negative) = (false, true);
|
||||
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
|
||||
any_number = true;
|
||||
non_negative &= n >= 0.0;
|
||||
}
|
||||
if series.is_empty() || !any_number {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
let labelled = series.first().is_some_and(|x| x.categories.is_some());
|
||||
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
|
||||
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
|
||||
const LINE: (&str, &str, &str) = ("line", "", "Line");
|
||||
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
|
||||
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
|
||||
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
|
||||
const AREA: (&str, &str, &str) = ("area", "", "Area");
|
||||
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
|
||||
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
|
||||
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
|
||||
let mut out = Vec::new();
|
||||
if series.len() == 2 && !labelled && !by_rows {
|
||||
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
|
||||
} else if series.len() == 1 {
|
||||
if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR]);
|
||||
if non_negative && points >= 2 {
|
||||
out.extend([PIE, DOUGHNUT]);
|
||||
}
|
||||
out.push(LINE_MARKERS);
|
||||
}
|
||||
} else if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
|
||||
/// goes on and the chart (previews in the Recommended Charts dialog).
|
||||
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let mut t = str_param(p, "type").unwrap_or("column").to_string();
|
||||
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
|
||||
if t == "auto" {
|
||||
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
|
||||
t = first.0.into();
|
||||
sub = first.1.into();
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
scatter_x_from_first(kind, &mut series);
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
|
||||
let title = str_param(p, "title").map(str::to_string).or_else(|| {
|
||||
if series.len() == 1 {
|
||||
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
|
||||
} else {
|
||||
Some("Chart Title".into())
|
||||
}
|
||||
});
|
||||
let chart = Chart {
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
cell: at,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(480.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(288.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: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
Ok((sheet, chart))
|
||||
}
|
||||
|
||||
pub(super) fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, chart) = build_chart(s, p)?;
|
||||
let id = chart.id;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.charts.push(chart);
|
||||
Ok(json!({"chart": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let idx = match u32_param(p, "chart") {
|
||||
Some(id) => sh.charts.iter().position(|c| c.id == id),
|
||||
None => sh.charts.len().checked_sub(1),
|
||||
};
|
||||
idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart"))
|
||||
}
|
||||
|
||||
pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", si)?;
|
||||
let quartiles = p
|
||||
.get("quartiles")
|
||||
.map(|v| match v.as_str() {
|
||||
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
|
||||
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
|
||||
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
|
||||
})
|
||||
.transpose()?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
}
|
||||
if let Some(method) = quartiles {
|
||||
for series in &mut c.series {
|
||||
series.quartiles = method;
|
||||
}
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(str::to_string);
|
||||
}
|
||||
if let Some(l) = str_param(p, "legend") {
|
||||
c.legend = match l {
|
||||
"none" => LegendPos::None,
|
||||
"top" => LegendPos::Top,
|
||||
"left" => LegendPos::Left,
|
||||
"right" => LegendPos::Right,
|
||||
_ => LegendPos::Bottom,
|
||||
};
|
||||
}
|
||||
if let Some(v) = bool_param(p, "dataLabels") {
|
||||
c.data_labels = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "gridlines") {
|
||||
c.gridlines = v;
|
||||
}
|
||||
if let Some(v) = u32_param(p, "style") {
|
||||
c.style = v.clamp(1, 16);
|
||||
}
|
||||
if let Some(t) = p.get("xTitle") {
|
||||
c.x_title = t.as_str().map(str::to_string);
|
||||
}
|
||||
if let Some(t) = p.get("yTitle") {
|
||||
c.y_title = t.as_str().map(str::to_string);
|
||||
}
|
||||
if let Some(at) = at {
|
||||
c.anchor.cell = at;
|
||||
}
|
||||
if let Some(w) = f64_param(p, "width") {
|
||||
c.anchor.width = w.clamp(20.0, 10000.0) as f32;
|
||||
}
|
||||
if let Some(h) = f64_param(p, "height") {
|
||||
c.anchor.height = h.clamp(20.0, 10000.0) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dx") {
|
||||
c.anchor.dx = v as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dy") {
|
||||
c.anchor.dy = v as f32;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
|
||||
let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) };
|
||||
// Charts read from a file have no source range: take the block their series span.
|
||||
let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) };
|
||||
let (sheet_name, body) = split_sheet(&src);
|
||||
let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si);
|
||||
let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) };
|
||||
let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) };
|
||||
// Whole-column/row references (common in files) span a million cells: clip the block's
|
||||
// far edges to the cells in use, then refuse anything still too large to chart.
|
||||
if let Some(used) = ssh.used_range() {
|
||||
r.end.row = r.end.row.min(used.end.row).max(r.start.row);
|
||||
r.end.col = r.end.col.min(used.end.col).max(r.start.col);
|
||||
}
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
return Err(bad("chart.switchRowColumn", "range too large for a chart"));
|
||||
}
|
||||
let by_rows = !by_rows;
|
||||
let series = series_from_range(ssh, r, by_rows);
|
||||
if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) {
|
||||
c.source.get_or_insert(src);
|
||||
c.by_rows = by_rows;
|
||||
c.series = series;
|
||||
scatter_x_from_first(c.kind, &mut c.series);
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// The block a chart's series come from, and whether each series is a row of it: the bounding
|
||||
/// range of the series names, categories and values, when they're all references to one sheet.
|
||||
fn series_block(series: &[Series]) -> Option<(String, bool)> {
|
||||
let mut sheet: Option<String> = None;
|
||||
let mut bounds: Option<RangeRef> = None;
|
||||
let mut rows = 0;
|
||||
for s in series {
|
||||
for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() {
|
||||
let Ok(gridcraft_formula::Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None };
|
||||
let gridcraft_formula::SheetSel::Named(n) = &r.sheet else { return None };
|
||||
if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) {
|
||||
return None;
|
||||
}
|
||||
let range = r.range();
|
||||
if i == 0 && range.height() == 1 && range.width() > 1 {
|
||||
rows += 1;
|
||||
}
|
||||
bounds = Some(bounds.map_or(range, |b| b.union(&range)));
|
||||
}
|
||||
}
|
||||
Some((format!("{}!{}", gridcraft_formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len()))
|
||||
}
|
||||
|
||||
pub(super) fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(si)?.charts.remove(ci);
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- conditional formatting
|
||||
|
||||
fn preset_style(name: &str) -> Style {
|
||||
let mut s = Style::default();
|
||||
match name {
|
||||
"redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06),
|
||||
"yellowFill" | "yellowFillDarkYellowText" => {
|
||||
s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C));
|
||||
s.font.color = Color::rgb(0x9C, 0x57, 0x00);
|
||||
}
|
||||
"greenFill" | "greenFillDarkGreenText" => {
|
||||
s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE));
|
||||
s.font.color = Color::rgb(0x00, 0x61, 0x00);
|
||||
}
|
||||
"lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)),
|
||||
"redBorder" => {
|
||||
let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) };
|
||||
s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() };
|
||||
}
|
||||
_ => {
|
||||
s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE));
|
||||
s.font.color = Color::rgb(0x9C, 0x00, 0x06);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
|
||||
fn is_string_literal(v: &str) -> bool {
|
||||
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
|
||||
}
|
||||
|
||||
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
|
||||
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
|
||||
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
|
||||
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
|
||||
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
|
||||
fn cf_style_json(st: &Json) -> Result<Json> {
|
||||
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
|
||||
let err = |m: String| bad("home.conditionalFormat", m);
|
||||
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
|
||||
let mut st = obj.clone();
|
||||
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
|
||||
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
|
||||
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
|
||||
}
|
||||
// A colour written the way the other formatting commands take it, as the model stores it.
|
||||
let color = |v: &mut Json, what: &str| -> Result<()> {
|
||||
if v.is_string() || v.get("theme").is_some() {
|
||||
let c = crate::cmd::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
|
||||
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
|
||||
&& let Some(c) = f.get_mut("color")
|
||||
{
|
||||
color(c, "font.color")?;
|
||||
}
|
||||
if let Some(fill) = st.get_mut("fill") {
|
||||
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
|
||||
if fill.is_string() && crate::cmd::format::color_param(Some(fill)) == Some(Color::Auto) {
|
||||
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
} else if fill.is_string() || fill.get("theme").is_some() {
|
||||
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
|
||||
}
|
||||
if let Some(f) = fill.as_object_mut() {
|
||||
for k in ["fg", "bg"] {
|
||||
if let Some(c) = f.get_mut(k) {
|
||||
color(c, &format!("fill.{k}"))?;
|
||||
}
|
||||
}
|
||||
// A colour with no pattern is a solid fill, not an invisible one.
|
||||
if f.contains_key("fg") && !f.contains_key("pattern") {
|
||||
f.insert("pattern".into(), json!("Solid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
|
||||
for (side, line) in b.iter_mut() {
|
||||
if let Some(c) = line.get_mut("color") {
|
||||
color(c, &format!("borders.{side}.color"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json::Object(st))
|
||||
}
|
||||
|
||||
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
|
||||
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
|
||||
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
|
||||
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
|
||||
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
|
||||
// only parses after several strips and would still be saved unparseable. (#1999 review)
|
||||
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
|
||||
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
|
||||
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
|
||||
check_cf_formula("home.conditionalFormat", what, f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_cf_rule(rule: &CfRule) -> Result<()> {
|
||||
const CMD: &str = "home.conditionalFormat";
|
||||
match rule {
|
||||
CfRule::CellIs { a, b, .. } => {
|
||||
check_cf_formula(CMD, "comparison value", a)?;
|
||||
if let Some(b) = b {
|
||||
check_cf_formula(CMD, "second comparison value", b)?;
|
||||
}
|
||||
}
|
||||
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
|
||||
CfRule::DataBar { min, max, .. } => {
|
||||
check_cf_value("data bar minimum", min)?;
|
||||
check_cf_value("data bar maximum", max)?;
|
||||
}
|
||||
CfRule::ColorScale { stops } => {
|
||||
for (kind, _) in stops {
|
||||
check_cf_value("color scale threshold", kind)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let Some(rule) = p.get("rule") else {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("newFormattingRule".into(), json!({})));
|
||||
return ok();
|
||||
};
|
||||
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
|
||||
let style = Box::new(match rule.get("style") {
|
||||
Some(st) if !st.is_null() => {
|
||||
let st = cf_style_json(st)?;
|
||||
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
serde_json::from_value(crate::cmd::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
|
||||
}
|
||||
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
|
||||
});
|
||||
let kind = g("type").unwrap_or_else(|| "cellIs".into());
|
||||
let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind {
|
||||
match v {
|
||||
Some(Json::Object(o)) => {
|
||||
let t = o.get("type").and_then(Json::as_str).unwrap_or("num");
|
||||
let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0);
|
||||
match t {
|
||||
"min" => CfValueKind::Min,
|
||||
"max" => CfValueKind::Max,
|
||||
"percent" => CfValueKind::Percent(val),
|
||||
"percentile" => CfValueKind::Percentile(val),
|
||||
"formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()),
|
||||
_ => CfValueKind::Number(val),
|
||||
}
|
||||
}
|
||||
_ => default,
|
||||
}
|
||||
};
|
||||
let rule = match kind.as_str() {
|
||||
"cellIs" => {
|
||||
// Excel's file and VBA names (`lessThan`, …) are accepted too.
|
||||
let op = match g("operator").as_deref().unwrap_or("greater") {
|
||||
"between" => CfOperator::Between,
|
||||
"notBetween" => CfOperator::NotBetween,
|
||||
"equal" => CfOperator::Equal,
|
||||
"notEqual" => CfOperator::NotEqual,
|
||||
"less" | "lessThan" => CfOperator::Less,
|
||||
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
|
||||
"greater" | "greaterThan" => CfOperator::Greater,
|
||||
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
|
||||
};
|
||||
let a = rule.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default();
|
||||
let b = rule.get("value2").map(crate::cmd::edit::json_to_input);
|
||||
let quote = |v: String| {
|
||||
// A number, a formula, or text already written as a string literal (`"Closed"`, as
|
||||
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
|
||||
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
|
||||
v.trim_start_matches('=').to_string()
|
||||
} else if is_string_literal(&v) {
|
||||
v
|
||||
} else {
|
||||
format!("\"{}\"", v.replace('"', "\"\""))
|
||||
}
|
||||
};
|
||||
CfRule::CellIs { op, a: quote(a), b: b.map(quote), style }
|
||||
}
|
||||
"expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style },
|
||||
"containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style },
|
||||
"notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style },
|
||||
"beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style },
|
||||
"endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style },
|
||||
"blanks" => CfRule::Blanks { style },
|
||||
"noBlanks" => CfRule::NoBlanks { style },
|
||||
"errors" => CfRule::Errors { style },
|
||||
"noErrors" => CfRule::NoErrors { style },
|
||||
"duplicate" => CfRule::Duplicate { style },
|
||||
"unique" => CfRule::Unique { style },
|
||||
"top10" => CfRule::Top10 {
|
||||
bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false),
|
||||
percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false),
|
||||
rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32,
|
||||
style,
|
||||
},
|
||||
"aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style },
|
||||
"timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style },
|
||||
"colorScale" => {
|
||||
let cols: Vec<Color> = rule
|
||||
.get("colors")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|c| crate::cmd::format::color_param(Some(c))).collect())
|
||||
.unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]);
|
||||
let stops = match cols.len() {
|
||||
2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])],
|
||||
_ => vec![
|
||||
(CfValueKind::Min, cols.first().copied().unwrap_or_default()),
|
||||
(CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()),
|
||||
(CfValueKind::Max, cols.get(2).copied().unwrap_or_default()),
|
||||
],
|
||||
};
|
||||
CfRule::ColorScale { stops }
|
||||
}
|
||||
"dataBar" => CfRule::DataBar {
|
||||
min: value_kind(rule.get("min"), CfValueKind::Min),
|
||||
max: value_kind(rule.get("max"), CfValueKind::Max),
|
||||
color: crate::cmd::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)),
|
||||
gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true),
|
||||
show_value: true,
|
||||
},
|
||||
"iconSet" => CfRule::IconSet {
|
||||
set: g("set").unwrap_or_else(|| "3TrafficLights1".into()),
|
||||
thresholds: vec![],
|
||||
reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false),
|
||||
show_value: true,
|
||||
},
|
||||
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
|
||||
};
|
||||
check_cf_rule(&rule)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
|
||||
// New rules take precedence over older ones.
|
||||
for cf in sh.cond_formats.iter_mut() {
|
||||
cf.priority += 1;
|
||||
}
|
||||
sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false });
|
||||
let _ = priority;
|
||||
Ok(json!({"rules": sh.cond_formats.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn clear_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let whole = bool_param(p, "sheet").unwrap_or(false);
|
||||
let ranges = target_ranges(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if whole {
|
||||
sh.cond_formats.clear();
|
||||
} else {
|
||||
for cf in sh.cond_formats.iter_mut() {
|
||||
cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r)));
|
||||
}
|
||||
sh.cond_formats.retain(|c| !c.ranges.is_empty());
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() {
|
||||
let list: Vec<Json> = s
|
||||
.doc()?
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| {
|
||||
sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), "rule": c.rule})).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
return Ok(json!({"rules": list}));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The count is taken again after each step: a delete shortens the list.
|
||||
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
|
||||
&& i < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.remove(i);
|
||||
}
|
||||
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
|
||||
&& i > 0
|
||||
&& i < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.swap(i, i - 1);
|
||||
}
|
||||
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
|
||||
&& i + 1 < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.swap(i, i + 1);
|
||||
}
|
||||
for (k, cf) in sh.cond_formats.iter_mut().enumerate() {
|
||||
cf.priority = k as u32 + 1;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").unwrap_or("check").to_string();
|
||||
let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32;
|
||||
let d = s.doc()?;
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let color = crate::cmd::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
|
||||
let shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::Icon,
|
||||
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
|
||||
fill: color,
|
||||
line: Color::Auto,
|
||||
text: name,
|
||||
format: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"shape": id}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- links & comments
|
||||
|
||||
pub(super) fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
if bool_param(p, "remove").unwrap_or(false) {
|
||||
return edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.hyperlinks.remove(&at);
|
||||
Ok(Json::Null)
|
||||
});
|
||||
}
|
||||
let Some(target) = str_param(p, "target").map(str::to_string) else {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("insertLink".into(), json!({})));
|
||||
return ok();
|
||||
};
|
||||
let text = str_param(p, "text").map(str::to_string);
|
||||
let tooltip = str_param(p, "tooltip").map(str::to_string);
|
||||
let external = links_outside(&s.doc()?.wb, sheet, &target);
|
||||
edit(s, |cx| {
|
||||
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
|
||||
st.font.color = Color::Theme(10, 0);
|
||||
st.font.underline = Underline::Single;
|
||||
});
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
|
||||
} else {
|
||||
sh.set_style(at, link_style);
|
||||
}
|
||||
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
|
||||
cx.touch(sheet, at);
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
|
||||
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
|
||||
/// dialog; the interactive click asks for confirmation first, in the UI.
|
||||
pub(super) fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
|
||||
Some(t) => t.to_string(),
|
||||
None => {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {
|
||||
return Err(EngineError::Other("There's no hyperlink in this cell.".into()));
|
||||
};
|
||||
h.target.clone()
|
||||
}
|
||||
};
|
||||
match crate::links::classify(&target) {
|
||||
crate::links::LinkKind::Internal(r) => crate::cmd::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
|
||||
crate::links::LinkKind::External(u) => {
|
||||
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
|
||||
Ok(json!({"opened": u}))
|
||||
}
|
||||
crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
|
||||
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
|
||||
/// Page") is outside the workbook and saved as a relationship.
|
||||
fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
|
||||
let t = target.trim();
|
||||
if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
|
||||
return false;
|
||||
}
|
||||
let (on, body) = split_sheet(t);
|
||||
// Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
|
||||
// and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
|
||||
let on_sheet = on.is_none_or(|name| {
|
||||
wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
|
||||
|| (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
|
||||
});
|
||||
!(on_sheet && RangeRef::parse(body).is_some())
|
||||
}
|
||||
|
||||
pub(super) fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let named = str_param(p, "author").map(str::to_string);
|
||||
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
edit(s, |cx| {
|
||||
let comments = &mut cx.sheet_mut(sheet)?.comments;
|
||||
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
|
||||
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
|
||||
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
|
||||
c.text = text.clone();
|
||||
if let Some(author) = named.clone() {
|
||||
c.author = author;
|
||||
}
|
||||
return Ok(json!({"cell": at.a1()}));
|
||||
}
|
||||
comments.insert(
|
||||
at,
|
||||
Comment {
|
||||
author: author.clone(),
|
||||
text: text.clone(),
|
||||
replies: vec![],
|
||||
threaded,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
Ok(json!({"cell": at.a1()}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reply_comment(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
edit(s, |cx| {
|
||||
let c = cx.sheet_mut(sheet)?.comments.get_mut(&at).ok_or_else(|| EngineError::Other("There's no comment in this cell.".into()))?;
|
||||
c.replies.push((author.clone(), text.clone()));
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_comment(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let v = bool_param(p, "resolved").unwrap_or(true);
|
||||
edit(s, |cx| {
|
||||
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
|
||||
c.resolved = v;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn delete_comment(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.comments.retain(|c, _| !ranges.iter().any(|r| r.contains(*c)));
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn show_note(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
edit(s, |cx| {
|
||||
if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) {
|
||||
c.visible = !c.visible;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
|
||||
let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?;
|
||||
let kind = match str_param(p, "type") {
|
||||
Some("column") => SparklineKind::Column,
|
||||
Some("winLoss") => SparklineKind::WinLoss,
|
||||
_ => SparklineKind::Line,
|
||||
};
|
||||
let markers = bool_param(p, "markers").unwrap_or(false);
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.sparklines.retain(|x| x.cell != loc);
|
||||
sh.sparklines.push(Sparkline { cell: loc, source: src.clone(), kind, color: Color::Theme(4, 0), markers });
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let placement = str_param(p, "placement").unwrap_or("overCells");
|
||||
if !matches!(placement, "cell" | "overCells") {
|
||||
return Err(bad("insert.picture", "placement must be cell or overCells"));
|
||||
}
|
||||
let data = if let Some(b) = str_param(p, "base64") {
|
||||
crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))?
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
crate::io::read_file(path)?
|
||||
} else {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement})));
|
||||
return ok();
|
||||
};
|
||||
if placement == "cell" {
|
||||
let mime = validate_cell_picture(&data)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let at = match str_param(p, "at") {
|
||||
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
|
||||
None => d.selection.active,
|
||||
};
|
||||
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
|
||||
let style = sh.style_id(at);
|
||||
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
|
||||
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
|
||||
return edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture));
|
||||
cx.touch(sheet, at);
|
||||
Ok(json!({"cell": at.a1(), "placement": "cell"}))
|
||||
});
|
||||
}
|
||||
if data.len() > 64 * 1024 * 1024 {
|
||||
return Err(bad("insert.picture", "image larger than 64 MB"));
|
||||
}
|
||||
let mime = str_param(p, "mime")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
|
||||
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
|
||||
let d = s.doc()?;
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let scale = (640.0 / w.max(1) as f32).min(1.0);
|
||||
let img = Image {
|
||||
id,
|
||||
anchor: Anchor {
|
||||
cell: at,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
|
||||
height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
|
||||
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
|
||||
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
|
||||
},
|
||||
data: data.into(),
|
||||
mime,
|
||||
alt: str_param(p, "alt").unwrap_or("").into(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.images.push(img);
|
||||
Ok(json!({"image": id}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode.
|
||||
fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
|
||||
if data.len() > 16 * 1024 * 1024 {
|
||||
return Err(bad("insert.picture", "cell pictures must be at most 16 MiB"));
|
||||
}
|
||||
let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?;
|
||||
let mime = match format {
|
||||
image::ImageFormat::Png => "image/png",
|
||||
image::ImageFormat::Jpeg => "image/jpeg",
|
||||
_ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")),
|
||||
};
|
||||
let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format)
|
||||
.into_dimensions()
|
||||
.map_err(|_| bad("insert.picture", "invalid image dimensions"))?;
|
||||
if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 {
|
||||
return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels"));
|
||||
}
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(8192);
|
||||
limits.max_image_height = Some(8192);
|
||||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?;
|
||||
Ok(mime)
|
||||
}
|
||||
|
||||
pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
|
||||
"roundedRectangle" => ShapeKind::RoundedRectangle,
|
||||
"ellipse" | "oval" => ShapeKind::Ellipse,
|
||||
"triangle" => ShapeKind::Triangle,
|
||||
"line" => ShapeKind::Line,
|
||||
"arrow" => ShapeKind::Arrow,
|
||||
"textBox" => ShapeKind::TextBox,
|
||||
_ => ShapeKind::Rectangle,
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let fill = crate::cmd::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox {
|
||||
Color::rgb(255, 255, 255)
|
||||
} else {
|
||||
Color::Theme(4, 0)
|
||||
});
|
||||
let line = crate::cmd::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox {
|
||||
Color::rgb(0x80, 0x80, 0x80)
|
||||
} else {
|
||||
Color::Theme(4, -250)
|
||||
});
|
||||
let shape = Shape {
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
cell: at,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(144.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
|
||||
mode: anchor_mode_param(p).unwrap_or_default(),
|
||||
},
|
||||
fill,
|
||||
line,
|
||||
text: str_param(p, "text").unwrap_or("").into(),
|
||||
format: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"shape": id}))
|
||||
})
|
||||
}
|
||||
|
||||
/// A text box holds at most as many characters as a cell.
|
||||
const SHAPE_TEXT_MAX_CHARS: usize = 32_767;
|
||||
|
||||
pub(super) fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id =
|
||||
p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?;
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?;
|
||||
if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() {
|
||||
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
if shape.kind != ShapeKind::TextBox {
|
||||
return Err(bad("shape.setText", "the shape is not a text box"));
|
||||
}
|
||||
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
if shape.text == text {
|
||||
return ok();
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
shape.text = text.into();
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match kind.as_str() {
|
||||
"chart" => sh.charts.retain(|c| c.id != id),
|
||||
"image" => sh.images.retain(|c| c.id != id),
|
||||
"slicer" => sh.slicers.retain(|c| c.id != id),
|
||||
_ => sh.shapes.retain(|c| c.id != id),
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let at = cell_param(s, p, "at")?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let anchor = match kind.as_str() {
|
||||
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
_ => None,
|
||||
};
|
||||
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
|
||||
if let Some(at) = at {
|
||||
a.cell = at;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dx") {
|
||||
a.dx = v as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dy") {
|
||||
a.dy = v as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "width") {
|
||||
a.width = v.clamp(1.0, 10000.0) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "height") {
|
||||
a.height = v.clamp(0.0, 10000.0) as f32;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with
|
||||
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
|
||||
/// when the sheet is sorted or edited structurally.
|
||||
pub(super) fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
if !matches!(kind.as_str(), "chart" | "image" | "shape") {
|
||||
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
|
||||
}
|
||||
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let anchor = match kind.as_str() {
|
||||
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
};
|
||||
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
|
||||
a.mode = mode;
|
||||
Ok(json!({"mode": anchor_mode_name(mode)}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
|
||||
pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = block(s, p)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) {
|
||||
return Err(EngineError::Other("A table can't overlap another table.".into()));
|
||||
}
|
||||
let arrays = |sh: &gridcraft_model::Sheet| {
|
||||
sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r)))
|
||||
};
|
||||
if d.wb.sheet(sheet).is_some_and(arrays) {
|
||||
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
|
||||
}
|
||||
if r.count() > 50_000_000 {
|
||||
return Err(bad("insert.table", "range too large"));
|
||||
}
|
||||
let header = bool_param(p, "header").unwrap_or_else(|| {
|
||||
let sh = d.wb.sheet(sheet);
|
||||
sh.is_some_and(|sh| (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) && r.height() > 1)
|
||||
});
|
||||
let name = str_param(p, "name")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
|
||||
if !valid_table_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
|
||||
}
|
||||
// Without a header row, the data moves down a row to make room for one: never a table with it.
|
||||
let moves_a_table = |sh: &gridcraft_model::Sheet| {
|
||||
sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col)
|
||||
};
|
||||
if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) {
|
||||
return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
|
||||
}
|
||||
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
|
||||
let id = d.wb.next_object_id();
|
||||
edit(s, |cx| {
|
||||
// Without a header row, insert one with generic names.
|
||||
let r = if header {
|
||||
r
|
||||
} else {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The header row pushes the data down one row: never off the end of the sheet.
|
||||
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
|
||||
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
|
||||
}
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||
// Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
|
||||
// reference (on any sheet) at the cells that moved.
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(&mut cx.wb, &name, &e);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
}
|
||||
cx.structural = true;
|
||||
RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col))
|
||||
};
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
// Lower-case names taken, and the next suffix for each header, so that thousands of equal
|
||||
// headers don't take cubic time.
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut next_suffix = std::collections::HashMap::new();
|
||||
for c in r.start.col..=r.end.col {
|
||||
let mut n = sh.value(CellRef::new(r.start.row, c)).display();
|
||||
if n.is_empty() {
|
||||
n = format!("Column{}", c - r.start.col + 1);
|
||||
}
|
||||
let base = n.clone();
|
||||
let key = base.to_ascii_lowercase();
|
||||
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
|
||||
while taken.contains(&n.to_ascii_lowercase()) {
|
||||
n = format!("{base}{k}");
|
||||
k += 1;
|
||||
}
|
||||
next_suffix.insert(key, k);
|
||||
taken.insert(n.to_ascii_lowercase());
|
||||
// Headers are text.
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str()));
|
||||
names.push(n);
|
||||
}
|
||||
sh.tables.push(Table {
|
||||
id,
|
||||
name: name.clone(),
|
||||
range: r,
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: names
|
||||
.into_iter()
|
||||
.map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
|
||||
.collect(),
|
||||
style,
|
||||
banded_rows: true,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: vec![],
|
||||
table_type: None,
|
||||
connection_id: None,
|
||||
});
|
||||
cx.structural = true;
|
||||
Ok(json!({"table": name, "range": r.a1()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
|
||||
pub(super) fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Without a range, the active area decides (as in insert_table), not the first one.
|
||||
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
|
||||
match inside {
|
||||
Some((table, range)) => {
|
||||
table_style(s, &with(p, "table", json!(table)))?;
|
||||
Ok(json!({"table": table, "range": range.a1()}))
|
||||
}
|
||||
None => insert_table(s, p),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
if let Some(n) = str_param(p, "table") {
|
||||
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
|
||||
}
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let ti = sh
|
||||
.tables
|
||||
.iter()
|
||||
.position(|t| t.range.contains(d.selection.active))
|
||||
.ok_or_else(|| EngineError::Disabled("table".into(), "select a cell in a table".into()))?;
|
||||
Ok((sheet, ti))
|
||||
}
|
||||
|
||||
pub(super) fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
let (si, ti) = find_table(s, p)?;
|
||||
let on = bool_param(p, "on");
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let Some(t) = sh.tables.get_mut(ti) else { return Ok(Json::Null) };
|
||||
match which {
|
||||
"totals" => {
|
||||
let v = on.unwrap_or(!t.totals_row);
|
||||
if v != t.totals_row {
|
||||
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
|
||||
}
|
||||
t.totals_row = v;
|
||||
if v {
|
||||
t.range.end.row += 1;
|
||||
if let Some(first) = t.columns.first_mut() {
|
||||
first.totals_label = Some("Total".into());
|
||||
}
|
||||
if t.columns.len() > 1
|
||||
&& let Some(last) = t.columns.last_mut()
|
||||
&& last.totals == TotalsFn::None
|
||||
{
|
||||
last.totals = TotalsFn::Sum;
|
||||
}
|
||||
} else {
|
||||
let row = t.range.end.row;
|
||||
t.range.end.row = t.range.end.row.saturating_sub(1);
|
||||
let (c0, c1) = (t.range.start.col, t.range.end.col);
|
||||
for c in c0..=c1 {
|
||||
sh.remove_cell(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
write_totals(sh, ti);
|
||||
cx.structural = true;
|
||||
}
|
||||
}
|
||||
"bandedRows" => t.banded_rows = on.unwrap_or(!t.banded_rows),
|
||||
"bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols),
|
||||
"firstCol" => t.first_col = on.unwrap_or(!t.first_col),
|
||||
"lastCol" => t.last_col = on.unwrap_or(!t.last_col),
|
||||
"header" => {
|
||||
t.filter_button = on.unwrap_or(!t.filter_button);
|
||||
if !t.filter_button {
|
||||
t.filter.clear();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the totals row cells (labels and SUBTOTAL formulas).
|
||||
fn write_totals(sh: &mut Sheet, ti: usize) {
|
||||
let Some(t) = sh.tables.get(ti).cloned() else { return };
|
||||
if !t.totals_row {
|
||||
return;
|
||||
}
|
||||
let row = t.range.end.row;
|
||||
for (i, col) in t.columns.iter().enumerate() {
|
||||
let c = CellRef::new(row, t.range.start.col + i as u32);
|
||||
if let Some(code) = col.totals.subtotal_code() {
|
||||
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
|
||||
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
|
||||
} else if let Some(l) = &col.totals_label {
|
||||
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
|
||||
} else {
|
||||
sh.remove_cell(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn table_total_fn(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ti) = find_table(s, p)?;
|
||||
let col = str_param(p, "column").ok_or_else(|| bad("table.totalFunction", "missing `column`"))?.to_string();
|
||||
let f = match str_param(p, "function").unwrap_or("sum") {
|
||||
"average" => TotalsFn::Average,
|
||||
"count" => TotalsFn::Count,
|
||||
"countNums" => TotalsFn::CountNums,
|
||||
"max" => TotalsFn::Max,
|
||||
"min" => TotalsFn::Min,
|
||||
"stdDev" => TotalsFn::StdDev,
|
||||
"var" => TotalsFn::Var,
|
||||
"none" => TotalsFn::None,
|
||||
_ => TotalsFn::Sum,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if let Some(t) = sh.tables.get_mut(ti)
|
||||
&& let Some(i) = t.column_index(&col)
|
||||
&& let Some(c) = t.columns.get_mut(i)
|
||||
{
|
||||
c.totals = f;
|
||||
}
|
||||
write_totals(sh, ti);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ti) = find_table(s, p)?;
|
||||
let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string();
|
||||
edit(s, |cx| {
|
||||
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
|
||||
t.style = style.clone();
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
|
||||
/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would
|
||||
/// read as an operator inside a structured reference, and the writer would rename the table on save.
|
||||
fn valid_table_name(name: &str) -> bool {
|
||||
name.chars().count() <= 255
|
||||
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
|
||||
&& CellRef::parse(name).is_none()
|
||||
&& !crate::cmd::formulas::is_r1c1(name)
|
||||
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
|
||||
}
|
||||
|
||||
pub(super) fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ti) = find_table(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
|
||||
if !valid_table_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
let wb = &s.doc()?.wb;
|
||||
if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let old = cx.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.name.clone()).unwrap_or_default();
|
||||
if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) {
|
||||
t.name = name.clone();
|
||||
}
|
||||
// Structured references follow the rename.
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
|
||||
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
|
||||
let shadowed = cx.wb.name(&old, i).is_some();
|
||||
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
|
||||
for (c, f) in keys {
|
||||
let Some(e) = f.expr() else { continue };
|
||||
let ne = e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Struct(mut st) if st.table.eq_ignore_ascii_case(&old) => {
|
||||
st.table = name.clone();
|
||||
gridcraft_formula::Expr::Struct(st)
|
||||
}
|
||||
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
|
||||
o => o,
|
||||
});
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn table_convert(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ti) = find_table(s, p)?;
|
||||
edit(s, |cx| {
|
||||
// Apply the table look as plain formatting, then drop the table.
|
||||
let wb = &mut cx.wb;
|
||||
let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)).cloned() else { return Ok(Json::Null) };
|
||||
let looks: Vec<(CellRef, Style)> = t.range.iter().map(|c| (c, crate::tables::table_cell_style(wb, si, &t, c))).collect();
|
||||
for (c, st) in looks {
|
||||
let id = wb.styles.intern(st);
|
||||
if let Some(sh) = wb.sheet_mut(si) {
|
||||
sh.set_style(c, id);
|
||||
}
|
||||
}
|
||||
if let Some(sh) = wb.sheet_mut(si) {
|
||||
sh.tables.remove(ti);
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user