Skip to main content

ravenrs_sheet/engine/cmd/
file.rs

1//! File menu: new, open, save, close, export, properties, options.
2
3use 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        // Already open? Switch to it.
54        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    // What `CELL("filename")` reports (#1489). A workbook opened from bytes knows its name but
68    // never a folder, so it reports `[name]Sheet` with none; an import reports the file it came
69    // from, as Excel does, even though Save must still ask where to put the .xlsx.
70    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    // An import has no save target: ordinary Save must ask for a new destination. Neither does a
84    // workbook decrypted with its password, so Save never replaces the encrypted original with
85    // an unprotected copy.
86    let path = str_param(p, "path").filter(|_| !imported && !encrypted).map(str::to_string);
87    // Replace an untouched blank Book1 like Excel does.
88    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    // Someone else may have made the file: its active content asks before it runs.
94    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    // The decrypted-workbook notice is for the info bar, not a message box.
100    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    // A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
123    // document keeps its file and its unsaved changes.
124    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    // The document's own file keeps the format its content had when opened; any other path
129    // goes by its extension. Relative paths to linked workbooks are rewritten for the new folder.
130    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    // Only a full-fidelity format clears the dirty flag and becomes the document's file.
139    if matches!(kind, Some(crate::engine::io::FileKind::Xlsx | crate::engine::io::FileKind::Json)) {
140        // The new file is the workbook's own now, so `CELL("filename")` reports it (#1489). Set
141        // before the `saved` snapshot, or the workbook would read as dirty the moment it was
142        // saved; and only when it changes, so an ordinary Save does not clone the workbook.
143        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    // The file name goes into a PivotChart's link to its PivotTable.
166    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
170/// File › Close closes the active window; the workbook closes with its last window.
171fn 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
227/// File › Options: reports the workbook-level settings and asks the interface to show its Options
228/// page. Nothing changes here; every setting has its own command.
229fn 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}