From 8fff311210e8c152a5f2dc29e8f3b646d0241987 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:57:38 +0100 Subject: [PATCH 1/2] range.setValues: report the strings typed input read, and clear "" with parse: false (#2255) The default still reads a string with typed input, which is en-US: "05.04.2026" from a dd.mm.yyyy export became 4 May, "2.500" became 2.5, and the command said nothing (#2255). `parse: false` (PR #2267) lets a caller keep the text, but a script that does not pass it still cannot notice the change. The result now names them: `converted` lists the strings typed input read as a number (dates and times included), a boolean or an error, with the cell, the input and what the cell shows, and `convertedCount` is the total. It is capped at 20 entries and 32 characters per string, so a big write does not put a second copy of its data in the result. A string written as a formula (`=1+1`) and a leading apostrophe are not conversions. `cells` and the default reading of the values are unchanged. Two `parse: false` fixes from the review of #2267: - An empty string clears the cell, as `null` and the default path do. Storing an empty text cell left a cell that is not blank: `values: [["", ""]]` gave COUNTA 2 and ISBLANK FALSE. - MCP `write_range` takes `parse`, so an agent can opt out too; the tool's description and docs/mcp.md say so. Tests: the issue's examples under both settings, the report and its cap, the empty string, and an XLSX save/reopen that keeps the text as text and the default's date as a date. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/edit/cell.rs | 62 +++++++++++++++++++++++++--- crates/engine/src/cmd/edit/tests.rs | 64 +++++++++++++++++++++++++++++ crates/mcp/src/tests.rs | 16 ++++++++ crates/mcp/src/tools.rs | 6 ++- docs/mcp.md | 2 +- 6 files changed, 142 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27e217474..798ed8a48 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index c57afae97..65d5b73ce 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -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 { 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 { 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 { // 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. + None + } 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 { } Ok(()) })?; - Ok(json!({"cells": count})) + let d = s.doc()?; + let reported: Vec = + 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 { diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index c9e1e7a93..3701dfd19 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -192,6 +192,70 @@ 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("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"); + 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(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 61cfc8dfd..71631b5e8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -252,6 +252,22 @@ fn save_and_open_roundtrip() { let _ = std::fs::remove_file(path); } +/// #2255: `parse: false` on `write_range` stores strings as the text they are, so a dd.mm.yyyy +/// date or a thousands separator survives, and the result names the strings the default read. +#[test] +fn write_range_can_store_strings_as_text() { + let mut s = server(); + let row = json!([["05.04.2026", "2.500", "4.25 %", "=1+1"]]); + let stored = ok(&mut s, "write_range", json!({"range": "A1", "parse": false, "values": row})); + assert_eq!(stored["convertedCount"], 0, "nothing was read: {stored}"); + let v = ok(&mut s, "read_range", json!({"range": "A1:D1"})); + assert_eq!(v["values"], json!([["05.04.2026", "2.500", "4.25 %", 2.0]]), "{v}"); + // Without it the strings are read like typed input, and the result says which. + let read = ok(&mut s, "write_range", json!({"range": "A3", "values": row})); + assert_eq!(read["convertedCount"], 3, "{read}"); + assert_eq!(read["converted"][0], json!({"cell": "A3", "input": "05.04.2026", "stored": "5/4/2026"}), "{read}"); +} + /// An agent builds a small monthly budget through MCP tool calls only. #[test] fn agent_builds_a_budget() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b422b5f34..f1a36b004 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -190,11 +190,13 @@ fn base_tool_definitions() -> Vec { "Write range", "Write a 2-D array of values starting at the top-left cell of `range` (rows of cells). Numbers, booleans, null (clears) \ and strings are accepted; strings starting with = are formulas (\"=SUM(B2:B4)\"), other strings are parsed like typed \ - input (\"12%\", \"1/2/2024\", \"$5\"). One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", + input (\"12%\", \"1/2/2024\", \"$5\") unless `parse` is false, which stores them as the text they are. The result's \ + `converted` lists the strings that were read as something else. One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", obj( json!({ "range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""), "values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}}, + "parse": boolean("Store every string not starting with = as the text it is, instead of reading it like typed input (default true)"), }), &["range", "values"], ), @@ -637,7 +639,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result "list_windows" => exec(b, "window.list", json!({})), "inspect_workbook" => b.call("document.inspect", json!({})), "read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])), - "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])), + "write_range" => exec(b, "range.setValues", pick(a, &["range", "values", "parse"])), "set_cell" => exec(b, "cell.set", pick(a, &["cell", "input"])), "get_cell" => exec(b, "cell.get", pick(a, &["cell"])), "evaluate_formula" => { diff --git a/docs/mcp.md b/docs/mcp.md index 32ea6e0c1..c3ee026b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,7 +44,7 @@ with a file open. | `list_windows` | — | Open windows: window id, workbook (`doc`) id, title ("Book1", or "Book1:2" for a workbook's second window), unsaved changes, active, hidden. | | `inspect_workbook` | — | Sheets, used ranges, tables, charts, names, selection, history. | | `read_range` | `range?`, `sheet?`, `formulas?`, `formatted?` | Rows of values (default: the used range). | -| `write_range` | `range`, `values` (2-D) | `range.setValues`; strings starting with `=` are formulas. | +| `write_range` | `range`, `values` (2-D), `parse?` | `range.setValues`; strings starting with `=` are formulas, other strings are read like typed input unless `parse: false` stores them as text. `converted` in the result lists the strings read as a number, date, time, boolean or error. | | `set_cell` / `get_cell` | `cell`, `input` / `cell` | Enter one cell / read value, formula, display text, style. | | `evaluate_formula` | `formula`, `cell?` | Evaluates without changing the sheet. A formula that couldn't be entered in a cell (it doesn't parse, or a function gets the wrong number of arguments) is an error saying why. | | `select` | `range`, `active?` | Sets the selection. | From f2fab9e1619a0b7877465b15bd04f49ee6ebb552 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 20:01:47 +0100 Subject: [PATCH 2/2] contributors: add maxm11 entry (sync GitHub name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0e5b14ca4..00f6ce76a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -106,3 +106,6 @@ sync_github_name = true [people.Plutonium-fream] sync_github_name = true + +[people.maxm11] +sync_github_name = true