mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 14:17:12 -06:00
Merge PR #2666
This commit is contained in:
@@ -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()}))
|
||||
}
|
||||
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user