mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 16:30:50 -06:00
Merge current main into PR2597 for hosted validation
This commit is contained in:
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Values",
|
||||
[],
|
||||
None,
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}",
|
||||
has_doc,
|
||||
range_set_values
|
||||
),
|
||||
|
||||
@@ -183,6 +183,22 @@ pub(crate) fn json_to_input(v: &Json) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many converted strings `range.setValues` reports back, and how much of each input and
|
||||
/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data
|
||||
/// in the result.
|
||||
const MAX_REPORTED_CONVERSIONS: usize = 20;
|
||||
const MAX_REPORTED_CHARS: usize = 32;
|
||||
|
||||
/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was
|
||||
/// cut. Counting characters keeps a multi-byte input whole.
|
||||
fn clipped(text: &str) -> String {
|
||||
let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect();
|
||||
if out.len() < text.len() {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let start = target_range(s, p)?.start;
|
||||
@@ -200,6 +216,10 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad("range.setValues", "too many rows"));
|
||||
}
|
||||
let mut count = 0;
|
||||
// The strings typed input read as something other than the text given, for the result: a
|
||||
// caller can notice a "05.04.2026" that became a date without reading the file back (#2255).
|
||||
let mut converted: Vec<(CellRef, String)> = Vec::new();
|
||||
let mut converted_count = 0;
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
let cols: Vec<&Json> = match row.as_array() {
|
||||
@@ -222,15 +242,35 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// 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);
|
||||
if text.is_empty() {
|
||||
// An empty string clears the cell, as `null` and the default path do:
|
||||
// an empty text cell is not blank, and a script clearing cells with
|
||||
// `""` would leave cells `COUNTA` counts. The cell keeps its formatting.
|
||||
input_to_cell("", sheet, at, &mut cx.wb)?
|
||||
} else {
|
||||
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 })
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
};
|
||||
// A string that became a formula was written as one (`=`), which is deliberate;
|
||||
// only a number (dates and times included), boolean or error is a reading of the
|
||||
// string and worth reporting.
|
||||
if let Some(c) = &cell
|
||||
&& matches!(v, Json::String(_))
|
||||
&& c.formula.is_none()
|
||||
&& matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_))
|
||||
{
|
||||
converted_count += 1;
|
||||
if converted.len() < MAX_REPORTED_CONVERSIONS {
|
||||
converted.push((at, input.clone()));
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(c) => sh.set_cell(at, c),
|
||||
@@ -245,7 +285,17 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"cells": count}))
|
||||
let d = s.doc()?;
|
||||
let reported: Vec<Json> =
|
||||
d.wb.sheet(sheet)
|
||||
.map(|sh| {
|
||||
converted
|
||||
.iter()
|
||||
.map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))}))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported}))
|
||||
}
|
||||
|
||||
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -192,6 +192,74 @@ fn set_values_can_store_strings_as_the_text_they_are() {
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
|
||||
}
|
||||
|
||||
/// The result names the strings typed input read as something else, so a script notices without
|
||||
/// reading the file back (#2255). A formula and a leading apostrophe are not conversions.
|
||||
#[test]
|
||||
fn set_values_reports_the_strings_typed_input_read() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]);
|
||||
let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
|
||||
assert_eq!(r["cells"], 6, "{r}");
|
||||
assert_eq!(r["convertedCount"], 4, "{r}");
|
||||
assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}");
|
||||
assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}");
|
||||
assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}");
|
||||
assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}");
|
||||
// The report is a diagnostic, not a second copy of the data: a long input is cut.
|
||||
let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap();
|
||||
assert_eq!(long["convertedCount"], 1, "{long}");
|
||||
let input = long["converted"][0]["input"].as_str().unwrap_or_default();
|
||||
assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}");
|
||||
assert!(input.ends_with('…'), "{input}");
|
||||
// Nothing is read with `parse: false`, so nothing is reported.
|
||||
let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap();
|
||||
assert_eq!(verbatim["convertedCount"], 0, "{verbatim}");
|
||||
assert_eq!(verbatim["converted"], json!([]), "{verbatim}");
|
||||
}
|
||||
|
||||
/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a
|
||||
/// script clearing cells with "" must not leave cells `COUNTA` counts.
|
||||
#[test]
|
||||
fn set_values_parse_false_clears_an_empty_string() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A1"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["type"], "empty");
|
||||
assert_eq!(get(&mut s, "B1")["type"], "empty");
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting");
|
||||
assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0));
|
||||
}
|
||||
|
||||
/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the
|
||||
/// default's reading of a date survives as a date (#2255).
|
||||
#[test]
|
||||
fn set_values_text_survives_an_xlsx_roundtrip() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap();
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] {
|
||||
assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given");
|
||||
}
|
||||
assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula");
|
||||
assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0));
|
||||
assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true));
|
||||
// The default read the date, and the file keeps it as one.
|
||||
let at = CellRef::new(2, 0);
|
||||
assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026");
|
||||
assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -153,7 +153,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
// Text named `.xlsx` is read as text.
|
||||
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
|
||||
// Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871).
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| {
|
||||
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
|
||||
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
|
||||
_ => format!("We can't open '{name}': {e}"),
|
||||
@@ -182,17 +183,17 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
|
||||
wb.csv_delimiter = None;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, vec![]))
|
||||
Ok((wb, report.warnings))
|
||||
}
|
||||
_ => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
|
||||
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, vec![]))
|
||||
Ok((wb, report.warnings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() {
|
||||
assert_eq!(wb.sheets[0].name, "Sales");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() {
|
||||
let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap();
|
||||
let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap();
|
||||
for file in ["dates.csv", "dates.txt"] {
|
||||
let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}");
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}");
|
||||
assert!(warnings.is_empty(), "{file}: {warnings:?}");
|
||||
}
|
||||
let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A save that can't be put in place (here a folder already has the workbook's name, as
|
||||
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -561,3 +575,32 @@ fn content_wins_over_a_mismatched_extension() {
|
||||
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)`
|
||||
/// wrapper as the formula inside when GridCraft calculates every function it calls (including
|
||||
/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a
|
||||
/// Google-only function.
|
||||
#[test]
|
||||
fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
let mut wb = Workbook::new();
|
||||
let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]);
|
||||
let wrapped = [
|
||||
("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)),
|
||||
("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")),
|
||||
];
|
||||
for (at, text, value) in &wrapped {
|
||||
let cell = gridcraft_model::Cell {
|
||||
value: value.clone(),
|
||||
formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))),
|
||||
..Default::default()
|
||||
};
|
||||
sh.cells.set(CellRef::parse(at).unwrap(), cell);
|
||||
}
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap();
|
||||
let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
|
||||
assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))"));
|
||||
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
|
||||
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
|
||||
}
|
||||
|
||||
@@ -558,6 +558,17 @@ impl Session {
|
||||
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
|
||||
self.active_mut().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
/// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit
|
||||
/// is allowed. Front ends ask before opening an editor, so typing into a locked cell never
|
||||
/// leaves a draft that can't be entered (and so blocks saving).
|
||||
pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option<String> {
|
||||
let d = self.active()?;
|
||||
let sh = d.wb.sheet(sheet)?;
|
||||
if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked {
|
||||
return None;
|
||||
}
|
||||
cmd::locked_cell_error(sh, at, &d.unlocked_ranges)
|
||||
}
|
||||
/// Opens a workbook in a new window, which becomes the active one.
|
||||
pub fn add_document(&mut self, d: DocState) -> usize {
|
||||
let uid = d.uid;
|
||||
|
||||
@@ -1400,6 +1400,17 @@ fn protection_refuses_edits_of_locked_cells() {
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_cell_message_matches_what_an_edit_would_be_refused_with() {
|
||||
let s = protected(json!({}));
|
||||
let locked = CellRef::parse("A1").unwrap();
|
||||
let unlocked = CellRef::parse("D1").unwrap();
|
||||
|
||||
assert!(s.locked_cell_message(0, locked).is_some_and(|m| m.contains("protected sheet")));
|
||||
assert_eq!(s.locked_cell_message(0, unlocked), None, "an unlocked cell stays editable");
|
||||
assert_eq!(s.locked_cell_message(9, locked), None, "no such sheet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_allows_permitted_row_and_column_edits() {
|
||||
let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true}));
|
||||
@@ -2403,6 +2414,36 @@ fn deleting_a_3d_span_end_sheet_keeps_the_rest() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_roundtrips_after_sheet_deletion_and_reordering() {
|
||||
let view = gridcraft_model::WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: true, show_vertical_scroll: false, tab_ratio: 350 };
|
||||
for (initial_active, command, params, expected_order, expected_active) in [
|
||||
("Third", "home.deleteSheet", json!({"sheet": "First"}), vec!["Second", "Third"], 1),
|
||||
("Third", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 1),
|
||||
("First", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 0),
|
||||
("Third", "sheet.move", json!({"sheet": "Third", "to": 0}), vec!["Third", "First", "Second"], 0),
|
||||
("Third", "sheet.move", json!({"sheet": "First", "to": 2}), vec!["Second", "Third", "First"], 2),
|
||||
] {
|
||||
let mut session = s();
|
||||
session.execute("sheet.rename", json!({"name": "First"})).unwrap();
|
||||
for name in ["Second", "Third"] {
|
||||
session.execute("home.insertSheet", json!({"name": name})).unwrap();
|
||||
}
|
||||
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).view = view;
|
||||
session.execute("sheet.activate", json!({"sheet": initial_active})).unwrap();
|
||||
session.execute(command, params).unwrap();
|
||||
|
||||
let wb = &session.doc().unwrap().wb;
|
||||
assert_eq!(wb.active_sheet(), expected_active, "{command}");
|
||||
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command}");
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
|
||||
assert_eq!(back.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command} after reopening");
|
||||
assert_eq!(back.active_sheet(), expected_active, "activeTab follows {command}");
|
||||
assert_eq!(back.active().unwrap().name, expected_order[expected_active], "the active sheet is not a stale index");
|
||||
assert_eq!(back.view, view, "workbook display settings survive {command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_roundtrip_through_engine() {
|
||||
let mut s = s();
|
||||
|
||||
Reference in New Issue
Block a user