212 lines
10 KiB
Rust
212 lines
10 KiB
Rust
//! File menu: new, open, save, close, export, properties, options.
|
||
|
||
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}, 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.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),
|
||
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!(
|
||
query "file.options",
|
||
"Options",
|
||
["File"],
|
||
None,
|
||
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||
always,
|
||
options
|
||
),
|
||
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 encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
|
||
let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
|
||
let imported =
|
||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||
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. Neither does a
|
||
// workbook decrypted with its password, so Save never replaces the encrypted original with
|
||
// an unprotected copy.
|
||
let path = str_param(p, "path").filter(|_| !imported && !encrypted).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 mut doc = DocState::new(wb, path, title);
|
||
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
|
||
doc.content_kind = Some(crate::io::FileKind::Xlsx);
|
||
}
|
||
let i = s.add_document(doc);
|
||
// The decrypted-workbook notice is for the info bar, not a message box.
|
||
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
|
||
if !shown.is_empty() {
|
||
let mut message = shown.iter().take(5).copied().collect::<Vec<_>>().join("\n\n");
|
||
if shown.len() > 5 {
|
||
message.push_str(&format!("\n\n{} additional import warnings.", shown.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();
|
||
}
|
||
};
|
||
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
|
||
// document keeps its file and its unsaved changes.
|
||
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||
return super::print::export_pdf(s, &json!({"path": path}));
|
||
}
|
||
let d = s.doc_mut()?;
|
||
// The document's own file keeps the format its content had when opened; any other path
|
||
// goes by its extension.
|
||
let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
|
||
let bytes = match kind {
|
||
Some(kind) => crate::io::save_bytes_as(&d.wb, &path, kind)?,
|
||
None => crate::io::save_bytes(&d.wb, &path)?,
|
||
};
|
||
crate::io::write_file(&path, &bytes)?;
|
||
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
|
||
if matches!(kind, Some(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())
|
||
})
|
||
}
|
||
|
||
/// File › Options: reports the workbook-level settings and asks the interface to show its Options
|
||
/// page. Nothing changes here; every setting has its own command.
|
||
fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||
s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({})));
|
||
let calculation = s.active().map(|d| {
|
||
let c = &d.wb.calc;
|
||
let mode = match c.mode {
|
||
gridcraft_model::CalcMode::Automatic => "automatic",
|
||
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
|
||
gridcraft_model::CalcMode::Manual => "manual",
|
||
};
|
||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
|
||
});
|
||
Ok(json!({"calculation": calculation}))
|
||
}
|
||
|
||
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()
|
||
}
|