gridcraft/crates/engine/src/cmd/edit.rs

1623 lines
72 KiB
Rust

//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
use super::*;
use crate::selection::{current_region, jump};
use crate::{Clipboard, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set),
cmd!(
"range.setValues",
"Set Values",
[],
None,
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
has_doc,
range_set_values
),
cmd!(
"range.fill",
"Fill Range With Input",
[],
Some("Ctrl+Enter"),
"{range?, input}: enters the same input in every selected cell (relative formulas adjust)",
has_doc,
range_fill
),
cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to),
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy),
cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut),
cmd!(
"edit.paste",
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
has_doc,
paste
),
cmd!(
"edit.pasteSpecial",
"Paste Special…",
["Home", "Clipboard"],
Some("Ctrl+Cmd+V"),
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
has_clipboard,
paste_special
),
cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard),
cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")),
cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")),
cmd!("edit.clearContents", "Clear Contents", ["Home", "Editing", "Clear"], Some("Delete"), "{range?}", has_doc, |s, p| clear(
s, p, "contents"
)),
cmd!("edit.clearComments", "Clear Comments and Notes", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(
s, p, "comments"
)),
cmd!("edit.clearHyperlinks", "Clear Hyperlinks", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "hyperlinks")),
cmd!("edit.fillDown", "Fill Down", ["Home", "Editing", "Fill"], Some("Cmd+D"), "{range?}", has_doc, |s, p| fill_dir(s, p, 1, 0)),
cmd!("edit.fillRight", "Fill Right", ["Home", "Editing", "Fill"], Some("Cmd+R"), "{range?}", has_doc, |s, p| fill_dir(s, p, 0, 1)),
cmd!("edit.fillUp", "Fill Up", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, -1, 0)),
cmd!("edit.fillLeft", "Fill Left", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, 0, -1)),
cmd!(
"edit.autoFill",
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
has_doc,
auto_fill
),
cmd!(
"edit.fillSeries",
"Series…",
["Home", "Editing", "Fill"],
None,
"{range?, direction?: columns|rows, type?: linear|growth|date|autofill, step?: 1, stop?, dateUnit?: day|weekday|month|year}",
has_doc,
fill_series
),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
cmd!(
"edit.replace",
"Replace…",
["Home", "Editing", "Find & Select"],
Some("Ctrl+H"),
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}",
has_doc,
replace
),
cmd!(
"edit.formatPainter",
"Format Painter",
["Home", "Clipboard"],
None,
"{sticky?: bool} — first call picks up the selection's formats; with {apply: \"C3:D4\"} pastes them",
has_doc,
format_painter
),
cmd!(noundo "edit.beginEdit", "Edit Cell", [], Some("F2"), "{}", has_doc, begin_edit),
]
}
// ---------------------------------------------------------------- entry
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
/// format). Inherits the destination's cell, row or column style, including its number
/// format, before parsing. Errors when the formula can't be parsed.
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
if input.is_empty() {
let c = Cell { value: Value::Empty, formula: None, style };
return Ok(if c.is_blank() { None } else { Some(c) });
}
let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";
if fmt_is_text {
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
}
let is_formula = input.starts_with('=')
|| (input.len() > 1
&& (input.starts_with('+') || input.starts_with('-'))
&& gridcraft_core::parse::parse_number_text(input).is_none()
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
if is_formula {
let body = input.strip_prefix('=').unwrap_or(input);
// Excel closes missing parentheses for you.
let mut text = body.to_string();
let mut parsed = gridcraft_formula::parse(&text);
for _ in 0..8 {
if parsed.is_ok() {
break;
}
text.push(')');
parsed = gridcraft_formula::parse(&text);
}
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
let expr = wb.name_call_case(expr);
check_arg_counts(&expr)?;
let mut cell = Cell::formula(Formula::from_expr(expr));
cell.style = style;
// Formulas whose result is a date/time get a format from functions like TODAY().
if wb.styles.get(style).num_fmt.as_str() == "General" {
let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default();
let auto = if f.starts_with("TODAY(")
|| f.starts_with("DATE(")
|| f.starts_with("EDATE(")
|| f.starts_with("EOMONTH(")
|| f.starts_with("WORKDAY(")
{
Some("m/d/yyyy")
} else if f.starts_with("NOW(") {
Some("m/d/yyyy h:mm")
} else if f.starts_with("TIME(") {
Some("h:mm AM/PM")
} else {
None
};
if let Some(code) = auto {
cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code));
}
}
return Ok(Some(cell));
}
let parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
let mut style = style;
if let Some(code) = parsed.format {
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
if cur == "General" {
style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code));
}
}
// Wrap text automatically when the input has line breaks (Alt+Enter).
if input.contains('\n') && !wb.styles.get(style).align.wrap {
style = wb.styles.derive(style, |s| s.align.wrap = true);
}
Ok(Some(Cell { value: parsed.value, formula: None, style }))
}
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
/// refused when it's entered, rather than stored to show `#VALUE!`. Special forms the evaluator
/// handles itself (IF, LET, LAMBDA…) and defined LAMBDA names aren't in the registry: skipped.
fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
let mut bad = None;
expr.walk(&mut |e| {
if bad.is_none()
&& let gridcraft_formula::Expr::Call(name, args) = e
&& let Some(spec) = gridcraft_functions::lookup(name)
&& (args.len() < spec.min_args || args.len() > spec.max_args)
{
let few = args.len() < spec.min_args;
bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature));
}
});
match bad {
Some(msg) => Err(EngineError::Other(msg)),
None => Ok(()),
}
}
fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = match cell_param_on(s, p, "cell", sheet)? {
Some(c) => c,
None => s.doc()?.selection.active,
};
let input = str_param(p, "input")
.or_else(|| str_param(p, "value"))
.map(str::to_string)
.or_else(|| p.get("value").map(json_to_input))
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
let array = bool_param(p, "array").unwrap_or(false);
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
&& sh.is_protected()
&& d.wb.styles.get(sh.style_id(at)).protection.locked
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
edit(s, |cx| {
if array {
// Legacy Ctrl+Shift+Enter array formula over the selection.
let range = cx.sel.current();
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
if let Some(f) = cell.formula.as_mut() {
std::sync::Arc::make_mut(f).array = Some(range);
}
let sh = cx.sheet_mut(sheet)?;
// The array takes the whole range: other cells in it lose their contents.
for c in range.iter().skip(1).take(1_000_000) {
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
let style = old.style;
sh.set_cell(c, Cell { style, ..Cell::default() });
}
}
sh.set_cell(range.start, cell);
cx.touch(sheet, range.start);
return Ok(Json::Null);
}
let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?;
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
None => {
sh.remove_cell(at);
}
}
cx.touch(sheet, at);
Ok(Json::Null)
})?;
let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
Ok(json!({"cell": at.a1(), "value": v.display()}))
}
pub(crate) fn json_to_input(v: &Json) -> String {
match v {
Json::Null => String::new(),
Json::Bool(b) => {
if *b {
"TRUE".into()
} else {
"FALSE".into()
}
}
Json::Number(n) => n.to_string(),
Json::String(s) => s.clone(),
other => other.to_string(),
}
}
fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let start = target_range(s, p)?.start;
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
if rows.len() > MAX_ROWS as usize {
return Err(bad("range.setValues", "too many rows"));
}
let mut count = 0;
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
let cols: Vec<&Json> = match row.as_array() {
Some(a) => a.iter().collect(),
None => vec![row],
};
for (ci, v) in cols.iter().enumerate() {
let Some(at) = start.offset(ri as i64, ci as i64) else { continue };
let input = json_to_input(v);
let cell = match v {
Json::Number(n) => Some(Cell {
value: Value::number(n.as_f64().unwrap_or(0.0)),
formula: None,
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
}),
Json::Bool(b) => {
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
}
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
};
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(c) => sh.set_cell(at, c),
None => {
sh.remove_cell(at);
}
}
cx.touch(sheet, at);
count += 1;
}
}
Ok(())
})?;
Ok(json!({"cells": count}))
}
fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
let input = str_param(p, "input").unwrap_or("").to_string();
let origin = s.doc()?.selection.active;
edit(s, |cx| {
let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?;
for r in &ranges {
if r.count() > 2_000_000 {
return Err(bad("range.fill", "range too large"));
}
for at in r.iter() {
let mut cell = base.clone().unwrap_or_default();
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
{
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
}
cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style);
cx.sheet_mut(sheet)?.set_cell(at, cell);
cx.touch(sheet, at);
}
}
Ok(Json::Null)
})
}
// ---------------------------------------------------------------- selection
fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("sheet").is_some() {
let i = target_sheet(s, p)?;
s.doc_mut()?.wb_switch_sheet(i);
} else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell"))
&& let (Some(_), _) = split_sheet(r)
{
let i = target_sheet(s, p)?;
s.doc_mut()?.wb_switch_sheet(i);
}
let ranges = target_ranges(s, p)?;
let active = cell_param(s, p, "active")?;
let d = s.doc_mut()?;
let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default();
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
if let Some(sh) = d.wb.active() {
sel.expand_merges(sh);
}
d.selection = sel;
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
}
impl crate::DocState {
/// Switches the active sheet, remembering each sheet's selection.
pub fn wb_switch_sheet(&mut self, i: usize) {
if i >= self.wb.sheets.len() || i == self.wb.active_sheet {
return;
}
let cur = self.wb.active_sheet;
if self.sheet_selections.len() < self.wb.sheets.len() {
self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default());
}
if let Some(slot) = self.sheet_selections.get_mut(cur) {
*slot = self.selection.clone();
}
// The active sheet is view state: change it without making the workbook dirty.
let dirty = self.is_dirty();
let wb = std::sync::Arc::make_mut(&mut self.wb);
wb.active_sheet = i;
if !dirty {
self.saved = self.wb.clone();
}
self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default();
}
}
fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0);
let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0);
let extend = bool_param(p, "extend").unwrap_or(false);
let jmp = bool_param(p, "jump").unwrap_or(false);
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let sel = &mut d.selection;
// Extension moves the far corner, not the active cell.
let from = if extend {
let r = sel.current();
let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row };
let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col };
CellRef::new(far_row, far_col)
} else {
sel.active
};
// Skip hidden rows/columns and step over merged areas.
let mut to = if jmp {
jump(sheet, from, dr.signum(), dc.signum())
} else {
let mut t = from.offset_clamped(dr, dc);
let mut guard = 0;
while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 {
let n = t.offset_clamped(dr.signum(), dc.signum());
if n == t {
break;
}
t = n;
guard += 1;
}
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
} else if dc > 0 {
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
}
}
t
};
if let Some(m) = sheet.merge_at(to)
&& !extend
{
to = m.start;
}
if extend {
sel.extend_to(to);
sel.expand_merges(sheet);
} else {
*sel = crate::Selection::at(to);
if let Some(m) = sheet.merge_at(to) {
sel.ranges = vec![m];
}
}
Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}))
}
fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
let fwd = bool_param(p, "forward").unwrap_or(true);
let by_row = bool_param(p, "byRow").unwrap_or(false);
let d = s.doc_mut()?;
d.selection.cycle(fwd, by_row);
Ok(json!({"active": d.selection.active.a1()}))
}
fn select_all(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let region = current_region(sheet, d.selection.active);
let all = RangeRef::all();
let r = if region.is_single() || d.selection.current() == region { all } else { region };
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] };
Ok(json!({"selection": d.selection.a1()}))
}
fn select_region(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let r = current_region(sheet, d.selection.active);
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] };
Ok(json!({"selection": d.selection.a1()}))
}
fn select_row(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let r = d.selection.current();
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] };
Ok(json!({"selection": d.selection.a1()}))
}
fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let r = d.selection.current();
let active = d.selection.active;
d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] };
Ok(json!({"selection": d.selection.a1()}))
}
fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
// A defined name or table?
let d = s.doc()?;
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
let sheet = d.wb.active_sheet;
if let Some(n) = d.wb.name(reference, sheet) {
n.formula.clone()
} else if let Some((si, ti)) = d.wb.table(reference) {
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t)
} else {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
} else {
reference.to_string()
};
let resolved = resolved.replace('$', "");
selection_set(s, &json!({"range": resolved}))
}
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("blanks");
let d = s.doc()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() };
if kind == "lastCell" {
let c = sheet.used_range().map(|r| r.end).unwrap_or_default();
return selection_set(s, &json!({"cell": c.a1()}));
}
if kind == "currentRegion" {
return select_region(s, &json!({}));
}
let mut hits: Vec<CellRef> = Vec::new();
if scope.count() > 4_000_000 {
return Err(bad("edit.goToSpecial", "range too large"));
}
for c in scope.iter() {
let cell = sheet.cell(c);
let v = sheet.value(c);
let ok = match kind {
"blanks" => v.is_empty(),
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
"comments" | "notes" => sheet.comments.contains_key(&c),
"errors" => v.is_error(),
"numbers" => v.is_number(),
"text" => v.is_text(),
"visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col),
"conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))),
"dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))),
_ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))),
};
if ok {
hits.push(c);
}
}
if hits.is_empty() {
return Err(EngineError::Other("No cells were found.".into()));
}
let ranges = coalesce(&hits);
let d = s.doc_mut()?;
d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges };
Ok(json!({"selection": d.selection.a1(), "count": hits.len()}))
}
/// Merges cells into vertical runs per column (a compact multi-area selection).
fn coalesce(cells: &[CellRef]) -> Vec<RangeRef> {
let mut sorted = cells.to_vec();
sorted.sort_by_key(|c| (c.col, c.row));
let mut out: Vec<RangeRef> = Vec::new();
for c in sorted {
if let Some(last) = out.last_mut()
&& last.start.col == c.col
&& last.end.col == c.col
&& last.end.row + 1 == c.row
{
last.end.row = c.row;
continue;
}
out.push(RangeRef::cell(c));
}
if out.len() > 2000 {
out.truncate(2000);
}
out
}
// ---------------------------------------------------------------- history
fn undo(s: &mut Session, p: &Json) -> Result<Json> {
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
let d = s.doc_mut()?;
let mut label = String::new();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
d.redo.push(cur);
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
}
d.calc.rebuild(&d.wb);
d.revision += 1;
Ok(json!({"undone": label}))
}
fn redo(s: &mut Session, p: &Json) -> Result<Json> {
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
let d = s.doc_mut()?;
let mut label = String::new();
for _ in 0..steps {
let Some(e) = d.redo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
d.undo.push(cur);
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
}
d.calc.rebuild(&d.wb);
d.revision += 1;
Ok(json!({"redone": label}))
}
// ---------------------------------------------------------------- clipboard
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if ranges.len() > 1 && cut {
return Err(multiple());
}
// The selection as made (untrimmed), for Insert Copied Cells.
let selected = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let trim = |range: RangeRef| {
if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
}
};
let (wb, range, areas) = if ranges.len() > 1 {
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
result["html"] = Json::String(html);
}
Ok(result)
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
/// into one block at the first area's place, and that block. Formulas travel as their values, as
/// in Excel. `None` when the areas don't line up.
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
let first = *areas.first()?;
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
let mut sorted = areas.to_vec();
if across {
sorted.sort_by_key(|a| a.start.col);
} else if down {
sorted.sort_by_key(|a| a.start.row);
} else {
return None;
}
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
return None;
}
let anchor = sorted.first()?.start;
let (h, w) = if across {
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
} else {
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
};
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
let src = wb.sheet(sheet)?;
let mut out = wb.clone();
let dst = out.sheet_mut(sheet)?;
dst.take_cells(block);
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
}
} else {
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
}
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
Some((out, block))
}
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, false)
}
fn cut(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, true)
}
fn clear_clipboard(s: &mut Session, _: &Json) -> Result<Json> {
s.clipboard = None;
s.format_painter = None;
ok()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum What {
All,
Formulas,
Values,
Formats,
Comments,
Validation,
AllExceptBorders,
ColumnWidths,
FormulasAndNumberFormats,
ValuesAndNumberFormats,
}
fn paste(s: &mut Session, p: &Json) -> Result<Json> {
// External text (from the system clipboard) when it differs from ours.
if let Some(text) = str_param(p, "text")
&& s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n']))
{
return paste_text(s, p, text);
}
if s.clipboard.is_none() {
return Err(EngineError::Other("Nothing to paste.".into()));
}
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
}
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
if let Some(cl) = cell.as_mut() {
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
None => {
sh.remove_cell(c);
}
}
cx.touch(sheet, c);
}
}
if h > 0 && w > 0 {
let end = at.offset_clamped(h as i64 - 1, w as i64 - 1);
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
}
Ok(())
})?;
Ok(json!({"rows": h, "cols": w}))
}
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
/// source recorded for it in its HTML, with a format that shows the same text when one can be
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
let Some(hc) = html else { return };
let Some(n) = hc.num else { return };
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
return;
}
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
return;
}
cell.value = Value::number(n);
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
&& let Some(code) = crate::clip_html::format_for(field, n)
{
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
}
}
/// Tab-separated text with Excel-style quoting.
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut row = Vec::new();
let mut field = String::new();
let mut chars = text.chars().peekable();
let mut quoted = false;
let mut at_start = true;
while let Some(c) = chars.next() {
if quoted {
if c == '"' {
if chars.peek() == Some(&'"') {
field.push('"');
chars.next();
} else {
quoted = false;
}
} else {
field.push(c);
}
continue;
}
match c {
'"' if at_start => {
quoted = true;
at_start = false;
}
'\t' => {
row.push(std::mem::take(&mut field));
at_start = true;
}
'\r' => {}
'\n' => {
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
at_start = true;
}
_ => {
field.push(c);
at_start = false;
}
}
}
if !field.is_empty() || !row.is_empty() {
row.push(field);
rows.push(row);
}
rows
}
fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
let what = match str_param(p, "what").unwrap_or("all") {
"all" => What::All,
"formulas" => What::Formulas,
"values" => What::Values,
"formats" => What::Formats,
"comments" | "notes" => What::Comments,
"validation" => What::Validation,
"allExceptBorders" => What::AllExceptBorders,
"columnWidths" => What::ColumnWidths,
"formulasAndNumberFormats" => What::FormulasAndNumberFormats,
"valuesAndNumberFormats" => What::ValuesAndNumberFormats,
other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))),
};
let op = str_param(p, "operation").unwrap_or("none").to_string();
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
let transpose = bool_param(p, "transpose").unwrap_or(false);
let link = bool_param(p, "link").unwrap_or(false);
if link && !clip.areas.is_empty() {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
}
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
let src = clip.range;
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
// Tile the copy over a larger destination that is a multiple of its size.
let tile = sel.height() % h == 0
&& sel.width() % w == 0
&& !sel.is_single()
&& cell_param_on(s, p, "at", dest_sheet)?.is_none()
&& !sel.is_full_cols()
&& !sel.is_full_rows()
&& sel.count() <= 4_000_000;
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
let same_doc = s.doc()?.uid == clip.doc_uid;
let cut_move = clip.cut && same_doc && what == What::All;
let src_wb = clip.wb.clone();
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
let src_sheet_name = src_sheet.name.clone();
edit(s, |cx| {
// Styles from another workbook must be re-interned.
let mut style_map = std::collections::HashMap::new();
let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId {
if same_doc {
return id;
}
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
};
let mut moved_formulas = Vec::new();
let mut moved_pictures = std::collections::BTreeMap::new();
if cut_move {
// Remove the source first (contents and formats), and fix references to it.
let source = cx.sheet_mut(clip.sheet)?;
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
let taken = source.take_cells(src);
for (c, _) in &taken {
cx.touch(clip.sheet, *c);
}
moved_formulas = taken;
}
for tr in 0..tiles_r {
for tc in 0..tiles_c {
for r in 0..src.height() {
for c in 0..src.width() {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move {
moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone())
} else {
src_sheet.cell(sc).cloned()
};
let src_val = src_sheet.value(sc);
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
continue;
}
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
let mut new_picture = old_picture.clone();
let mut new = old.clone();
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
moved.array = f.array;
return Some(std::sync::Arc::new(moved));
}
let moved =
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula::from_expr(moved)))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
st
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none"
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
let a = old.value.clone();
let b = new.value.clone();
// Numbers stored as text take part (multiplying by 1 turns them into
// numbers); other text and errors stay as they are.
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
&& (a.is_number() || a.is_empty() || a.is_text())
&& (b.is_number() || b.is_empty())
{
new.value = match op.as_str() {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" => {
if y == 0.0 {
Value::Error(gridcraft_core::CellError::Div0)
} else {
Value::number(x / y)
}
}
_ => b.clone(),
};
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
new.value = a;
}
// A formula already there is kept and combined with the pasted number.
if let Some(f) = &old.formula
&& let Ok(y) = b.to_number()
&& (b.is_number() || b.is_empty())
&& let Some(sym) = match op.as_str() {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
{
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
}
}
}
// Merged areas inside the copied block travel with it.
if matches!(what, What::All | What::Formats) && !transpose {
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect();
for m in merges {
let dr = at.row as i64 - src.start.row as i64;
let dc = at.col as i64 - src.start.col as i64;
if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
let nm = RangeRef::new(a, b);
let sh = cx.sheet_mut(dest_sheet)?;
sh.merges.retain(|x| !x.intersects(&nm));
sh.merges.push(nm);
}
}
}
if cut_move {
// References elsewhere to the moved block now point to its new place.
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
let e = if dest_sheet == clip.sheet {
gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }
} else {
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
};
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
cx.structural = true;
}
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
*cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] };
Ok(())
})?;
if cut_move {
s.clipboard = None;
}
Ok(json!({"pasted": s.doc()?.selection.a1()}))
}
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
if keys.is_empty() {
continue;
}
let Some(sheet) = wb.sheet_mut(si) else { continue };
for (c, f) in keys {
let Some(expr) = f.expr() else { continue };
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
let text = gridcraft_formula::print(&new);
if text != f.text
&& let Some(cell) = sheet.cells.get_mut(c)
{
let mut nf = Formula::from_expr(new);
nf.array = f.array;
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if let Ok(expr) = gridcraft_formula::parse(&n.formula) {
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
n.formula = gridcraft_formula::print(&new);
}
}
}
// ---------------------------------------------------------------- clear
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
// Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
// and only when every box in the selection is unchecked are the boxes removed.
let uncheck_only = what == "contents" && {
let d = s.doc()?;
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
};
edit(s, |cx| {
for r in &ranges {
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
Some(u) => match r.intersection(&u) {
Some(x) => x,
None => continue,
},
None => continue,
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
cell.formula = None;
if checkbox && uncheck_only {
cell.value = Value::Bool(false);
} else {
cell.value = Value::Empty;
if checkbox {
cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General"));
}
}
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, mut cell) in cells {
match what {
"all" => {
sh.remove_cell(c);
}
"contents" => {
sh.set_cell(c, cell);
}
"formats" => {
cell.style = StyleId::DEFAULT;
sh.cells.set(c, cell);
}
_ => {}
}
}
if matches!(what, "all" | "comments") {
sh.comments.retain(|c, _| !r.contains(*c));
}
if matches!(what, "all" | "hyperlinks") {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
}
if matches!(what, "all" | "formats") {
sh.merges.retain(|m| !r.contains_range(m));
}
for c in r.iter().take(1_000_000) {
cx.changed.push((sheet, c));
}
}
Ok(Json::Null)
})
}
// ---------------------------------------------------------------- fill
fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let mut r = target_range(s, p)?;
// Whole rows or columns fill only where the sheet has content (nothing on an empty sheet).
if r.is_full_cols() || r.is_full_rows() {
match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) {
Some(u) => r = u,
None => return Ok(Json::Null),
}
}
if r.count() > 5_000_000 {
return Err(bad("edit.fill", "range too large"));
}
// With one row selected, Fill Down copies from the row above (Excel behaviour).
if dr == 1 && r.height() == 1 {
r.start.row = r.start.row.saturating_sub(1);
}
if dc == 1 && r.width() == 1 {
r.start.col = r.start.col.saturating_sub(1);
}
let src = if dr == 1 {
RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col))
} else if dr == -1 {
RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end)
} else if dc == 1 {
RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col))
} else {
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
};
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
Ok(Json::Null)
})
}
fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?;
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
let mode = match str_param(p, "mode").unwrap_or("series") {
"copy" => crate::fill::FillMode::Copy,
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
_ => crate::fill::FillMode::Series,
};
if target.count() > 5_000_000 || src.count() > 5_000_000 {
return Err(bad("edit.autoFill", "range too large"));
}
edit(s, |cx| {
crate::fill::fill(cx, sheet, src, target, mode)?;
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
Ok(Json::Null)
})
}
fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let r = target_range(s, p)?;
let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height());
let kind = str_param(p, "type").unwrap_or("linear").to_string();
let step = f64_param(p, "step").unwrap_or(1.0);
let stop = f64_param(p, "stop");
let unit = str_param(p, "dateUnit").unwrap_or("day").to_string();
edit(s, |cx| {
crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?;
Ok(Json::Null)
})
}
fn flash_fill(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let r = target_range(s, p)?;
let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?;
if n == 0 {
return Err(EngineError::Other(
"We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(),
));
}
Ok(json!({"filled": n}))
}
// ---------------------------------------------------------------- find & replace
fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool {
let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) };
let has_wild = w.contains(['*', '?']);
if has_wild {
let pat = if whole { w } else { format!("*{w}*") };
return wildcard(&h, &pat);
}
if whole { h == w } else { h.contains(&w) }
}
/// `*`/`?` wildcard match with `~` escapes.
pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
let t: Vec<char> = text.chars().collect();
let mut p: Vec<(char, bool)> = Vec::new();
let mut it = pat.chars();
while let Some(c) = it.next() {
if c == '~' {
if let Some(n) = it.next() {
p.push((n, true));
}
} else {
p.push((c, false));
}
}
let (mut ti, mut pi) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) {
ti += 1;
pi += 1;
} else if pi < p.len() && p[pi] == ('*', false) {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(sp) = star {
pi = sp + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == ('*', false) {
pi += 1;
}
pi == p.len()
}
fn find(s: &mut Session, p: &Json) -> Result<Json> {
let what = str_param(p, "what").unwrap_or("").to_string();
if what.is_empty() {
return Err(bad("edit.find", "missing `what`"));
}
let case = bool_param(p, "matchCase").unwrap_or(false);
let whole = bool_param(p, "wholeCell").unwrap_or(false);
let values = str_param(p, "lookIn") == Some("values");
let by_cols = bool_param(p, "byColumns").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(false);
let workbook = str_param(p, "within") == Some("workbook");
s.last_find = Some(p.clone());
let d = s.doc()?;
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet] };
let mut hits: Vec<(usize, CellRef, String)> = Vec::new();
for si in sheets {
let Some(sh) = d.wb.sheet(si) else { continue };
let mut cells: Vec<(CellRef, String)> = sh
.cells
.iter()
.filter_map(|(c, cell)| {
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
crate::display::cell_text(&d.wb, sh, c)
} else {
cell.input_text()
};
matches(&text, &what, case, whole).then_some((c, text))
})
.collect();
if by_cols {
cells.sort_by_key(|(c, _)| (c.col, c.row));
}
hits.extend(cells.into_iter().map(|(c, t)| (si, c, t)));
}
if hits.is_empty() {
return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}")));
}
if all {
let list: Vec<Json> =
hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect();
return Ok(json!({"count": list.len(), "results": list}));
}
// Next hit after the active cell.
let cur = (d.wb.active_sheet, d.selection.active);
let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) };
let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned();
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
let dd = s.doc_mut()?;
dd.wb_switch_sheet(si);
dd.selection = crate::Selection::at(c);
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
}
fn replace(s: &mut Session, p: &Json) -> Result<Json> {
let what = str_param(p, "what").unwrap_or("").to_string();
let with = str_param(p, "with").unwrap_or("").to_string();
if what.is_empty() {
return Err(bad("edit.replace", "missing `what`"));
}
let case = bool_param(p, "matchCase").unwrap_or(false);
let whole = bool_param(p, "wholeCell").unwrap_or(false);
let all = bool_param(p, "all").unwrap_or(true);
let workbook = str_param(p, "within") == Some("workbook");
let active = s.doc()?.selection.active;
let n = edit(s, |cx| {
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] };
let mut n = 0;
for si in sheets {
let cells: Vec<(CellRef, String)> =
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
for (c, text) in cells {
if !all && c != active {
continue;
}
if !matches(&text, &what, case, whole) {
continue;
}
let new = if whole {
with.clone()
} else if case {
text.replace(&what, &with)
} else {
replace_ci(&text, &what, &with)
};
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
let sh = cx.sheet_mut(si)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
None => {
sh.remove_cell(c);
}
}
cx.touch(si, c);
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"replaced": n}))
}
fn replace_ci(text: &str, what: &str, with: &str) -> String {
let lower = text.to_lowercase();
let w = what.to_lowercase();
if lower.len() != text.len() || w.is_empty() {
return text.replace(what, with);
}
let mut out = String::new();
let mut i = 0;
while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) {
out.push_str(text.get(i..i + pos).unwrap_or(""));
out.push_str(with);
i += pos + w.len();
}
out.push_str(text.get(i..).unwrap_or(""));
out
}
// ---------------------------------------------------------------- format painter
fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) {
let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) };
let dest_sheet = s.doc()?.wb.active_sheet;
let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles;
edit(s, |cx| {
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
let (h, w) = (src.height(), src.width());
let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
for c in dest.iter().take(2_000_000) {
let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
let st = src_sheet.style_id(sc);
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
cx.sheet_mut(dest_sheet)?.set_style(c, st);
cx.touch(dest_sheet, c);
}
*cx.sel = crate::Selection::range(dest);
Ok(())
})?;
if !sticky {
s.format_painter = None;
}
return ok();
}
let sticky = bool_param(p, "sticky").unwrap_or(false);
let d = s.doc()?;
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet, d.selection.current(), sticky));
ok()
}
fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
s.ui_requests.push(crate::UiRequest::EditCell(None));
ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_array_entry() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap();
s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap();
s.execute("selection.set", json!({"range": "C2:C4"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "C3")["value"], json!(4.0));
// Past the result Excel shows #N/A; the old content of the range is replaced.
assert_eq!(get(&mut s, "C4")["text"], "#N/A");
let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text());
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
}
#[test]
fn wildcards() {
assert!(wildcard("hello", "h*o"));
assert!(wildcard("hello", "h?llo"));
assert!(!wildcard("hello", "h?lo"));
assert!(wildcard("a*b", "a~*b"));
assert!(!wildcard("axb", "a~*b"));
assert!(wildcard("", "*"));
}
#[test]
fn tsv() {
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
}
#[test]
fn paste_special_operations_keep_text_errors_and_formulas() {
let mut s = Session::new();
s.new_workbook();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}),
)
.unwrap();
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], "123");
s.execute("edit.copy", json!({"range": "A2"})).unwrap();
s.execute("selection.set", json!({"range": "A1:E1"})).unwrap();
s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap();
assert_eq!(get(&mut s, "A1")["value"], json!(123.0));
assert_eq!(get(&mut s, "B1")["value"], "Total");
assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A");
assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1");
assert_eq!(get(&mut s, "D1")["value"], json!(8.0));
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
}
#[test]
fn replace_case_insensitive() {
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
}
}