mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 08:51:30 -06:00
Main gained #470 (floating objects) and #501 (Solver Sensitivity report). Conflicts: - cmd/edit.rs, edit/paste_special.rs, sheet/tabs.rs: #470 adds rewrite_cell_links for chart references beside this branch's rewrite_python_refs; every structural edit, sheet rename and sheet delete runs both, the Python one last where it can refuse the edit (the edit then changes nothing). - cmd/sheet.rs: rewrite() keeps this branch's Result with main's doc comment. - model/src/sheet.rs and engine tests.rs: both sides' additions kept. - ROADMAP.md: header combined, both sides' rows kept. Main's new code writes no cells, so no Python handling was needed beyond these.
500 lines
26 KiB
Rust
500 lines
26 KiB
Rust
use super::clipboard::What;
|
|
use super::{
|
|
Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param,
|
|
target_sheet,
|
|
};
|
|
|
|
fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator<Item = (u32, u32, u32, u32)> + Clone {
|
|
(0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c)))))
|
|
}
|
|
|
|
fn apply_operation(op: &str, old: &Cell, new: &mut Cell) {
|
|
let operand = new.value.clone();
|
|
if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number())
|
|
&& (old.value.is_number() || old.value.is_empty() || old.value.is_text())
|
|
&& (operand.is_number() || operand.is_empty())
|
|
{
|
|
new.value = match op {
|
|
"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),
|
|
"divide" => Value::number(x / y),
|
|
_ => operand.clone(),
|
|
};
|
|
} else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) {
|
|
new.value = old.value.clone();
|
|
}
|
|
if let Some(formula) = &old.formula
|
|
&& let Ok(y) = operand.to_number()
|
|
&& (operand.is_number() || operand.is_empty())
|
|
&& let Some(symbol) = operation_symbol(op)
|
|
{
|
|
let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y));
|
|
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
|
|
new.value = Value::Empty;
|
|
}
|
|
}
|
|
|
|
fn operation_symbol(op: &str) -> Option<char> {
|
|
match op {
|
|
"add" => Some('+'),
|
|
"subtract" => Some('-'),
|
|
"multiply" => Some('*'),
|
|
"divide" => Some('/'),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn copy_merges(
|
|
cx: &mut crate::cmd::Ctx,
|
|
dest_sheet: usize,
|
|
src_sheet: &gridcraft_model::Sheet,
|
|
src: RangeRef,
|
|
at: CellRef,
|
|
what: What,
|
|
transpose: bool,
|
|
) -> Result<()> {
|
|
if !matches!(what, What::All | What::Formats) || transpose {
|
|
return Ok(());
|
|
}
|
|
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect();
|
|
for merge in merges {
|
|
let row_offset = at.row as i64 - src.start.row as i64;
|
|
let col_offset = at.col as i64 - src.start.col as i64;
|
|
if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) {
|
|
let moved = RangeRef::new(start, end);
|
|
let sheet = cx.sheet_mut(dest_sheet)?;
|
|
sheet.merges.retain(|existing| !existing.intersects(&moved));
|
|
sheet.merges.push(moved);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
|
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
|
|
let what = match str_param(p, "what").unwrap_or("all") {
|
|
"all" => What::All,
|
|
"formulas" => What::Formulas,
|
|
"values" => What::Values,
|
|
"formats" => What::Formats,
|
|
"comments" | "notes" => What::Comments,
|
|
"validation" => What::Validation,
|
|
"allExceptBorders" => What::AllExceptBorders,
|
|
"columnWidths" => What::ColumnWidths,
|
|
"formulasAndNumberFormats" => What::FormulasAndNumberFormats,
|
|
"valuesAndNumberFormats" => What::ValuesAndNumberFormats,
|
|
other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))),
|
|
};
|
|
let op = str_param(p, "operation").unwrap_or("none").to_string();
|
|
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
|
|
let transpose = bool_param(p, "transpose").unwrap_or(false);
|
|
let link = bool_param(p, "link").unwrap_or(false);
|
|
if link && !clip.areas.is_empty() {
|
|
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
|
|
}
|
|
let dest_sheet = target_sheet(s, p)?;
|
|
let sel = s.doc()?.selection.current();
|
|
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
|
|
let src = clip.range;
|
|
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
|
|
// Tile the copy over a larger destination that is a multiple of its size.
|
|
let tile = sel.height() % h == 0
|
|
&& sel.width() % w == 0
|
|
&& !sel.is_single()
|
|
&& cell_param_on(s, p, "at", dest_sheet)?.is_none()
|
|
&& !sel.is_full_cols()
|
|
&& !sel.is_full_rows()
|
|
&& sel.count() <= 4_000_000;
|
|
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
|
|
let same_doc = s.doc()?.uid == clip.doc_uid;
|
|
let cut_move = clip.cut && same_doc && what == What::All;
|
|
let src_wb = clip.wb.clone();
|
|
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
|
|
let src_sheet_name = src_sheet.name.clone();
|
|
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
|
|
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
|
|
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
|
|
}
|
|
edit(s, |cx| {
|
|
// Styles from another workbook must be re-interned.
|
|
let mut style_map = std::collections::HashMap::new();
|
|
let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId {
|
|
if same_doc {
|
|
return id;
|
|
}
|
|
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
|
|
};
|
|
let mut moved_formulas = Vec::new();
|
|
let mut moved_pictures = std::collections::BTreeMap::new();
|
|
if cut_move {
|
|
// Remove the source first (contents and formats), and fix references to it.
|
|
let source = cx.sheet_mut(clip.sheet)?;
|
|
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
|
|
let taken = source.take_cells(src);
|
|
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
|
|
// with the block: the destination is written from the clipboard snapshot taken
|
|
// before the cut, so removing them here loses nothing and leaves the old place
|
|
// clean instead of leaving one merged block and one note behind.
|
|
source.comments.retain(|c, _| !src.contains(*c));
|
|
source.hyperlinks.retain(|c, _| !src.contains(*c));
|
|
source.merges.retain(|m| !src.contains_range(m));
|
|
for (c, _) in &taken {
|
|
cx.touch(clip.sheet, *c);
|
|
}
|
|
moved_formulas = taken;
|
|
}
|
|
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
|
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
|
|
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() && x.py.is_none()) {
|
|
continue;
|
|
}
|
|
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
|
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
|
|
let mut new_picture = old_picture.clone();
|
|
let mut new = old.clone();
|
|
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
|
|
let f = cell.formula.as_ref()?;
|
|
let e = f.expr()?;
|
|
if cut_move {
|
|
// A moved formula still reads cells on its original sheet. Qualify
|
|
// those references before the workbook-wide move adjustment below.
|
|
let e = if dest_sheet != clip.sheet {
|
|
e.map(&mut |x| match x {
|
|
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
|
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
|
gridcraft_formula::Expr::Ref(r)
|
|
}
|
|
other => other,
|
|
})
|
|
} else {
|
|
e
|
|
};
|
|
let mut moved = Formula::from_expr(e);
|
|
// The array range moves with its formula.
|
|
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
|
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
|
moved.dynamic = f.dynamic;
|
|
return Some(std::sync::Arc::new(moved));
|
|
}
|
|
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
|
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
|
};
|
|
// A Python cell's code travels with it. Cut to another sheet, it keeps reading the
|
|
// cells its `xl()` references named on its original sheet, as a moved formula does:
|
|
// they are qualified with that sheet before the workbook-wide move adjustment below.
|
|
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
|
|
let Some(py) = cell.py.clone() else { return Ok(None) };
|
|
if !cut_move || dest_sheet == clip.sheet {
|
|
return Ok(Some(py));
|
|
}
|
|
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
|
let e = gridcraft_formula::parse(reference).ok()?.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,
|
|
});
|
|
Some(gridcraft_formula::print(&e))
|
|
});
|
|
Ok(Some(match qualified {
|
|
Some(code) => {
|
|
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
|
|
check_python_fits(&moved, &dest_name, dest)?;
|
|
std::sync::Arc::new(moved)
|
|
}
|
|
None => py,
|
|
}))
|
|
};
|
|
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.py = None;
|
|
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() };
|
|
new.py = moved_py(&sc_cell)?;
|
|
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() };
|
|
new.py = moved_py(&sc_cell)?;
|
|
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.py = 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()
|
|
&& new.py.is_none()
|
|
{
|
|
if old_picture.is_some() || new_picture.is_some() {
|
|
new_picture = None;
|
|
new.value = Value::Error(gridcraft_core::CellError::Value);
|
|
}
|
|
// Numbers stored as text participate; other text and errors stay as they are.
|
|
apply_operation(&op, &old, &mut new);
|
|
}
|
|
}
|
|
// 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.
|
|
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
|
|
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) -> Result<()> {
|
|
use gridcraft_formula::adjust::Edit;
|
|
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
|
|
// (a cut moves its arrays itself).
|
|
let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. });
|
|
for si in 0..wb.sheets.len() {
|
|
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
|
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
|
|
wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
|
if keys.is_empty() {
|
|
continue;
|
|
}
|
|
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
|
|
let Some(sheet) = wb.sheet_mut(si) else { continue };
|
|
for (c, f) in keys {
|
|
let Some(expr) = f.expr() else { continue };
|
|
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
|
|
let text = gridcraft_formula::print(&new);
|
|
let array = match f.array {
|
|
Some(a) if on_target => crate::cmd::sheet::map_range(a, e),
|
|
a => a,
|
|
};
|
|
if (text != f.text || array != f.array)
|
|
&& let Some(cell) = sheet.cells.get_mut(c)
|
|
{
|
|
let mut nf = Formula::from_expr(new);
|
|
nf.array = array;
|
|
nf.dynamic = f.dynamic;
|
|
cell.formula = Some(std::sync::Arc::new(nf));
|
|
}
|
|
}
|
|
}
|
|
for n in wb.names.iter_mut() {
|
|
if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) {
|
|
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
|
|
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
|
|
n.formula = gridcraft_formula::print(&new);
|
|
}
|
|
}
|
|
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
|
|
rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e))
|
|
}
|
|
|
|
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
|
|
/// name and bubble sizes, and the source block it was made from). Every edit that moves
|
|
/// references calls it: inserting and deleting lines and cells and cut-moves (through
|
|
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
|
|
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
|
|
/// block that no longer exists is dropped.
|
|
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
|
|
for si in 0..wb.sheets.len() {
|
|
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
|
|
let Some(sh) = wb.sheet_mut(si) else { continue };
|
|
let fix = |text: &str| -> String {
|
|
match gridcraft_formula::parse(text) {
|
|
Ok(expr) => gridcraft_formula::print(&f(&host, expr)),
|
|
Err(_) => text.to_string(),
|
|
}
|
|
};
|
|
for ch in sh.charts.iter_mut() {
|
|
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
|
for se in ch.series.iter_mut() {
|
|
se.values = fix(&se.values);
|
|
se.categories = se.categories.as_deref().map(fix);
|
|
se.name = se.name.as_deref().map(fix);
|
|
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on
|
|
/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells,
|
|
/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced
|
|
/// when the reference it names changed, and text that doesn't parse is kept. A cell's cached
|
|
/// result is kept while its references still name the same data (a rename, a move); a reference
|
|
/// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted
|
|
/// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the
|
|
/// recalculation removes its spill. A rewrite that would
|
|
/// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses
|
|
/// the whole edit, which then changes nothing: see [`check_python_fits`].
|
|
pub(crate) fn rewrite_python_refs(
|
|
wb: &mut gridcraft_model::Workbook,
|
|
f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr,
|
|
) -> Result<()> {
|
|
for si in 0..wb.sheets.len() {
|
|
let Some(sh) = wb.sheets.get(si) else { continue };
|
|
let host = sh.name.clone();
|
|
let mut new_code: Vec<(CellRef, String, bool)> = Vec::new();
|
|
for (c, cell) in sh.cells.iter() {
|
|
let Some(py) = &cell.py else { continue };
|
|
let mut lost = false;
|
|
let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
|
let expr = gridcraft_formula::parse(reference).ok()?;
|
|
let before = gridcraft_formula::print(&expr);
|
|
let after = gridcraft_formula::print(&f(&host, expr));
|
|
lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after);
|
|
(after != before).then_some(after)
|
|
});
|
|
if let Some(code) = changed {
|
|
check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?;
|
|
new_code.push((c, code, lost));
|
|
}
|
|
}
|
|
if new_code.is_empty() {
|
|
continue;
|
|
}
|
|
let Some(sh) = wb.sheet_mut(si) else { continue };
|
|
for (c, code, lost) in new_code {
|
|
if let Some(cell) = sh.cells.get_mut(c)
|
|
&& let Some(py) = cell.py.as_mut()
|
|
{
|
|
let py = std::sync::Arc::make_mut(py);
|
|
py.code = code;
|
|
if lost {
|
|
py.output = None;
|
|
cell.value = Value::Error(gridcraft_core::CellError::Blocked);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None`
|
|
/// for any other form (a name, an error).
|
|
fn area_shape(reference: &str) -> Option<(u32, u32)> {
|
|
let r = RangeRef::parse(reference.rsplit('!').next()?)?;
|
|
Some((r.height(), r.width()))
|
|
}
|
|
|
|
/// Refuses Python code an edit has made longer than one cell holds: the limit `python.set`
|
|
/// enforces and opening a file applies (code over it is dropped to its value on open). The edit
|
|
/// is refused rather than made, so no code is lost and none keeps a reference the edit moved.
|
|
pub(crate) fn check_python_fits(py: &gridcraft_model::PyCode, sheet: &str, at: CellRef) -> Result<()> {
|
|
if py.fits() {
|
|
return Ok(());
|
|
}
|
|
Err(EngineError::Other(format!(
|
|
"The edit would make the Python code in {}!{} longer than Excel's {}-character formula limit, so nothing was changed. Shorten the code first.",
|
|
gridcraft_formula::quote_sheet(sheet),
|
|
at.a1(),
|
|
gridcraft_model::python::MAX_FORMULA_CHARS
|
|
)))
|
|
}
|