From 95f8206bb9e69549869a3ef35191bd2b1e14e099 Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Sun, 18 Oct 2026 12:49:32 -0300 Subject: [PATCH] #1808: file.open can keep every CSV/TSV field as text file.open takes {csvAsText: true}: a CSV, TSV or TXT file opens with every field as the text it holds, with no number, date or boolean conversion, so identifiers such as GTINs, SKUs and ZIP codes keep their digits and leading zeros and save back unchanged. The default is unchanged. The CsvOptions.parse_values switch already existed in the reader. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/file.rs | 4 ++-- crates/engine/src/io.rs | 17 ++++++++++++----- crates/engine/src/tests.rs | 26 ++++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7291ee897..9ddc05611 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook and {csvAsText?: bool} to keep every csv/tsv/txt field as text, with no number or date conversion (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -56,7 +56,7 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted; - let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?; + let (mut wb, warnings) = crate::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?; wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir); let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls)) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 3e84036cb..4a14b8790 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -55,8 +55,15 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { /// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never /// quote the password. pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec)> { + open_bytes_with(name, bytes, password, false) +} + +/// [`open_bytes_with_password`], and with `csv_as_text` a CSV/TSV/TXT file keeps every field as +/// the text it holds, with no number, date or boolean conversion (identifiers such as GTINs, SKUs +/// and ZIP codes survive). It does not apply to other formats. +pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>, csv_as_text: bool) -> Result<(Workbook, Vec)> { if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted { - let (mut wb, warnings) = open_plain(name, bytes)?; + let (mut wb, warnings) = open_plain(name, bytes, csv_as_text)?; fit_imported_rows(&mut wb); return Ok((wb, warnings)); } @@ -70,7 +77,7 @@ pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str> if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted { return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file)."))); } - let (mut wb, mut warnings) = open_plain(name, &plain)?; + let (mut wb, mut warnings) = open_plain(name, &plain, csv_as_text)?; fit_imported_rows(&mut wb); warnings.insert(0, DECRYPTED_NOTICE.into()); Ok((wb, warnings)) @@ -112,7 +119,7 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String { } /// Parses bytes that aren't encrypted. -fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { +fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); if sniffed == gridcraft_xlsx::Format::Xls { @@ -153,7 +160,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { Ok((wb, vec![])) } Some(FileKind::Tsv) => { - let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() }; + 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()))?; // Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default. wb.csv_delimiter = None; @@ -161,7 +168,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { Ok((wb, vec![])) } _ => { - let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, ..Default::default() }; + 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()))?; rename_first_sheet(&mut wb, name); Ok((wb, vec![])) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..c7af79c31 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3282,3 +3282,29 @@ fn paste_special_validation_moves_relative_references() { assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1"); assert!(ok(&mut s, "C3", "20")); } + +#[test] +fn csv_as_text_keeps_identifier_columns_as_written() { + let csv = "gtin,zip,flag,day\n8435392892278,01234,TRUE,2026-03-04\n"; + let open = |s: &mut Session, extra: serde_json::Value| { + let mut p = json!({"name": "feed.csv", "base64": crate::io::base64_encode(csv.as_bytes())}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + s.execute("file.open", p).unwrap(); + }; + let value = |s: &mut Session, cell: &str| s.execute("cell.get", json!({"cell": cell})).unwrap()["value"].clone(); + // By default fields are typed like typed input: the leading zero is lost and the GTIN is a number. + let mut s = s(); + open(&mut s, json!({})); + assert!(value(&mut s, "A2").is_number()); + assert_eq!(value(&mut s, "B2").as_f64(), Some(1234.0)); + // With csvAsText every field stays the text the file holds, and saves back unchanged. + let mut s = self::s(); + open(&mut s, json!({"csvAsText": true})); + for (cell, text) in [("A2", "8435392892278"), ("B2", "01234"), ("C2", "TRUE"), ("D2", "2026-03-04")] { + assert_eq!(value(&mut s, cell), json!(text), "{cell}"); + } + let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); + assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 158149fb3..48af53877 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |