Merge pull request #1867 from HyakoV3:fix/1808-csv-as-text

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
Brandon Thomas
2026-10-19 12:22:05 -04:00
4 changed files with 42 additions and 7 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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))
+12 -5
View File
@@ -55,8 +55,15 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
/// [`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<String>)> {
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<String>)> {
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<String>)> {
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
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<String>)> {
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<String>)> {
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![]))
+26
View File
@@ -3318,3 +3318,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());
}