diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 32164fb24..e48fe8838 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,33 @@ use super::*; use crate::selection::{current_region, jump}; use crate::{Clipboard, Session}; +mod cell; +mod clipboard; +mod fill; +mod find; +mod go_to; +mod paste_special; +mod selection; + +use cell::{cell_set, range_fill, range_set_values}; +pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula}; +#[cfg(test)] +pub(crate) use clipboard::parse_tsv; +use clipboard::{clear_clipboard, copy, cut, paste}; +use fill::{auto_fill, fill_dir, fill_series, flash_fill}; +#[cfg(test)] +use find::replace_ci; +pub(crate) use find::wildcard; +use find::{find, replace}; +pub(super) use go_to::go_to; +pub use go_to::go_to_names; +use go_to::go_to_special; +#[cfg(test)] +use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; +use paste_special::paste_special; +pub(crate) use paste_special::rewrite_all_formulas; +use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; + pub fn specs() -> Vec { vec![ cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), @@ -116,657 +143,6 @@ pub fn specs() -> Vec { ] } -// ---------------------------------------------------------------- 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> { - 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 { - // 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 { - 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); - } - } - super::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(), - } -} - -fn range_set_values(s: &mut Session, p: &Json) -> Result { - 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); - } - } - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - count += 1; - } - } - Ok(()) - })?; - Ok(json!({"cells": count})) -} - -fn range_fill(s: &mut Session, p: &Json) -> Result { - 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> = 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); - super::data::grow_autofilter(sh, at); - 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 { - 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(); - } -} - -fn selection_move(s: &mut Session, p: &Json) -> Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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()})) -} - -/// How many references the Go To dialog keeps for going back to. -const GO_TO_RECENT: usize = 10; -/// The most areas one Go To selects. -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(super) fn go_to(s: &mut Session, p: &Json) -> Result { - 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 = None; - let mut areas: Vec = 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::>().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::>().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. -fn reference_parts(text: &str) -> impl Iterator { - 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, 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)> { - let from = n.scope.unwrap_or(sheet); - let formula = n.formula.trim().trim_start_matches('='); - let mut target: Option = 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 { - let own: std::collections::HashSet = - wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); - let mut out: Vec = 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 -} - -fn go_to_special(s: &mut Session, p: &Json) -> Result { - 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 = 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 { let mut sorted = cells.to_vec(); @@ -832,619 +208,6 @@ fn redo(s: &mut Session, p: &Json) -> Result { Ok(json!({"redone": label})) } -// ---------------------------------------------------------------- clipboard - -fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { - 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 = 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 { - copy_impl(s, p, false) -} -fn cut(s: &mut Session, p: &Json) -> Result { - copy_impl(s, p, true) -} -fn clear_clipboard(s: &mut Session, _: &Json) -> Result { - 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 { - // 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 { - 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> = 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> { - 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 { - 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> { - 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 = 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)> = - 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 { @@ -1538,283 +301,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) } -// ---------------------------------------------------------------- fill - -fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { - 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 { - 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) - }) -} - -fn fill_series(s: &mut Session, p: &Json) -> Result { - 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 { - 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 = 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 { - 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 = 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 = - 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 { - 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 = 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 { diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs new file mode 100644 index 000000000..62a6b38bd --- /dev/null +++ b/crates/engine/src/cmd/edit/cell.rs @@ -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> { + 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 { + // 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 { + 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 { + 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 { + 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> = 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) + }) +} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 000000000..60c05c687 --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -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 { + 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 = 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 { + copy_impl(s, p, false) +} + +pub(super) fn cut(s: &mut Session, p: &Json) -> Result { + copy_impl(s, p, true) +} + +pub(super) fn clear_clipboard(s: &mut Session, _: &Json) -> Result { + 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 { + // 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 { + 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> = 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> { + 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 +} diff --git a/crates/engine/src/cmd/edit/fill.rs b/crates/engine/src/cmd/edit/fill.rs new file mode 100644 index 000000000..84f4cd6d3 --- /dev/null +++ b/crates/engine/src/cmd/edit/fill.rs @@ -0,0 +1,96 @@ +use super::*; + +// ---------------------------------------------------------------- fill + +pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { + 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 { + 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 { + 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 { + 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})) +} diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs new file mode 100644 index 000000000..2dcbfa886 --- /dev/null +++ b/crates/engine/src/cmd/edit/find.rs @@ -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 = 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 { + 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 = 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 = + 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 { + 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 = 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 +} diff --git a/crates/engine/src/cmd/edit/go_to.rs b/crates/engine/src/cmd/edit/go_to.rs new file mode 100644 index 000000000..b94bcd221 --- /dev/null +++ b/crates/engine/src/cmd/edit/go_to.rs @@ -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 { + 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 = None; + let mut areas: Vec = 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::>().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::>().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 { + 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, 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)> { + let from = n.scope.unwrap_or(sheet); + let formula = n.formula.trim().trim_start_matches('='); + let mut target: Option = 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 { + let own: std::collections::HashSet = + wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); + let mut out: Vec = 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 { + 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 = 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()})) +} diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs new file mode 100644 index 000000000..eba1b429e --- /dev/null +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -0,0 +1,351 @@ +use super::clipboard::What; +use super::*; + +pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { + 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> { + 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 = 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)> = + 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); + } + } +} diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs new file mode 100644 index 000000000..95793f338 --- /dev/null +++ b/crates/engine/src/cmd/edit/selection.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()})) +}