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) |