mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 20:26:48 -06:00
refactor(engine): move cmd/data.rs items to cmd/data/
This commit is contained in:
+18
-1377
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
use super::sort::sort_key;
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- filter
|
||||
|
||||
pub(super) fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.filter")?;
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
|
||||
let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start };
|
||||
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
|
||||
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
|
||||
if let Some(name) = in_table {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) {
|
||||
t.filter_button = !t.filter_button;
|
||||
if !t.filter_button {
|
||||
t.filter.clear();
|
||||
}
|
||||
}
|
||||
Ok(Json::Null)
|
||||
});
|
||||
}
|
||||
if has {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden.
|
||||
let filtered: Vec<u32> = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect();
|
||||
sh.autofilter = None;
|
||||
for r in filtered {
|
||||
if let Some(info) = sh.rows.get_mut(&r) {
|
||||
info.hidden = false;
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(json!({"filter": false}))
|
||||
});
|
||||
}
|
||||
install_filter(s, p)
|
||||
}
|
||||
|
||||
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
|
||||
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, mut r) = data_target(s, p, "data.filter")?;
|
||||
// A selected header row filters the data below it (#201).
|
||||
if p.get("range").is_none() && r.height() == 1 {
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let below = current_region(sh, r.start).end.row;
|
||||
if below > r.end.row {
|
||||
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
|
||||
Ok(json!({"filter": true, "range": r.a1()}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let af = match &sh.autofilter {
|
||||
Some(a) => a.clone(),
|
||||
None => {
|
||||
install_filter(s, &json!({}))?;
|
||||
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
|
||||
}
|
||||
};
|
||||
let col = match col_param(p.get("column"), af.range.start.col) {
|
||||
Some(c) => c,
|
||||
// A number too large to be a column is outside the filter range, not a missing parameter.
|
||||
None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")),
|
||||
None => return Err(bad("data.filterBy", "missing `column`")),
|
||||
};
|
||||
if col < af.range.start.col || col > af.range.end.col {
|
||||
return Err(bad("data.filterBy", "column outside the filter range"));
|
||||
}
|
||||
let off = col - af.range.start.col;
|
||||
let crit = if bool_param(p, "clear").unwrap_or(false) {
|
||||
None
|
||||
} else if let Some(vals) = p.get("values").and_then(Json::as_array) {
|
||||
Some(FilterCriterion::Values {
|
||||
values: vals.iter().map(crate::cmd::edit::json_to_input).collect(),
|
||||
blanks: bool_param(p, "blanks").unwrap_or(false),
|
||||
})
|
||||
} else if let Some(c) = p.get("custom") {
|
||||
let a =
|
||||
(c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default());
|
||||
let b =
|
||||
c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(crate::cmd::edit::json_to_input).unwrap_or_default()));
|
||||
Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) })
|
||||
} else if let Some(t) = p.get("top") {
|
||||
Some(FilterCriterion::Top10 {
|
||||
bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false),
|
||||
percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false),
|
||||
count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32,
|
||||
})
|
||||
} else {
|
||||
bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage)
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(a) = sh.autofilter.as_mut() {
|
||||
a.criteria.retain(|(o, _)| *o != off);
|
||||
if let Some(c) = crit.clone() {
|
||||
a.criteria.push((off, c));
|
||||
}
|
||||
}
|
||||
apply_filter(&mut cx.wb, sheet);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0);
|
||||
Ok(json!({"hiddenRows": hidden}))
|
||||
}
|
||||
|
||||
/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)?
|
||||
fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
|
||||
let num = gridcraft_core::parse::parse_number_text(crit);
|
||||
let cmp = match (v.as_f64(), num) {
|
||||
(Some(a), Some(b)) => a.partial_cmp(&b),
|
||||
// A numeric ordering comparison only matches numbers; blanks and text never do.
|
||||
(None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None,
|
||||
_ => Some(gridcraft_core::compare_text(text, crit)),
|
||||
};
|
||||
let wild = || crate::cmd::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase());
|
||||
match op {
|
||||
"=" => {
|
||||
if crit.contains(['*', '?']) {
|
||||
wild()
|
||||
} else {
|
||||
cmp == Some(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
"<>" => {
|
||||
if crit.contains(['*', '?']) {
|
||||
!wild()
|
||||
} else {
|
||||
cmp != Some(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
">" => cmp == Some(Ordering::Greater),
|
||||
"<" => cmp == Some(Ordering::Less),
|
||||
">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)),
|
||||
"<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)),
|
||||
"beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()),
|
||||
"endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()),
|
||||
"contains" => text.to_lowercase().contains(&crit.to_lowercase()),
|
||||
"notContains" => !text.to_lowercase().contains(&crit.to_lowercase()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result.
|
||||
fn filter_blank(v: &Value) -> bool {
|
||||
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
|
||||
}
|
||||
|
||||
/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342).
|
||||
/// A table's own filter is left to the table, and a row typed after a blank row does not count.
|
||||
/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc.
|
||||
pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
|
||||
if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {
|
||||
return;
|
||||
}
|
||||
let Some(af) = sh.autofilter.as_ref() else { return };
|
||||
let r = af.range;
|
||||
if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {
|
||||
return;
|
||||
}
|
||||
let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));
|
||||
if let Some(af) = sh.autofilter.as_mut() {
|
||||
af.range = grown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides rows of the autofilter range that fail any criterion.
|
||||
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(sh) = wb.sheet(sheet) else { return };
|
||||
let Some(af) = sh.autofilter.clone() else { return };
|
||||
let r = af.range;
|
||||
let mut hide = Vec::new();
|
||||
// Precompute per-criterion top10 threshold and average once, not per row.
|
||||
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
|
||||
for (i, (off, c)) in af.criteria.iter().enumerate() {
|
||||
if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {
|
||||
let col = r.start.col + off;
|
||||
let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
|
||||
let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };
|
||||
let thr = match c {
|
||||
FilterCriterion::Top10 { bottom, percent, count } => {
|
||||
nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
|
||||
let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
|
||||
let k = k.clamp(1, nums.len().max(1));
|
||||
nums.get(k - 1).copied()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
col_stats.insert(i, (thr, avg));
|
||||
}
|
||||
}
|
||||
for row in r.start.row + 1..=r.end.row {
|
||||
let mut visible = true;
|
||||
for (i, (off, crit)) in af.criteria.iter().enumerate() {
|
||||
let c = CellRef::new(row, r.start.col + off);
|
||||
let v = sh.value(c);
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let ok = match crit {
|
||||
FilterCriterion::Values { values, blanks } => {
|
||||
if filter_blank(&v) {
|
||||
*blanks
|
||||
} else {
|
||||
values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))
|
||||
}
|
||||
}
|
||||
FilterCriterion::Custom { a, b, and } => {
|
||||
let x = custom_ok(&v, &text, &a.0, &a.1);
|
||||
match b {
|
||||
Some(b) => {
|
||||
let y = custom_ok(&v, &text, &b.0, &b.1);
|
||||
if *and { x && y } else { x || y }
|
||||
}
|
||||
None => x,
|
||||
}
|
||||
}
|
||||
FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {
|
||||
(Some(n), Some((thr, _))) => {
|
||||
let thr = thr.unwrap_or(n);
|
||||
if *bottom { n <= thr } else { n >= thr }
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {
|
||||
(Some(n), Some((_, avg))) => {
|
||||
if *above {
|
||||
n > *avg
|
||||
} else {
|
||||
n < *avg
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col,
|
||||
FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col,
|
||||
// Icons are not evaluated here: the row keeps its current visibility.
|
||||
FilterCriterion::Icon { .. } => !sh.is_row_hidden(row),
|
||||
};
|
||||
if !ok {
|
||||
visible = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
hide.push((row, !visible));
|
||||
}
|
||||
let Some(shm) = wb.sheet_mut(sheet) else { return };
|
||||
for (row, h) in hide {
|
||||
if h {
|
||||
shm.rows.entry(row).or_default().hidden = true;
|
||||
} else if let Some(e) = shm.rows.get_mut(&row) {
|
||||
e.hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(a) = sh.autofilter.as_mut() {
|
||||
a.criteria.clear();
|
||||
}
|
||||
apply_filter(&mut cx.wb, sheet);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
apply_filter(&mut cx.wb, sheet);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Distinct display values of a filter column (for the dropdown list).
|
||||
pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> {
|
||||
let Some(sh) = wb.sheet(sheet) else { return vec![] };
|
||||
let Some(af) = &sh.autofilter else { return vec![] };
|
||||
let off = col.saturating_sub(af.range.start.col);
|
||||
let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c);
|
||||
let mut seen = std::collections::BTreeMap::new();
|
||||
for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {
|
||||
let c = CellRef::new(row, col);
|
||||
let v = sh.value(c);
|
||||
let blank = filter_blank(&v);
|
||||
let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
|
||||
let on = match selected {
|
||||
Some(FilterCriterion::Values { values, blanks }) => {
|
||||
if blank {
|
||||
*blanks
|
||||
} else {
|
||||
values.iter().any(|x| x.eq_ignore_ascii_case(&t))
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
|
||||
seen.entry((key, t)).or_insert(on);
|
||||
}
|
||||
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- outline
|
||||
|
||||
fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
if let Some(r) = str_param(p, "rows").and_then(parse_lines) {
|
||||
return Ok((true, r.start.row, r.end.row));
|
||||
}
|
||||
if let Some(r) = str_param(p, "cols").and_then(parse_lines) {
|
||||
return Ok((false, r.start.col, r.end.col));
|
||||
}
|
||||
let r = s.doc()?.selection.current();
|
||||
match str_param(p, "by") {
|
||||
Some("rows") => Ok((true, r.start.row, r.end.row)),
|
||||
Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)),
|
||||
_ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)),
|
||||
_ => Ok((true, r.start.row, r.end.row)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
// The selection belongs to the active sheet, so another sheet needs its lines.
|
||||
if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() {
|
||||
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
|
||||
}
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let b = b.min(a.saturating_add(1_048_575));
|
||||
for i in a..=b {
|
||||
let e = map.entry(i).or_default();
|
||||
e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) };
|
||||
if !on && e.outline == 0 {
|
||||
e.hidden = false;
|
||||
}
|
||||
}
|
||||
// A summary line whose group is gone has nothing left to collapse.
|
||||
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
|
||||
if !on
|
||||
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
|
||||
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
|
||||
{
|
||||
e.collapsed = false;
|
||||
}
|
||||
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or
|
||||
/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the
|
||||
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
|
||||
pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let all = outline::groups_with(map, before);
|
||||
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
|
||||
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
|
||||
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
|
||||
let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0);
|
||||
hits.into_iter().filter(|g| g.level == deepest).collect()
|
||||
};
|
||||
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
|
||||
if chosen.is_empty() {
|
||||
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
|
||||
}
|
||||
for g in chosen {
|
||||
// Inner groups collapsed on their own (their summary marked), before this one changes.
|
||||
let inner: Vec<outline::OutlineGroup> = all
|
||||
.iter()
|
||||
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
|
||||
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
|
||||
.copied()
|
||||
.collect();
|
||||
for i in g.start..=g.end {
|
||||
if let Some(e) = map.get_mut(&i) {
|
||||
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
|
||||
}
|
||||
}
|
||||
if !g.has_summary() {
|
||||
continue;
|
||||
}
|
||||
if hide {
|
||||
map.entry(g.summary()).or_default().collapsed = true;
|
||||
} else if let Some(e) = map.get_mut(&g.summary()) {
|
||||
e.collapsed = false;
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as
|
||||
/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the
|
||||
/// group expanded and leave it invisible (#1714).
|
||||
fn set_hidden(e: &mut LineInfo, hidden: bool) {
|
||||
e.hidden = hidden;
|
||||
if !hidden && e.size == Some(0.0) {
|
||||
e.size = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
|
||||
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
|
||||
pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8);
|
||||
let (rows, cols) = (level("rows"), level("cols"));
|
||||
if rows.is_none() && cols.is_none() {
|
||||
return Err(EngineError::BadParams {
|
||||
cmd: "data.showLevels".into(),
|
||||
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
|
||||
});
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
|
||||
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
|
||||
let Some(n) = n else { continue };
|
||||
let groups = outline::groups_with(map, before);
|
||||
for e in map.values_mut().filter(|e| e.outline > 0) {
|
||||
set_hidden(e, e.outline >= n);
|
||||
}
|
||||
// Inner groups come first, so a summary line shared with the group around it ends up
|
||||
// as that outer group says.
|
||||
for g in groups.into_iter().filter(|g| g.has_summary()) {
|
||||
map.entry(g.summary()).or_default().collapsed = g.level >= n;
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
|
||||
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
|
||||
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
|
||||
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
|
||||
/// group whose detail is all hidden, or the outermost when none is.
|
||||
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
|
||||
let mut marked: Vec<outline::OutlineGroup> =
|
||||
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
|
||||
// A mark on a line that nested groups share moves with the outermost of them whose
|
||||
// detail is all hidden, or with the outermost when none is.
|
||||
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
|
||||
for g in &marked {
|
||||
let key = (!g.collapsed, g.level);
|
||||
let best = owner.entry(g.summary()).or_insert(key);
|
||||
*best = (*best).min(key);
|
||||
}
|
||||
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
|
||||
for g in &marked {
|
||||
if let Some(e) = map.get_mut(&g.summary()) {
|
||||
e.collapsed = false;
|
||||
}
|
||||
}
|
||||
for g in marked {
|
||||
let g = outline::OutlineGroup { summary_before: !on, ..g };
|
||||
if g.has_summary() {
|
||||
map.entry(g.summary()).or_default().collapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
|
||||
let Some(on) = bool_param(p, key) else { continue };
|
||||
let was_before = outline::summary_before(sh, rows);
|
||||
if rows {
|
||||
sh.summary_below = on;
|
||||
} else {
|
||||
sh.summary_right = on;
|
||||
}
|
||||
if was_before != on {
|
||||
continue;
|
||||
}
|
||||
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
|
||||
}
|
||||
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.subtotal")?;
|
||||
let header = bool_param(p, "header").unwrap_or(true);
|
||||
let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col);
|
||||
let func = str_param(p, "function").unwrap_or("sum");
|
||||
let code = match func {
|
||||
"count" => 3,
|
||||
"average" => 1,
|
||||
"max" => 4,
|
||||
"min" => 5,
|
||||
"product" => 6,
|
||||
_ => 9,
|
||||
};
|
||||
let cols: Vec<u32> = p
|
||||
.get("columns")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
|
||||
.unwrap_or_else(|| vec![r.end.col]);
|
||||
let label = match func {
|
||||
"count" => "Count",
|
||||
"average" => "Average",
|
||||
"max" => "Max",
|
||||
"min" => "Min",
|
||||
"product" => "Product",
|
||||
_ => "Total",
|
||||
};
|
||||
// Group boundaries (header in the first row).
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let mut groups: Vec<(u32, u32, String)> = Vec::new();
|
||||
let first = r.start.row + u32::from(header);
|
||||
let mut start = first;
|
||||
for row in first..=r.end.row + 1 {
|
||||
let cur = sh.value(CellRef::new(row, group_col)).display();
|
||||
let prev = sh.value(CellRef::new(start, group_col)).display();
|
||||
if row > r.end.row || cur != prev {
|
||||
groups.push((start, row - 1, prev));
|
||||
start = row;
|
||||
}
|
||||
}
|
||||
// The totals go below each group, so the outline puts summary rows below their detail (the
|
||||
// Subtotal dialog's "Summary below data"), in the same undo step.
|
||||
if !sh.summary_below {
|
||||
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
|
||||
}
|
||||
// Insert a total row after each group, bottom-up so indices stay valid.
|
||||
let mut inserted = 0;
|
||||
for (g0, g1, name) in groups.iter().rev() {
|
||||
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
|
||||
let row = g1 + 1;
|
||||
let lbl = CellRef::new(row, group_col).a1();
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?;
|
||||
for c in &cols {
|
||||
let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1();
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
}
|
||||
s.execute(
|
||||
"home.bold",
|
||||
json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}),
|
||||
)?;
|
||||
s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
|
||||
inserted += 1;
|
||||
}
|
||||
let last = r.end.row + inserted + 1;
|
||||
let lbl = CellRef::new(last, group_col).a1();
|
||||
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?;
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?;
|
||||
for c in &cols {
|
||||
let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1();
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
}
|
||||
Ok(json!({"groups": inserted}))
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SortKey {
|
||||
col: u32,
|
||||
desc: bool,
|
||||
by: String,
|
||||
list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names,
|
||||
/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
|
||||
fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
if let Some(t) = str_param(p, "range")
|
||||
&& let (Some(name), _) = split_sheet(t)
|
||||
&& d.wb.sheet_index(&name) != Some(sheet)
|
||||
{
|
||||
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
|
||||
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
|
||||
}
|
||||
Ok((sheet, data_range(s, p)?))
|
||||
}
|
||||
|
||||
pub(super) fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result<Json> {
|
||||
let (sheet, r) = sort_target(s, p)?;
|
||||
let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col);
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let header = guess_header(&d.wb, sh, r);
|
||||
do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
|
||||
}
|
||||
|
||||
pub(super) fn sort(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = sort_target(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
|
||||
let by_cols = str_param(p, "orientation") == Some("columns");
|
||||
let case = bool_param(p, "matchCase").unwrap_or(false);
|
||||
let mut keys: Vec<SortKey> = Vec::new();
|
||||
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
|
||||
let col = if by_cols {
|
||||
let row = match k.get("row").and_then(Json::as_u64) {
|
||||
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
|
||||
Some(n) => {
|
||||
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
|
||||
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
row.or_else(|| col_param(k.get("column"), r.start.row))
|
||||
} else {
|
||||
col_param(k.get("column"), r.start.col)
|
||||
};
|
||||
let Some(col) = col else { continue };
|
||||
keys.push(SortKey {
|
||||
col,
|
||||
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
|
||||
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
|
||||
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
|
||||
});
|
||||
}
|
||||
if keys.is_empty() {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
|
||||
return ok();
|
||||
}
|
||||
if keys.len() > 64 {
|
||||
return Err(bad("data.sort", "too many sort levels (64 max)"));
|
||||
}
|
||||
do_sort(s, sheet, r, header, keys, by_cols, case)
|
||||
}
|
||||
|
||||
fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
|
||||
// Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here.
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?;
|
||||
let body = if by_cols {
|
||||
if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r }
|
||||
} else if header && r.height() > 1 {
|
||||
RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
|
||||
} else {
|
||||
r
|
||||
};
|
||||
if body.count() > 20_000_000 {
|
||||
return Err(bad("data.sort", "range too large"));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) };
|
||||
if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) {
|
||||
return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into()));
|
||||
}
|
||||
let n = if by_cols { body.width() } else { body.height() };
|
||||
let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) };
|
||||
let theme = cx.wb.theme.clone();
|
||||
let styles = &cx.wb.styles;
|
||||
let key_val = |i: u32, k: &SortKey| -> (Value, u32) {
|
||||
let c = line(i, k.col);
|
||||
let v = sh.value(c);
|
||||
let color = match k.by.as_str() {
|
||||
"cellColor" => {
|
||||
styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX)
|
||||
}
|
||||
"fontColor" => {
|
||||
styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
(v, color)
|
||||
};
|
||||
let mut order: Vec<u32> = (0..n).collect();
|
||||
let keyed: Vec<Vec<(Value, u32)>> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
for (ki, k) in keys.iter().enumerate() {
|
||||
let (va, ca) = &keyed[a as usize][ki];
|
||||
let (vb, cb) = &keyed[b as usize][ki];
|
||||
let o = if k.by != "values" {
|
||||
ca.cmp(cb)
|
||||
} else if let Some(list) = &k.list {
|
||||
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
|
||||
pos(va).cmp(&pos(vb))
|
||||
} else if case && va.is_text() && vb.is_text() {
|
||||
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
|
||||
} else {
|
||||
sort_compare(va, vb)
|
||||
};
|
||||
// Blanks always sort last, whatever the order.
|
||||
let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o };
|
||||
if o != Ordering::Equal {
|
||||
return o;
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
});
|
||||
if order.iter().enumerate().all(|(i, &o)| i as u32 == o) {
|
||||
return Ok(Json::Null);
|
||||
}
|
||||
// Move whole lines (cells with formulas adjusted for relative references).
|
||||
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
|
||||
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
|
||||
// describe — the way cell pictures and row heights do. Collect them before inserting,
|
||||
// so a swap can't resurrect one from a destination that is also a source.
|
||||
let side: Vec<(CellRef, Comment)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
|
||||
.collect();
|
||||
let links: Vec<(CellRef, Hyperlink)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
|
||||
.collect();
|
||||
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
|
||||
let mut pictures = Vec::new();
|
||||
for (dst_i, &src_i) in order.iter().enumerate() {
|
||||
for &k in &lanes {
|
||||
let src = line(src_i, k);
|
||||
let dst = line(dst_i as u32, k);
|
||||
let mut cell = sh.cell(src).cloned();
|
||||
if let Some(c) = cell.as_mut()
|
||||
&& let Some(f) = &c.formula
|
||||
&& let Some(e) = f.expr()
|
||||
{
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64);
|
||||
c.formula = Some(Arc::new(Formula::from_expr(shifted)));
|
||||
}
|
||||
if let Some(picture) = sh.cell_pictures.get(&src) {
|
||||
pictures.push((dst, picture.clone()));
|
||||
}
|
||||
moved.push((dst, cell));
|
||||
}
|
||||
}
|
||||
// Row heights travel with rows.
|
||||
let heights: Vec<Option<LineInfo>> =
|
||||
if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() };
|
||||
// Floating objects anchored inside the sorted body travel with their lines, unless they
|
||||
// are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering
|
||||
// is the inverse of `order`: `dst_of[src] = dst`.
|
||||
let dst_of: Vec<u32> = {
|
||||
let mut v = vec![0u32; order.len()];
|
||||
for (dst_i, &src_i) in order.iter().enumerate() {
|
||||
if let Some(slot) = v.get_mut(src_i as usize) {
|
||||
*slot = dst_i as u32;
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
|
||||
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
|
||||
// can be re-fitted to the lines it spans after the sort.
|
||||
let objs: Vec<Anchor> =
|
||||
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
|
||||
let descs: Vec<(u32, u32, f32, f32)> = objs
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
|
||||
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
|
||||
let tc = sh.col_at(x2);
|
||||
let tr = sh.row_at(y2);
|
||||
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
|
||||
})
|
||||
.collect();
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
for (c, cell) in &moved {
|
||||
match cell {
|
||||
Some(x) => shm.set_cell(*c, x.clone()),
|
||||
None => {
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every cell of the body is the destination of exactly one source cell, so clearing the
|
||||
// body and inserting the permuted entries is the same permutation the cells went through.
|
||||
for c in body.iter() {
|
||||
shm.comments.remove(&c);
|
||||
shm.hyperlinks.remove(&c);
|
||||
}
|
||||
for (at, cm) in side {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (i, h) in heights.into_iter().enumerate() {
|
||||
let row = body.start.row + i as u32;
|
||||
match h {
|
||||
Some(info) => {
|
||||
shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info });
|
||||
}
|
||||
None => {
|
||||
if let Some(e) = shm.rows.get_mut(&row) {
|
||||
e.size = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
|
||||
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
|
||||
// Only objects anchored inside the sorted block move: within its lines on the sort axis
|
||||
// and within its span on the other axis (a picture beside the block stays put).
|
||||
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
|
||||
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
|
||||
let ni = shm.images.len();
|
||||
let nch = shm.charts.len();
|
||||
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
|
||||
let other = if by_cols { a.cell.row } else { a.cell.col };
|
||||
if other < o0 || other > o1 {
|
||||
continue;
|
||||
}
|
||||
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
|
||||
if na != a {
|
||||
if i < ni {
|
||||
if let Some(o) = shm.images.get_mut(i) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if i < ni + nch {
|
||||
if let Some(o) = shm.charts.get_mut(i - ni) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
|
||||
o.anchor = na;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (c, _) in moved {
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
Ok(json!({"sorted": body.a1()}))
|
||||
}
|
||||
|
||||
/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the
|
||||
/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a
|
||||
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
|
||||
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
|
||||
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
|
||||
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
|
||||
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
|
||||
if a.mode == AnchorMode::Absolute {
|
||||
return a;
|
||||
}
|
||||
let (tc, tr, tdx, tdy) = bottom;
|
||||
let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } };
|
||||
let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) };
|
||||
if axis_start < b0 || axis_start > b1 {
|
||||
return a;
|
||||
}
|
||||
let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) };
|
||||
if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 {
|
||||
let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) };
|
||||
let x1 = sh.col_left(nc) + a.dx as f64;
|
||||
let y1 = sh.row_top(nr) + a.dy as f64;
|
||||
let x2 = sh.col_left(nbc) + tdx as f64;
|
||||
let y2 = sh.row_top(nbr) + tdy as f64;
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
|
||||
} else {
|
||||
Anchor { cell: CellRef::new(nr, nc), ..a }
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
|
||||
/// before uppercase at the first letter that differs in case.
|
||||
fn case_order(a: &str, b: &str) -> Ordering {
|
||||
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
|
||||
}
|
||||
|
||||
pub(super) fn sort_key(v: &Value) -> (u8, i64, String) {
|
||||
match v {
|
||||
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
|
||||
Value::Text(t) => (1, 0, t.to_lowercase()),
|
||||
Value::Bool(b) => (2, *b as i64, String::new()),
|
||||
Value::Empty => (9, 0, String::new()),
|
||||
_ => (3, 0, v.display()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- tools
|
||||
|
||||
pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Rows and columns past the data are blank: only the part with data is compared (whole
|
||||
// columns compared a million blank rows and reported them as removed duplicates).
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
|
||||
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
|
||||
let cols: Vec<u32> = p
|
||||
.get("columns")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
|
||||
.unwrap_or_else(|| (r.start.col..=r.end.col).collect());
|
||||
// With no column to compare, every row key is empty: the first row would survive and every
|
||||
// other row would be deleted as a duplicate. A column outside the range compares data the
|
||||
// user didn't select. Excel refuses both.
|
||||
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
|
||||
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
|
||||
}
|
||||
let start = r.start.row + header as u32;
|
||||
let mut removed = 0;
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut keep: Vec<u32> = Vec::new();
|
||||
// Cells are compared by the text they show, as in Excel: the same date in two formats
|
||||
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
|
||||
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
|
||||
for row in start..=r.end.row {
|
||||
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
|
||||
if seen.insert(key) {
|
||||
keep.push(row);
|
||||
} else {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
let pictures: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
|
||||
})
|
||||
.collect();
|
||||
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
|
||||
// describe them move with them; the ones on removed rows are dropped with the row.
|
||||
let notes: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
|
||||
})
|
||||
.collect();
|
||||
let rows: Vec<Vec<Option<Cell>>> =
|
||||
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
for row in start..=r.end.row {
|
||||
for c in r.start.col..=r.end.col {
|
||||
shm.remove_cell(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
for row in start..=r.end.row {
|
||||
for c in r.start.col..=r.end.col {
|
||||
let at = CellRef::new(row, c);
|
||||
shm.comments.remove(&at);
|
||||
shm.hyperlinks.remove(&at);
|
||||
}
|
||||
}
|
||||
for (i, cells) in rows.into_iter().enumerate() {
|
||||
for (j, cell) in cells.into_iter().enumerate() {
|
||||
if let Some(cell) = cell {
|
||||
shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (at, cm) in notes {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let remaining = (r.end.row + 1 - start) as usize - removed;
|
||||
Ok(json!({"removed": removed, "remaining": remaining}))
|
||||
}
|
||||
|
||||
pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = target_range(s, p)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let mut delims: Vec<char> = p
|
||||
.get("delimiters")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect())
|
||||
.unwrap_or_else(|| vec!['\t']);
|
||||
if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) {
|
||||
delims.push(o);
|
||||
}
|
||||
let fixed: Option<Vec<usize>> =
|
||||
p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect());
|
||||
let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false);
|
||||
let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"');
|
||||
let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start);
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||||
let mut out: Vec<(CellRef, String)> = Vec::new();
|
||||
for (i, row) in (r.start.row..=r.end.row).enumerate() {
|
||||
let text = sh.value(CellRef::new(row, r.start.col)).display();
|
||||
let parts: Vec<String> = match &fixed {
|
||||
Some(w) => {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut cuts = vec![0];
|
||||
let mut breaks: Vec<usize> = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect();
|
||||
breaks.sort_unstable();
|
||||
breaks.dedup();
|
||||
cuts.extend(breaks);
|
||||
cuts.push(chars.len());
|
||||
cuts.windows(2)
|
||||
.map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::<String>().trim().to_string()).unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
None => split_delimited(&text, &delims, consecutive, quote),
|
||||
};
|
||||
for (j, part) in parts.into_iter().enumerate() {
|
||||
out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part));
|
||||
}
|
||||
}
|
||||
for (c, t) in out {
|
||||
let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(x) => shm.set_cell(c, x),
|
||||
None => {
|
||||
shm.remove_cell(c);
|
||||
}
|
||||
}
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_q = false;
|
||||
let mut last_delim = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == quote {
|
||||
if in_q && chars.peek() == Some("e) {
|
||||
cur.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = !in_q;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !in_q && delims.contains(&c) {
|
||||
if consecutive && last_delim {
|
||||
continue;
|
||||
}
|
||||
out.push(std::mem::take(&mut cur));
|
||||
last_delim = true;
|
||||
continue;
|
||||
}
|
||||
last_delim = false;
|
||||
cur.push(c);
|
||||
}
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- validation
|
||||
|
||||
fn op_param(s: Option<&str>) -> CfOperator {
|
||||
match s.unwrap_or("between") {
|
||||
"notBetween" => CfOperator::NotBetween,
|
||||
"equal" => CfOperator::Equal,
|
||||
"notEqual" => CfOperator::NotEqual,
|
||||
"greater" | "greaterThan" => CfOperator::Greater,
|
||||
"less" | "lessThan" => CfOperator::Less,
|
||||
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
|
||||
_ => CfOperator::Between,
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` minus `cut`, as up to four non-overlapping rectangles.
|
||||
fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
let Some(i) = r.intersection(cut) else { return vec![r] };
|
||||
let mut out = Vec::new();
|
||||
if i.start.row > r.start.row {
|
||||
out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));
|
||||
}
|
||||
if i.end.row < r.end.row {
|
||||
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
|
||||
}
|
||||
if i.start.col > r.start.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));
|
||||
}
|
||||
if i.end.col < r.end.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
|
||||
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
for dv in validations.iter_mut() {
|
||||
for cut in cuts {
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
|
||||
}
|
||||
}
|
||||
validations.retain(|d| !d.ranges.is_empty());
|
||||
}
|
||||
|
||||
pub(super) fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if bool_param(p, "clear").unwrap_or(false) {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
remove_validation_cells(&mut sh.validations, &ranges);
|
||||
Ok(Json::Null)
|
||||
});
|
||||
}
|
||||
let Some(kind) = str_param(p, "type") else {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({})));
|
||||
return ok();
|
||||
};
|
||||
let kind = match kind {
|
||||
"whole" => ValidationKind::Whole,
|
||||
"decimal" => ValidationKind::Decimal,
|
||||
"list" => ValidationKind::List,
|
||||
"date" => ValidationKind::Date,
|
||||
"time" => ValidationKind::Time,
|
||||
"textLength" => ValidationKind::TextLength,
|
||||
"custom" => ValidationKind::Custom,
|
||||
"any" => ValidationKind::Any,
|
||||
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
|
||||
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
|
||||
};
|
||||
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
|
||||
return Err(bad("data.validation", "a list validation needs `formula1`"));
|
||||
}
|
||||
const OPERATORS: [&str; 12] = [
|
||||
"between",
|
||||
"notBetween",
|
||||
"equal",
|
||||
"notEqual",
|
||||
"greater",
|
||||
"greaterThan",
|
||||
"less",
|
||||
"lessThan",
|
||||
"greaterOrEqual",
|
||||
"greaterThanOrEqual",
|
||||
"lessOrEqual",
|
||||
"lessThanOrEqual",
|
||||
];
|
||||
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
|
||||
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
|
||||
}
|
||||
let error_style = match str_param(p, "errorStyle") {
|
||||
None | Some("stop") => ErrorStyle::Stop,
|
||||
Some("warning") => ErrorStyle::Warning,
|
||||
Some("information") => ErrorStyle::Information,
|
||||
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
|
||||
};
|
||||
let f1 = str_param(p, "formula1").unwrap_or("");
|
||||
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
|
||||
let dv = Validation {
|
||||
ranges: ranges.clone(),
|
||||
kind,
|
||||
op: op_param(str_param(p, "operator")),
|
||||
f1,
|
||||
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
|
||||
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
|
||||
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
|
||||
input_title: str_param(p, "inputTitle").unwrap_or("").into(),
|
||||
input_message: str_param(p, "inputMessage").unwrap_or("").into(),
|
||||
show_input: true,
|
||||
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
|
||||
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
|
||||
error_style,
|
||||
show_error: true,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
remove_validation_cells(&mut sh.validations, &ranges);
|
||||
sh.validations.push(dv.clone());
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
|
||||
/// than a reference or a name to evaluate.
|
||||
fn is_literal_list(f: &str) -> bool {
|
||||
f.starts_with('"')
|
||||
|| (!f.contains('!')
|
||||
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
|
||||
}
|
||||
|
||||
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
|
||||
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
|
||||
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
|
||||
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
|
||||
fn list_source(f: &str) -> String {
|
||||
let t = f.trim();
|
||||
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
|
||||
return f.trim_start_matches('=').to_string();
|
||||
}
|
||||
format!("\"{}\"", t.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// List entries of a list validation (literal or from a range).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
|
||||
let f = dv.f1.trim();
|
||||
if is_literal_list(f) {
|
||||
// A quoted list is a string literal: `""` inside stands for one `"`.
|
||||
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
|
||||
Some(body) => body.replace("\"\"", "\""),
|
||||
None => f.trim_matches('"').to_string(),
|
||||
};
|
||||
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
}
|
||||
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
|
||||
match gridcraft_calc::evaluate(wb, sheet, at, f) {
|
||||
Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(),
|
||||
v if !v.is_empty() && !v.is_error() => vec![v.display()],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks typed input against the validation of a cell. `Ok(None)` = valid.
|
||||
pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> {
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?;
|
||||
if dv.kind == ValidationKind::Any || !dv.show_error {
|
||||
return None;
|
||||
}
|
||||
if input.is_empty() && dv.allow_blank {
|
||||
return None;
|
||||
}
|
||||
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
|
||||
// The formulas are written for the first cell of the range and see the value being entered,
|
||||
// as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5).
|
||||
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
|
||||
let entered;
|
||||
let wb = if sh.value(at) == v {
|
||||
wb
|
||||
} else {
|
||||
let mut w = wb.clone();
|
||||
if let Some(sh) = w.sheet_mut(sheet) {
|
||||
let style = sh.style_id(at);
|
||||
sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() });
|
||||
}
|
||||
entered = w;
|
||||
&entered
|
||||
};
|
||||
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at));
|
||||
let num = |f: &str| eval(f).to_number().ok();
|
||||
let ok = match dv.kind {
|
||||
ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
|
||||
ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false),
|
||||
ValidationKind::TextLength => {
|
||||
let n = input.chars().count() as f64;
|
||||
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
|
||||
}
|
||||
_ => match v.as_f64() {
|
||||
Some(n) => {
|
||||
if dv.kind == ValidationKind::Whole && n.fract() != 0.0 {
|
||||
false
|
||||
} else {
|
||||
compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num))
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
};
|
||||
if ok {
|
||||
return None;
|
||||
}
|
||||
let msg = if dv.error_message.is_empty() {
|
||||
"This value doesn't match the data validation restrictions defined for this cell.".to_string()
|
||||
} else {
|
||||
dv.error_message.clone()
|
||||
};
|
||||
Some((dv.clone(), msg))
|
||||
}
|
||||
|
||||
/// A formula written for `first`, as seen from `at` (relative references shifted).
|
||||
fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
|
||||
if first == at {
|
||||
return f.to_string();
|
||||
}
|
||||
match gridcraft_formula::parse(f.trim_start_matches('=')) {
|
||||
Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative(
|
||||
e,
|
||||
at.row as i64 - first.row as i64,
|
||||
at.col as i64 - first.col as i64,
|
||||
)),
|
||||
Err(_) => f.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
|
||||
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
|
||||
pub(in super::super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
|
||||
if first == to {
|
||||
return f.to_string();
|
||||
}
|
||||
let moved = formula_at(f, first, to);
|
||||
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
|
||||
let eq = if f.starts_with('=') { "=" } else { "" };
|
||||
if has_relative { format!("{eq}{moved}") } else { f.to_string() }
|
||||
}
|
||||
|
||||
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
|
||||
let Some(a) = a else { return false };
|
||||
match op {
|
||||
CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)),
|
||||
CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)),
|
||||
CfOperator::Equal => n == a,
|
||||
CfOperator::NotEqual => n != a,
|
||||
CfOperator::Greater => n > a,
|
||||
CfOperator::Less => n < a,
|
||||
CfOperator::GreaterOrEqual => n >= a,
|
||||
CfOperator::LessOrEqual => n <= a,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let input = str_param(p, "input").unwrap_or("");
|
||||
match check_validation(&d.wb, d.wb.active_sheet(), at, input) {
|
||||
None => Ok(json!({"ok": true})),
|
||||
Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let mut bad_cells = Vec::new();
|
||||
for dv in &sh.validations {
|
||||
for r in &dv.ranges {
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u));
|
||||
let Some(r) = r else { continue };
|
||||
for c in r.iter().take(100_000) {
|
||||
let text = sh.input_text(c);
|
||||
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
|
||||
bad_cells.push(c.a1());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"invalid": bad_cells}))
|
||||
}
|
||||
Reference in New Issue
Block a user