mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
* Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Fix imports * Fix serde imports * Fix engine imports * Fix clippy gate and a type error from the explicit-import change Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken. Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * Apply rustfmt to the explicit-import cleanup so the fmt gate passes * Share the per-directory font path expansion in theme.rs The candidate font lists repeated the same loop over the Linux font directories in every role. A single helper expands them in the same order, so the lookup order is unchanged and the duplicated blocks go away. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * Read the Windows fonts folder in one place in theme.rs Every script-font list repeated the same WINDIR lookup. A single helper returns the same path, so the duplicated lines go away without changing which font files are tried. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * Build the light and dark UI palettes from one table in theme.rs Tokens::light() and Tokens::dark() listed the same 40 colour fields in the same order, so every change had to be made twice. A single palette constructor takes the mode and picks each colour's value, so each field is written once and both modes keep their exact colours. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * Share the Noto CJK font list between the CJK script lists in theme.rs The Japanese, Simplified and Korean lists each repeated the same loop over the Linux CJK directories with a different locale file name. One helper builds that loop, and each list keeps its own face index, so the lookup order is unchanged. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * Share Linux Noto directory list across Thai and Hebrew font lookups Removes the duplicated directory arrays flagged by SonarCloud new-code duplication. * Reuse in_dirs for Thai and Hebrew Noto font candidates Removes the remaining duplicated per-directory loops flagged by SonarCloud. * Share one font-list builder for the Thai and Hebrew faces The two per-script functions repeated the same Windows, macOS, Linux and variable-font setup. A single script_fonts helper now builds both lists, keeping the candidate order unchanged, which clears the Sonar duplication gate. --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Codebuff <noreply@codebuff.com>
231 lines
12 KiB
Rust
231 lines
12 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 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),
|
||
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, crate::Trust::Local))
|
||
}
|
||
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 (mut wb, warnings) = crate::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?;
|
||
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
|
||
// What `CELL("filename")` reports (#1489). A workbook opened from bytes knows its name but
|
||
// never a folder, so it reports `[name]Sheet` with none; an import reports the file it came
|
||
// from, as Excel does, even though Save must still ask where to put the .xlsx.
|
||
wb.source_path = match str_param(p, "path") {
|
||
Some(path) => crate::io::source_path(path),
|
||
None => Some(std::path::PathBuf::from(&name)),
|
||
};
|
||
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(std::ffi::OsStr::to_str).unwrap_or("Book"))
|
||
} else {
|
||
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::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);
|
||
}
|
||
// Someone else may have made the file: its active content asks before it runs.
|
||
let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile);
|
||
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(std::ffi::OsStr::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. Relative paths to linked workbooks are rewritten for the new folder.
|
||
let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
|
||
let rebased = crate::io::source_dir(&path).and_then(|f| gridcraft_xlsx::rebase_links(&d.wb, &f));
|
||
let wb = rebased.as_ref().unwrap_or(&d.wb);
|
||
let bytes = match kind {
|
||
Some(kind) => crate::io::save_bytes_as(wb, &path, kind)?,
|
||
None => crate::io::save_bytes(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)) {
|
||
// The new file is the workbook's own now, so `CELL("filename")` reports it (#1489). Set
|
||
// before the `saved` snapshot, or the workbook would read as dirty the moment it was
|
||
// saved; and only when it changes, so an ordinary Save does not clone the workbook.
|
||
let file = crate::io::source_path(&path);
|
||
if d.wb.source_path != file {
|
||
std::sync::Arc::make_mut(&mut d.wb).source_path = file;
|
||
}
|
||
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 name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
|
||
// The file name goes into a PivotChart's link to its PivotTable.
|
||
let bytes = crate::io::save_bytes(&d.wb, &name)?;
|
||
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()
|
||
}
|