mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
Blank cell and row repeats that run past 16384 columns / 1048576 rows (padding from writers with larger sheets) are clipped instead of rejecting the file; any data or merge past the edge is still an error. convert() builds its Cell with ..Cell::value(value) so new Cell fields take their defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
7.6 KiB
Rust
167 lines
7.6 KiB
Rust
//! File menu: new, open, save, close, export, properties.
|
|
|
|
use serde_json::{Value as Json, json};
|
|
|
|
use super::*;
|
|
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, 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 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),
|
|
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", 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.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!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
|
|
]
|
|
}
|
|
|
|
fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
|
|
let i = match str_param(p, "sample") {
|
|
Some(name) => {
|
|
let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
|
|
let title = crate::sample::title(name);
|
|
s.add_document(DocState::new(wb, None, title))
|
|
}
|
|
None => s.new_workbook(),
|
|
};
|
|
Ok(json!({"index": i, "title": s.doc()?.display_title()}))
|
|
}
|
|
|
|
fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
|
let (name, bytes) = if let Some(b) = str_param(p, "base64") {
|
|
(str_param(p, "name").unwrap_or("Book.xlsx").to_string(), crate::io::base64_decode(b).ok_or_else(|| bad("file.open", "invalid base64"))?)
|
|
} else if let Some(path) = str_param(p, "path") {
|
|
// Already open? Switch to it.
|
|
if let Some(i) = s.documents().iter().position(|d| d.path.as_deref() == Some(path)) {
|
|
s.set_active(i);
|
|
return Ok(json!({"index": i, "alreadyOpen": true}));
|
|
}
|
|
(path.to_string(), crate::io::read_file(path)?)
|
|
} else {
|
|
s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({})));
|
|
return ok();
|
|
};
|
|
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
|
|
let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb))
|
|
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb);
|
|
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()))
|
|
{
|
|
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}),
|
|
)
|
|
}
|
|
|
|
fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
|
let path = match str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone())) {
|
|
Some(p) => p,
|
|
None => {
|
|
s.ui_requests.push(crate::UiRequest::Dialog("saveAs".into(), json!({})));
|
|
return ok();
|
|
}
|
|
};
|
|
let d = s.doc_mut()?;
|
|
let bytes = crate::io::save_bytes(&d.wb, &path)?;
|
|
crate::io::write_file(&path, &bytes)?;
|
|
let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx);
|
|
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
|
|
if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) {
|
|
d.saved = d.wb.clone();
|
|
d.path = Some(path.clone());
|
|
}
|
|
Ok(json!({"path": path, "bytes": bytes.len()}))
|
|
}
|
|
|
|
fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
|
|
if str_param(p, "path").is_none() {
|
|
s.ui_requests.push(crate::UiRequest::Dialog("saveAs".into(), json!({})));
|
|
return ok();
|
|
}
|
|
save(s, p)
|
|
}
|
|
|
|
fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
|
|
let fmt = str_param(p, "format").unwrap_or("xlsx");
|
|
let d = s.doc()?;
|
|
let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?;
|
|
let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
|
|
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
|
|
}
|
|
|
|
fn close(s: &mut Session, p: &Json) -> Result<Json> {
|
|
let force = bool_param(p, "force").unwrap_or(false);
|
|
let d = s.doc()?;
|
|
if d.is_dirty() && !force {
|
|
s.ui_requests.push(crate::UiRequest::Dialog("saveChanges".into(), json!({"title": d.display_title()})));
|
|
return Ok(json!({"closed": false, "dirty": true}));
|
|
}
|
|
let i = s.active_index();
|
|
s.close_document(i);
|
|
Ok(json!({"closed": true}))
|
|
}
|
|
|
|
fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
|
|
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
|
|
let d = s.doc()?;
|
|
let mut wb = (*d.wb).clone();
|
|
if p.get("sheet").is_some() {
|
|
wb.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)?;
|
|
crate::io::write_file(&target, &bytes)?;
|
|
Ok(json!({"path": target, "bytes": bytes.len()}))
|
|
}
|
|
|
|
fn properties(s: &mut Session, p: &Json) -> Result<Json> {
|
|
edit(s, |cx| {
|
|
let pr = &mut cx.wb.props;
|
|
for (k, slot) in [
|
|
("title", &mut pr.title),
|
|
("subject", &mut pr.subject),
|
|
("author", &mut pr.author),
|
|
("company", &mut pr.company),
|
|
("keywords", &mut pr.keywords),
|
|
("description", &mut pr.description),
|
|
] {
|
|
if let Some(v) = str_param(p, k) {
|
|
*slot = v.to_string();
|
|
}
|
|
}
|
|
Ok(serde_json::to_value(&cx.wb.props).unwrap_or_default())
|
|
})
|
|
}
|
|
|
|
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
|
|
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
|
|
if i >= s.documents().len() {
|
|
return Err(bad("window.activate", "no such window"));
|
|
}
|
|
s.set_active(i);
|
|
ok()
|
|
}
|