refactor(engine): move cmd/edit.rs items to cmd/edit/

This commit is contained in:
aiongg
2026-10-26 20:43:39 +00:00
parent 228cf80593
commit 0069d91e90
8 changed files with 1584 additions and 1541 deletions
File diff suppressed because it is too large Load Diff
+287
View File
@@ -0,0 +1,287 @@
use super::*;
// ---------------------------------------------------------------- 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(()),
}
}
pub(super) 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);
}
}
crate::cmd::data::grow_autofilter(sh, 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(),
}
}
pub(super) 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);
}
}
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
count += 1;
}
}
Ok(())
})?;
Ok(json!({"cells": count}))
}
pub(super) 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)));
}
let sh = cx.sheet_mut(sheet)?;
sh.set_cell(at, cell);
crate::cmd::data::grow_autofilter(sh, at);
cx.touch(sheet, at);
}
}
Ok(Json::Null)
})
}
+269
View File
@@ -0,0 +1,269 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::*;
// ---------------------------------------------------------------- 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))
}
pub(super) fn copy(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, false)
}
pub(super) fn cut(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, true)
}
pub(super) fn clear_clipboard(s: &mut Session, _: &Json) -> Result<Json> {
s.clipboard = None;
s.format_painter = None;
ok()
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum What {
All,
Formulas,
Values,
Formats,
Comments,
Validation,
AllExceptBorders,
ColumnWidths,
FormulasAndNumberFormats,
ValuesAndNumberFormats,
}
pub(super) 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
}
+96
View File
@@ -0,0 +1,96 @@
use super::*;
// ---------------------------------------------------------------- fill
pub(super) 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)
})
}
pub(super) 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,
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
_ => 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)
})
}
pub(super) 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)
})
}
pub(super) 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}))
}
+184
View File
@@ -0,0 +1,184 @@
use super::cell::input_to_cell;
use super::*;
// ---------------------------------------------------------------- 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()
}
pub(super) 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()}))
}
pub(super) 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())
})
}
pub(super) 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
}
+199
View File
@@ -0,0 +1,199 @@
use super::selection::{select_region, selection_set};
use super::*;
/// How many references the Go To dialog keeps for going back to.
pub(super) const GO_TO_RECENT: usize = 10;
/// The most areas one Go To selects.
pub(super) const GO_TO_MAX_AREAS: usize = 2000;
/// How deep a name that refers to other names is followed.
const GO_TO_NAME_DEPTH: usize = 8;
pub(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "list") == Some(true) {
let d = s.doc()?;
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent}));
}
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
let d = s.doc()?;
let mut target: Option<usize> = None;
let mut areas: Vec<RangeRef> = Vec::new();
let mut named = false;
for part in reference_parts(reference) {
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
return Err(EngineError::Other("Reference isn't valid.".into()));
};
// One selection holds the areas of one sheet.
if target.is_some_and(|t| t != sheet) {
return Err(EngineError::Other("Reference isn't valid.".into()));
}
target = Some(sheet);
named |= by_name;
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
}
}
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
if bool_param(p, "record") == Some(true) && !named {
let d = s.doc_mut()?;
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
d.go_to_recent.insert(0, entry);
d.go_to_recent.truncate(GO_TO_RECENT);
}
Ok(out)
}
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
pub(super) fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
let mut chars = text.char_indices();
std::iter::from_fn(move || {
if done {
return None;
}
for (i, c) in chars.by_ref() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth = depth.saturating_sub(1),
',' if !quoted && depth == 0 => {
let part = text.get(start..i).unwrap_or("").trim();
start = i + 1;
return Some(part);
}
_ => {}
}
}
done = true;
Some(text.get(start..).unwrap_or("").trim())
})
}
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
return None;
}
let (prefix, body) = split_sheet(part);
let on = match &prefix {
Some(n) => wb.sheet_index(n)?,
None => sheet,
};
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
return Some((on, vec![r], false));
}
let name = match prefix {
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
None => wb.name(body, sheet),
};
if let Some(n) = name {
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
}
if prefix.is_none()
&& let Some((si, ti)) = wb.table(body)
{
// As in Excel, a table's name stands for its data, without the header and totals rows.
let t = wb.sheet(si)?.tables.get(ti)?;
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
}
None
}
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
/// reference.
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
let from = n.scope.unwrap_or(sheet);
let formula = n.formula.trim().trim_start_matches('=');
let mut target: Option<usize> = None;
let mut areas = Vec::new();
for p in reference_parts(formula) {
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
if target.is_some_and(|t| t != s) {
return None;
}
target = Some(s);
areas.extend(found);
if areas.len() > GO_TO_MAX_AREAS {
return None;
}
}
target.map(|t| (t, areas))
}
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
/// do not refer to a range are left out.
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
let own: std::collections::HashSet<String> =
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<String> = wb
.names
.iter()
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
.filter_map(|n| match n.scope {
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
None => Some(n.name.clone()),
Some(s) if s == sheet => Some(n.name.clone()),
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
})
.collect();
out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n));
out.dedup_by(|a, b| gridcraft_formula::same_name(a, b));
out
}
pub(super) 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()}))
}
+351
View File
@@ -0,0 +1,351 @@
use super::clipboard::What;
use super::*;
pub(super) 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)?;
crate::cmd::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 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::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 => crate::cmd::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);
}
}
}
+171
View File
@@ -0,0 +1,171 @@
use super::*;
// ---------------------------------------------------------------- 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 }
}
pub(super) 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();
}
// View state: set in place, so the workbook is neither copied nor made dirty.
self.wb.set_active_sheet(i);
self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default();
}
}
pub(super) 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()}))
}
pub(super) 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()}))
}
pub(super) 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()}))
}
pub(super) 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()}))
}
pub(super) 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()}))
}
pub(super) 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()}))
}