1623 lines
72 KiB
Rust
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");
|
|
}
|
|
}
|