#16: import ODS worksheet data without overwriting source files

This commit is contained in:
harrisonoconnorhover
2026-10-09 22:09:09 -04:00
parent 13664fe66b
commit da0bf32d8a
17 changed files with 1018 additions and 12 deletions
+17 -3
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} (xlsx, xlsm, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, 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 by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
@@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
};
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string();
let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true);
let imported =
crate::io::FileKind::from_path(&name) == Some(crate::io::FileKind::Ods) || gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Ods;
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else {
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
};
// An import has no save target: ordinary Save must ask for a new destination.
let path = str_param(p, "path").filter(|_| !imported).map(str::to_string);
// Replace an untouched blank Book1 like Excel does.
if s.documents().len() == 1
&& s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty()))
@@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
s.close_document(0);
}
let i = s.add_document(DocState::new(wb, path, title));
if !warnings.is_empty() {
let mut message = warnings.iter().take(5).cloned().collect::<Vec<_>>().join("\n\n");
if warnings.len() > 5 {
message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5));
}
s.ui_requests.push(crate::UiRequest::Message(message));
}
let d = s.doc()?;
Ok(
json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), "warnings": warnings}),
+8 -1
View File
@@ -1,4 +1,4 @@
//! Opening and saving files: XLSX (the native format), CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
@@ -11,6 +11,7 @@ use crate::{EngineError, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
Xlsx,
Ods,
Csv,
Tsv,
Json,
@@ -22,6 +23,7 @@ impl FileKind {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
"ods" => FileKind::Ods,
"csv" => FileKind::Csv,
"tsv" | "tab" | "txt" => FileKind::Tsv,
"json" | "scjson" => FileKind::Json,
@@ -36,6 +38,10 @@ impl FileKind {
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Ods || kind == Some(FileKind::Ods) {
let (wb, report) = gridcraft_xlsx::read_ods(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
@@ -81,6 +87,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
+70
View File
@@ -145,6 +145,76 @@ fn xlsx_roundtrip_through_engine() {
assert_eq!(v(&s, "B3"), Value::Number(3.0));
}
fn ods_fixture() -> Vec<u8> {
use std::io::{Cursor, Write};
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [
("mimetype", "application/vnd.oasis.opendocument.spreadsheet"),
(
"META-INF/manifest.xml",
r#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3"><manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>"#,
),
(
"content.xml",
r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" office:version="1.3"><office:body><office:spreadsheet><table:table table:name="Data"><table:table-row><table:table-cell office:value-type="float" office:value="42" table:formula="of:=SUM([.B1:.B2])"/><table:table-cell office:value-type="string"><text:p>Original data</text:p></table:table-cell></table:table-row></table:table></office:spreadsheet></office:body></office:document-content>"#,
),
] {
zip.start_file(name, options).unwrap();
zip.write_all(data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn ods_import_keeps_cached_values_and_reports_limits() {
let bytes = ods_fixture();
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods);
for name in ["source.ods", "source.xlsx"] {
let mut s = s();
let r = s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(&bytes)})).unwrap();
assert!(!r["warnings"].as_array().unwrap().is_empty());
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_))));
assert!(s.doc().unwrap().path.is_none());
assert!(s.doc().unwrap().display_title().ends_with(".xlsx"));
assert_eq!(v(&s, "A1"), Value::Number(42.0));
assert_eq!(v(&s, "B1"), Value::from("Original data"));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().formula.is_none());
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
s.execute("file.open", json!({"name": "imported.xlsx", "base64": saved["base64"]})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(42.0));
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn ods_import_never_reuses_source_as_save_target() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-ods-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let bytes = ods_fixture();
for name in ["source.ods", "disguised.xlsx"] {
let path = dir.join(name);
std::fs::write(&path, &bytes).unwrap();
let mut s = s();
s.execute("file.open", json!({"path": path})).unwrap();
s.take_ui_requests();
s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap();
s.execute("file.save", json!({})).unwrap();
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs")));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert!(s.execute("file.saveAs", json!({"path": dir.join("source.ods")})).is_err());
assert!(s.execute("file.saveBytes", json!({"format": "ods"})).is_err());
let output = dir.join("converted.xlsx");
s.execute("file.saveAs", json!({"path": output})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
assert_eq!(std::fs::read(&path).unwrap(), bytes);
s.execute("file.open", json!({"name": "converted.xlsx", "base64": crate::io::base64_encode(&std::fs::read(output).unwrap())})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(43.0));
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn samples_build() {
for (name, _) in crate::sample::SAMPLES {