1use serde_json::{Value as Json, json};
4
5use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param};
6use crate::engine::DocState;
7
8pub fn specs() -> Vec<CommandSpec> {
9 vec![
10 cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
11 cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook and {csvAsText?: bool} to keep every csv/tsv/txt field as text, with no number or date conversion (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
12 cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
13 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),
14 cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
15 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),
16 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")),
17 cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
18 cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
19 cmd!(
20 query "file.options",
21 "Options",
22 ["File"],
23 None,
24 "{} → {calculation: {mode, iterative, maxIterations, maxChange, precisionAsDisplayed}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
25 always,
26 options
27 ),
28 cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{window} | {doc} | {index} (a window id, a workbook id, or the workbook's position in the list)", has_doc, activate_window),
29 cmd!(noundo "window.close", "Close Window", ["Window"], None, "{window?, doc?, force?: bool} → {closed, document}: closes a window (the active one by default); its workbook closes with its last window, asking first when it has unsaved changes unless `force`", has_doc, close_window),
30 cmd!(query "window.list", "Windows", ["Window"], None, "{} → [{window, doc, index, title, dirty, active, hidden}]", always, |s, _| Ok(s.window_list())),
31 ]
32}
33
34fn new_workbook(s: &mut Session, p: &Json) -> Result<Json> {
35 let i = match str_param(p, "sample") {
36 Some(name) => {
37 let wb = crate::engine::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?;
38 let title = crate::engine::sample::title(name);
39 s.add_document(DocState::new(wb, None, title, crate::engine::Trust::Local))
40 }
41 None => s.new_workbook(),
42 };
43 Ok(json!({"index": i, "title": s.doc()?.display_title()}))
44}
45
46fn open(s: &mut Session, p: &Json) -> Result<Json> {
47 let (name, bytes) = if let Some(b) = str_param(p, "base64") {
48 (
49 str_param(p, "name").unwrap_or("Book.xlsx").to_string(),
50 crate::engine::io::base64_decode(b).ok_or_else(|| bad("file.open", "invalid base64"))?,
51 )
52 } else if let Some(path) = str_param(p, "path") {
53 if let Some(i) = s.documents().iter().position(|d| d.path.as_deref() == Some(path)) {
55 s.set_active(i);
56 return Ok(json!({"index": i, "alreadyOpen": true}));
57 }
58 (path.to_string(), crate::engine::io::read_file(path)?)
59 } else {
60 s.ui_requests.push(crate::engine::UiRequest::Dialog("open".into(), json!({})));
61 return ok();
62 };
63 let encrypted = crate::xlsx::sniff(&bytes) == crate::xlsx::Format::Encrypted;
64 let (mut wb, warnings) =
65 crate::engine::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?;
66 wb.source_dir = str_param(p, "path").and_then(crate::engine::io::source_dir);
67 wb.source_path = match str_param(p, "path") {
71 Some(path) => crate::engine::io::source_path(path),
72 None => Some(std::path::PathBuf::from(&name)),
73 };
74 let imported = matches!(
75 crate::engine::io::FileKind::from_path(&name),
76 Some(crate::engine::io::FileKind::Ods | crate::engine::io::FileKind::Xlsb | crate::engine::io::FileKind::Xls)
77 ) || matches!(crate::xlsx::sniff(&bytes), crate::xlsx::Format::Ods | crate::xlsx::Format::Xlsb | crate::xlsx::Format::Xls);
78 let title = if imported {
79 format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
80 } else {
81 std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
82 };
83 let path = str_param(p, "path").filter(|_| !imported && !encrypted).map(str::to_string);
87 if s.documents().len() == 1
89 && 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()))
90 {
91 s.close_document(0);
92 }
93 let mut doc = DocState::new(wb, path, title, crate::engine::Trust::FromFile);
95 if crate::engine::io::FileKind::from_path(&name).is_none() && crate::xlsx::sniff(&bytes) == crate::xlsx::Format::Xlsx {
96 doc.content_kind = Some(crate::engine::io::FileKind::Xlsx);
97 }
98 let i = s.add_document(doc);
99 let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::engine::io::DECRYPTED_NOTICE).collect();
101 if !shown.is_empty() {
102 let mut message = shown.iter().take(5).copied().collect::<Vec<_>>().join("\n\n");
103 if shown.len() > 5 {
104 message.push_str(&format!("\n\n{} additional import warnings.", shown.len() - 5));
105 }
106 s.ui_requests.push(crate::engine::UiRequest::Message(message));
107 }
108 let d = s.doc()?;
109 Ok(
110 json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), "warnings": warnings}),
111 )
112}
113
114fn save(s: &mut Session, p: &Json) -> Result<Json> {
115 let path = match str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone())) {
116 Some(p) => p,
117 None => {
118 s.ui_requests.push(crate::engine::UiRequest::Dialog("saveAs".into(), json!({})));
119 return ok();
120 }
121 };
122 if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
125 return super::print::export_pdf(s, &json!({"path": path}));
126 }
127 let d = s.doc_mut()?;
128 let kind = crate::engine::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
131 let rebased = crate::engine::io::source_dir(&path).and_then(|f| crate::xlsx::rebase_links(&d.wb, &f));
132 let wb = rebased.as_ref().unwrap_or(&d.wb);
133 let bytes = match kind {
134 Some(kind) => crate::engine::io::save_bytes_as(wb, &path, kind)?,
135 None => crate::engine::io::save_bytes(wb, &path)?,
136 };
137 crate::engine::io::write_file(&path, &bytes)?;
138 if matches!(kind, Some(crate::engine::io::FileKind::Xlsx | crate::engine::io::FileKind::Json)) {
140 let file = crate::engine::io::source_path(&path);
144 if d.wb.source_path != file {
145 std::sync::Arc::make_mut(&mut d.wb).source_path = file;
146 }
147 d.saved = d.wb.clone();
148 d.path = Some(path.clone());
149 }
150 Ok(json!({"path": path, "bytes": bytes.len()}))
151}
152
153fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
154 if str_param(p, "path").is_none() {
155 s.ui_requests.push(crate::engine::UiRequest::Dialog("saveAs".into(), json!({})));
156 return ok();
157 }
158 save(s, p)
159}
160
161fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
162 let fmt = str_param(p, "format").unwrap_or("xlsx");
163 let d = s.doc()?;
164 let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
165 let bytes = crate::engine::io::save_bytes(&d.wb, &name)?;
167 Ok(json!({"name": name, "base64": crate::engine::io::base64_encode(&bytes), "bytes": bytes.len()}))
168}
169
170fn close(s: &mut Session, p: &Json) -> Result<Json> {
172 if !bool_param(p, "document").unwrap_or(false)
173 && let Some(id) = s.active_window()
174 && let Some(doc) = s.window(id).map(|w| w.doc_uid)
175 && s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1
176 {
177 s.close_window(id);
178 return Ok(json!({"closed": true, "document": false}));
179 }
180 let force = bool_param(p, "force").unwrap_or(false);
181 let d = s.doc()?;
182 if d.is_dirty() && !force {
183 s.ui_requests.push(crate::engine::UiRequest::Dialog("saveChanges".into(), json!({"title": d.display_title()})));
184 return Ok(json!({"closed": false, "dirty": true}));
185 }
186 let i = s.active_index();
187 s.close_document(i);
188 Ok(json!({"closed": true}))
189}
190
191fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
192 let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
193 let d = s.doc()?;
194 let wb = (*d.wb).clone();
195 if p.get("sheet").is_some() {
196 wb.set_active_sheet(target_sheet(s, p)?);
197 }
198 let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
199 let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
200 crate::engine::io::csv_values_bytes(&wb)?
201 } else {
202 crate::engine::io::save_bytes(&wb, &target)?
203 };
204 crate::engine::io::write_file(&target, &bytes)?;
205 Ok(json!({"path": target, "bytes": bytes.len()}))
206}
207
208fn properties(s: &mut Session, p: &Json) -> Result<Json> {
209 edit(s, |cx| {
210 let pr = &mut cx.wb.props;
211 for (k, slot) in [
212 ("title", &mut pr.title),
213 ("subject", &mut pr.subject),
214 ("author", &mut pr.author),
215 ("company", &mut pr.company),
216 ("keywords", &mut pr.keywords),
217 ("description", &mut pr.description),
218 ] {
219 if let Some(v) = str_param(p, k) {
220 *slot = v.to_string();
221 }
222 }
223 Ok(serde_json::to_value(&cx.wb.props).unwrap_or_default())
224 })
225}
226
227fn options(s: &mut Session, _: &Json) -> Result<Json> {
230 s.ui_requests.push(crate::engine::UiRequest::Dialog("options".into(), json!({})));
231 let calculation = s.active().map(|d| {
232 let c = &d.wb.calc;
233 let mode = match c.mode {
234 crate::model::CalcMode::Automatic => "automatic",
235 crate::model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
236 crate::model::CalcMode::Manual => "manual",
237 };
238 json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change, "precisionAsDisplayed": c.precision_as_displayed})
239 });
240 Ok(json!({"calculation": calculation}))
241}
242
243fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
244 let id = match u32_param(p, "index").filter(|_| p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null)) {
245 Some(i) => {
246 let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("window.activate", "no such window"))?;
247 s.doc_window(uid).ok_or_else(|| bad("window.activate", "no such window"))?
248 }
249 None if p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null) => {
250 return Err(bad("window.activate", "missing `window`"));
251 }
252 None => super::extra::window_param(s, "window.activate", p)?,
253 };
254 s.unhide_window(id);
255 ok()
256}
257
258fn close_window(s: &mut Session, p: &Json) -> Result<Json> {
259 let id = super::extra::window_param(s, "window.close", p)?;
260 let doc = s.window(id).map(|w| w.doc_uid).unwrap_or_default();
261 if s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 {
262 s.close_window(id);
263 return Ok(json!({"closed": true, "document": false}));
264 }
265 let force = bool_param(p, "force").unwrap_or(false);
266 let d = s.documents().iter().find(|d| d.uid == doc).ok_or(crate::engine::EngineError::NoDocument)?;
267 if d.is_dirty() && !force {
268 let title = d.display_title();
269 s.activate_window(id);
270 s.ui_requests.push(crate::engine::UiRequest::Dialog("saveChanges".into(), json!({"title": title})));
271 return Ok(json!({"closed": false, "dirty": true}));
272 }
273 s.close_window(id);
274 Ok(json!({"closed": true, "document": true}))
275}