mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 13:16:50 -06:00
Paste Link writes a formula into every cell whatever `what` says, so a linked paste of formats from a file now makes the destination FromFile as a paste of formulas does. Column widths and notes write no cells, linked or not, and still carry nothing. Regression test for the three kinds.
1963 lines
94 KiB
Rust
1963 lines
94 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), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
|
|
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|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
|
|
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);
|
|
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
|
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 mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
|
|
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
|
|
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
|
|
if parsed.format.is_none()
|
|
&& !input.contains('%')
|
|
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
|
|
&& let Value::Number(n) = parsed.value
|
|
&& n.abs() >= 1.0
|
|
{
|
|
parsed.value = Value::Number(n / 100.0);
|
|
}
|
|
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 }))
|
|
}
|
|
|
|
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
|
|
/// and refuses a formula that still doesn't parse.
|
|
pub(crate) fn parse_typed_formula(body: &str) -> Result<gridcraft_formula::Expr> {
|
|
// Excel closes missing parentheses for you.
|
|
let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse);
|
|
parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))
|
|
}
|
|
|
|
/// 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!`. Functions the registry doesn't
|
|
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
|
|
/// unknown functions are skipped.
|
|
pub(crate) 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)
|
|
&& !spec.accepts(args.len())
|
|
{
|
|
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,
|
|
};
|
|
// Both spellings take a string, number or boolean, as typed (#697).
|
|
let input = p.get("input").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));
|
|
}
|
|
let active_sheet = s.doc()?.wb.active_sheet;
|
|
edit(s, |cx| {
|
|
if array {
|
|
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
|
|
// the cell; a cell elsewhere (or on another sheet) gets a one-cell array.
|
|
let selected = cx.sel.current();
|
|
let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) };
|
|
// A table header can't hold an array's value, and a table resizes under it.
|
|
if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) {
|
|
return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into()));
|
|
}
|
|
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)")) };
|
|
// A string is run through typed input by default, which reads it with en-US rules: a caller
|
|
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
|
|
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
|
|
// are unaffected either way.
|
|
let parse = match p.get("parse") {
|
|
None | Some(Json::Null) => true,
|
|
Some(Json::Bool(b)) => *b,
|
|
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
|
|
};
|
|
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() })
|
|
}
|
|
// Text as given: no date or number reading, and a leading `'` is part of the
|
|
// text rather than the marker that makes it text. A formula is still a
|
|
// formula, which is what the ticket asks for and what `=` means in a cell.
|
|
Json::String(text) if !parse && !text.starts_with('=') => {
|
|
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
|
// Line breaks still turn on Wrap Text, as typed input does.
|
|
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
|
|
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
|
|
}
|
|
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
|
}
|
|
_ => 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| {
|
|
// Each cell gets what typing the input there gives: the automatic number format where
|
|
// it's General, its own format otherwise, text where it's Text. That depends only on
|
|
// the cell's style, so the input is parsed once per style.
|
|
let mut by_style: std::collections::HashMap<StyleId, Option<Cell>> = std::collections::HashMap::new();
|
|
for r in &ranges {
|
|
if r.count() > 2_000_000 {
|
|
return Err(bad("range.fill", "range too large"));
|
|
}
|
|
for at in r.iter() {
|
|
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
|
let base = match by_style.get(&style) {
|
|
Some(c) => c.clone(),
|
|
None => {
|
|
let c = input_to_cell(&input, sheet, at, &mut cx.wb)?;
|
|
by_style.insert(style, c.clone());
|
|
c
|
|
}
|
|
};
|
|
let mut cell = base.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)));
|
|
}
|
|
cx.sheet_mut(sheet)?.set_cell(at, cell);
|
|
cx.touch(sheet, at);
|
|
}
|
|
}
|
|
Ok(Json::Null)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------- selection
|
|
|
|
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
|
|
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
|
let Some(p) = sheet.protection.as_ref() else { return true };
|
|
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
|
|
}
|
|
|
|
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);
|
|
if !cell_selectable(&d.wb, sh, sel.active) {
|
|
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
|
|
}
|
|
}
|
|
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 { sel.moving_end() } 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 && !cell_selectable(&d.wb, sheet, to) {
|
|
// Step on to the next selectable cell in the same direction; stay put if there is none.
|
|
let (sr, sc) = (dr.signum(), dc.signum());
|
|
let mut t = to;
|
|
let mut found = false;
|
|
if !jmp && (sr, sc) != (0, 0) {
|
|
for _ in 0..10_000 {
|
|
let n = t.offset_clamped(sr, sc);
|
|
if n == t {
|
|
break;
|
|
}
|
|
t = n;
|
|
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
|
|
}
|
|
to = t;
|
|
}
|
|
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()}))
|
|
}
|
|
|
|
pub(super) 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!("{}!{}", gridcraft_core::quote_sheet(&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();
|
|
let now = d.wb.clone();
|
|
for _ in 0..steps {
|
|
let Some(e) = d.undo.pop() else { break };
|
|
let cur_wb = d.wb.clone();
|
|
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
|
|
let cur_selection = e.selection.clone();
|
|
label = e.label.clone();
|
|
d.wb = e.wb;
|
|
d.selection = e.selection;
|
|
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
|
|
}
|
|
super::extra::keep_watch_list(&now, &mut d.wb);
|
|
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();
|
|
let now = d.wb.clone();
|
|
for _ in 0..steps {
|
|
let Some(e) = d.redo.pop() else { break };
|
|
let cur_wb = d.wb.clone();
|
|
let cur_selection = d.selection.clone();
|
|
label = e.label.clone();
|
|
d.wb = e.wb;
|
|
d.selection = e.selection;
|
|
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
|
|
}
|
|
super::extra::keep_watch_list(&now, &mut d.wb);
|
|
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, trust: d.trust };
|
|
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);
|
|
let h = rows.len();
|
|
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
|
|
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
|
|
// only as far as the text's own shape, so spans in the markup can't expand without bound.
|
|
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
|
|
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();
|
|
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
|
|
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
|
|
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
|
|
}
|
|
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);
|
|
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
|
|
// with the block: the destination is written from the clipboard snapshot taken
|
|
// before the cut, so removing them here loses nothing and leaves the old place
|
|
// clean instead of leaving one merged block and one note behind.
|
|
source.comments.retain(|c, _| !src.contains(*c));
|
|
source.hyperlinks.retain(|c, _| !src.contains(*c));
|
|
source.merges.retain(|m| !src.contains_range(m));
|
|
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);
|
|
// The array range moves with its formula.
|
|
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
|
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
|
moved.dynamic = f.dynamic;
|
|
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 { dynamic: f.dynamic, ..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;
|
|
}
|
|
}
|
|
}
|
|
// The copied cell's validation rule replaces whatever rule the destination had.
|
|
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
|
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
|
|
let sh = cx.sheet_mut(dest_sheet)?;
|
|
super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
|
|
if let Some(mut dv) = rule {
|
|
// Relative references move with the rule, as pasted formulas do.
|
|
let first = dv.ranges.first().map_or(sc, |r| r.start);
|
|
dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest);
|
|
dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest));
|
|
dv.ranges = vec![RangeRef::new(dest, dest)];
|
|
sh.validations.push(dv);
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
|
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
|
}
|
|
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(())
|
|
})?;
|
|
// Formulas and validation from an untrusted file leave the destination untrusted; plain values,
|
|
// formats (cell styles and merged areas, no conditional formats), column widths and notes
|
|
// carry nothing active. A linked paste writes a formula into every cell whatever `what` says,
|
|
// except for column widths and notes, which write no cells at all.
|
|
let passive =
|
|
matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats));
|
|
if !same_doc && !passive {
|
|
let d = s.doc_mut()?;
|
|
d.trust = d.trust.receive(clip.trust);
|
|
}
|
|
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) {
|
|
use gridcraft_formula::adjust::Edit;
|
|
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
|
|
// (a cut moves its arrays itself).
|
|
let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. });
|
|
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 on_target = moves_arrays && host.eq_ignore_ascii_case(target);
|
|
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);
|
|
let array = match f.array {
|
|
Some(a) if on_target => super::sheet::map_range(a, e),
|
|
a => a,
|
|
};
|
|
if (text != f.text || array != f.array)
|
|
&& let Some(cell) = sheet.cells.get_mut(c)
|
|
{
|
|
let mut nf = Formula::from_expr(new);
|
|
nf.array = array;
|
|
nf.dynamic = f.dynamic;
|
|
cell.formula = Some(std::sync::Arc::new(nf));
|
|
}
|
|
}
|
|
}
|
|
for n in wb.names.iter_mut() {
|
|
if let Ok(expr) = gridcraft_formula::parse_name(&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();
|
|
// Report every stored cell we touch, even beyond a million coordinates in a sparse
|
|
// selection. Removed formula anchors must also reach calc so their spills disappear.
|
|
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
|
|
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") {
|
|
let mut unmerged = false;
|
|
sh.merges.retain(|m| {
|
|
let keep = !r.contains_range(m);
|
|
unmerged |= !keep;
|
|
keep
|
|
});
|
|
if unmerged {
|
|
// An empty merge can block a spill without storing any cells in this range.
|
|
// As in Unmerge, retry blocked formula anchors that could reach the freed area.
|
|
let reach = RangeRef::new(CellRef::new(0, 0), r.end);
|
|
let blocked: Vec<_> = sh
|
|
.cells
|
|
.iter_range(reach)
|
|
.filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill))
|
|
.map(|(c, _)| (sheet, c))
|
|
.collect();
|
|
cx.changed.extend(blocked);
|
|
}
|
|
}
|
|
}
|
|
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() > crate::fill::MAX_FILL_CELLS {
|
|
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,
|
|
"extend" => crate::fill::FillMode::Extend,
|
|
_ => crate::fill::FillMode::Series,
|
|
};
|
|
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
|
return Err(bad("edit.autoFill", "range too large"));
|
|
}
|
|
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
|
|
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
|
|
// into whole columns (`A:A`).
|
|
if !crate::fill::holds_source_in_corner(src, target) {
|
|
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
|
|
}
|
|
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}))
|
|
}
|
|
|
|
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
|
|
/// byte positions in the folded text still line up with the original's character boundaries.
|
|
fn folds_in_place(s: &str) -> bool {
|
|
s.chars().all(|c| {
|
|
let mut folded = c.to_lowercase();
|
|
let first = folded.next();
|
|
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
|
|
})
|
|
}
|
|
|
|
fn replace_ci(text: &str, what: &str, with: &str) -> String {
|
|
let lower = text.to_lowercase();
|
|
let w = what.to_lowercase();
|
|
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
|
|
// boundary: a length check alone misses foldings that keep the total length while shifting
|
|
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
|
|
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
|
|
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
|
|
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 };
|
|
// Whole rows or columns take a line format, as the ribbon's formatting commands give
|
|
// them, instead of a style on each of their cells (a column alone has a million). The
|
|
// line format comes from the first cell of the source pattern across the line; cells
|
|
// already stored follow the pattern. Row heights and column widths stay as they are.
|
|
let rows = dest.is_full_rows() && !dest.is_full_cols();
|
|
let cols = dest.is_full_cols() && !dest.is_full_rows();
|
|
let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
|
|
if rows || cols {
|
|
let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
|
|
let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
|
|
for line in lines {
|
|
let sl = src_first + (line - first) % n;
|
|
// The source cell for position `k` along the line.
|
|
let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
|
|
let line_style = if src_full {
|
|
if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
|
|
} else {
|
|
Some(src_sheet.style_id(at(0)))
|
|
};
|
|
let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
|
|
let (dest_line, src_line) = if rows {
|
|
(RangeRef::rows(line, line), RangeRef::rows(sl, sl))
|
|
} else {
|
|
(RangeRef::cols(line, line), RangeRef::cols(sl, sl))
|
|
};
|
|
let mut cells: Vec<CellRef> =
|
|
cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
|
|
if src_full {
|
|
cells.extend(
|
|
src_sheet
|
|
.cells
|
|
.iter_range(src_line)
|
|
.map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
|
|
);
|
|
}
|
|
if cols {
|
|
// A row format outranks a column format: the column's cells in formatted
|
|
// rows need a style of their own.
|
|
cells.extend(
|
|
cx.wb
|
|
.sheet(dest_sheet)
|
|
.into_iter()
|
|
.flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
|
|
);
|
|
}
|
|
let sh = cx.sheet_mut(dest_sheet)?;
|
|
(if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
|
|
for c in cells {
|
|
let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
|
|
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);
|
|
return Ok(());
|
|
}
|
|
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 cell_set_takes_numbers_for_input_and_value() {
|
|
// #697: a JSON number for `input` used to clear the cell instead of entering it.
|
|
let mut s = Session::new();
|
|
s.new_workbook();
|
|
s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap();
|
|
s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap();
|
|
s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap();
|
|
s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap();
|
|
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
|
assert_eq!(get(&mut s, "A1")["value"], json!(123.5));
|
|
assert_eq!(get(&mut s, "A2")["value"], json!(77.0));
|
|
assert_eq!(get(&mut s, "A3")["value"], json!(true));
|
|
assert_eq!(get(&mut s, "A4")["value"], json!(200.5));
|
|
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
|
|
}
|
|
|
|
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
|
|
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
|
|
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
|
|
#[test]
|
|
fn set_values_can_store_strings_as_the_text_they_are() {
|
|
let mut s = Session::new();
|
|
s.new_workbook();
|
|
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
|
|
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
|
|
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
|
|
|
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
|
|
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
|
|
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
|
|
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
|
|
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
|
|
|
|
// With `parse: false` each string is the text it was given, and a formula is still a
|
|
// formula.
|
|
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
|
|
for (i, want) in strings.iter().enumerate() {
|
|
let cell = format!("{}3", (b'A' + i as u8) as char);
|
|
let got = get(&mut s, &cell);
|
|
if *want == "=1+1" {
|
|
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
|
|
assert_eq!(got["formula"], "=1+1", "{got}");
|
|
} else {
|
|
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
|
|
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
|
|
}
|
|
}
|
|
// Numbers and booleans are untouched either way.
|
|
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
|
|
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
|
|
assert_eq!(get(&mut s, "B5")["value"], json!(true));
|
|
assert_eq!(get(&mut s, "C5")["text"], "");
|
|
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
|
|
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
|
|
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
|
|
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
|
|
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
|
|
// A line break still turns on Wrap Text.
|
|
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
|
|
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
|
|
let d = s.doc().unwrap();
|
|
let sh = d.wb.active().unwrap();
|
|
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
|
|
}
|
|
|
|
#[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 an_array_entered_outside_the_selection_covers_only_its_cell() {
|
|
// Found by the command-sequence fuzzer: the array took the selection's range even with
|
|
// `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the
|
|
// selected cells were wiped.
|
|
let mut s = Session::new();
|
|
s.new_workbook();
|
|
s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap();
|
|
s.execute("selection.set", json!({"range": "K20:K22"})).unwrap();
|
|
s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap();
|
|
let sh = s.doc().unwrap().wb.active().unwrap();
|
|
let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap();
|
|
assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap()));
|
|
assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep"));
|
|
}
|
|
|
|
#[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 cut_paste_that_overflows_the_grid_moves_nothing() {
|
|
// #997: the source was emptied first and the cells that fell off the grid were skipped.
|
|
let mut s = Session::new();
|
|
s.new_workbook();
|
|
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
|
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
|
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
|
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
|
|
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
|
|
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
|
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
|
// Not transposed, the second row would be past the last row.
|
|
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
|
|
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
|
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
|
// Not transposed it still fits in column XFD.
|
|
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
|
|
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
|
|
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
|
|
}
|
|
|
|
#[test]
|
|
fn replace_case_insensitive() {
|
|
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
|
}
|
|
|
|
#[test]
|
|
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
|
|
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
|
|
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
|
|
// no longer line up with the original, and the old slicing dropped the tail.
|
|
let mut s = Session::new();
|
|
s.new_workbook();
|
|
let text = "\u{212A}\u{0130}\u{0130}";
|
|
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
|
|
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
|
|
let sh = s.doc().unwrap().wb.active().unwrap();
|
|
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
|
|
}
|
|
}
|