This commit is contained in:
Brandon Thomas
2026-10-30 15:42:10 -04:00
5 changed files with 103 additions and 6 deletions
+6 -2
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
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),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
wb.set_active_sheet(target_sheet(s, p)?);
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
crate::io::csv_values_bytes(&wb)?
} else {
crate::io::save_bytes(&wb, &target)?
};
crate::io::write_file(&target, &bytes)?;
Ok(json!({"path": target, "bytes": bytes.len()}))
}
+33
View File
@@ -306,6 +306,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
.map_err(|e| EngineError::Other(e.to_string()))
}
/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and
/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV
/// writes displayed text instead ([`save_bytes`]) and stays the default.
pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
gridcraft_core::Value::Number(n) if n.is_finite() => {
let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {
Some(((date, time), d)) => iso(&d, date, time),
None => n.to_string(),
}
}
gridcraft_core::Value::Text(t) => t.to_string(),
v => crate::display::value_text_as(wb, &v, "General"),
})
.map_err(|e| EngineError::Other(e.to_string()))
}
/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.
fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String {
let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);
let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);
if d.milli != 0 {
clock.push_str(&format!(".{:03}", d.milli));
}
match (date, time) {
(true, true) => format!("{day}T{clock}"),
(false, true) => clock,
_ => day,
}
}
pub(crate) fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
+47
View File
@@ -2500,6 +2500,53 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
std::fs::remove_dir_all(dir).unwrap();
}
/// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the
/// raw values for automation: full precision, ISO dates and times.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() {
let mut s = s();
for (cell, input, code) in [
("A1", "1234.5", "$#,##0.00"),
("B1", "0.123456789", "0.00"),
("C1", "46023", "d-mmm-yy"),
("D1", "46023.5", "yyyy-mm-dd hh:mm"),
("E1", "0.75", "h:mm"),
("F1", "TRUE", "General"),
("G1", "a,b", "General"),
("H1", "=1/0", "General"),
("I1", "1", "h:mm"),
("J1", "1.5", "h:mm"),
("K1", "46023", "yyyy-mm-dd hh:mm"),
("L1", "1.5", "[h]:mm"),
("M1", "2.5", "[>=3]yyyy-mm-dd;[<3]hh:mm"),
] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap();
}
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-csv-values-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let displayed = dir.join("displayed.csv");
s.execute("file.exportCsv", json!({"path": displayed})).unwrap();
s.execute("file.exportCsv", json!({"path": dir.join("off.csv"), "values": false})).unwrap();
let raw = dir.join("raw.csv");
s.execute("file.exportCsv", json!({"path": raw, "values": true})).unwrap();
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
let default = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();
// The default export is byte for byte what Save As CSV writes, and `values: false` is the default.
assert_eq!(std::fs::read(&displayed).unwrap(), default);
assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default);
let default = String::from_utf8(default).unwrap();
assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}");
assert_eq!(
std::fs::read_to_string(&raw).unwrap(),
"1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5,12:00:00\r\n"
);
std::fs::remove_dir_all(dir).unwrap();
}
/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target).
fn linked_workbook(link: &str) -> Vec<u8> {
use std::io::{Cursor, Write};