Merge PR #1580: Calc: CELL("filename") names the workbook's file

This commit is contained in:
aiongg
2026-10-22 02:35:56 +00:00
6 changed files with 130 additions and 1 deletions
+10 -1
View File
@@ -1373,7 +1373,16 @@ impl<'h> Evaluator<'h> {
}))
}
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
"filename" => Ev::V(Value::text(String::new())),
// Excel's `<folder>/[<file>]<sheet>`, which the classic sheet-name formula splits on
// the `]`. Empty until the workbook has a file of its own, as in Excel (#1489).
"filename" => Ev::V(Value::text(match &wb.source_path {
Some(file) => {
let name = file.file_name().unwrap_or_default().to_string_lossy();
let on = sheet.map_or("", |s| s.name.as_str());
file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned()
}
None => String::new(),
})),
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
"format" => {
+37
View File
@@ -1294,3 +1294,40 @@ fn median_large_values_recalculate_dependents() {
assert!((t.num(cell) + 1.3).abs() < 1e-14);
}
}
#[test]
fn cell_filename_names_the_workbook_file() {
// Excel returns `<folder>/[<file>]<sheet>`, and the classic sheet-name formula splits it on
// the `]`; an empty string broke that formula in every German workbook that used it (#1489).
let dir = std::path::Path::new("books");
let mut t = T::new();
t.wb.sheet_mut(0).unwrap().name = "Info".into();
// No file yet: empty, as in a workbook Excel has never saved.
t.set("A1", "1");
t.set("B1", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B1"), Value::from(""));
t.wb.source_path = Some(dir.join("gc_cell_info_de.xlsx"));
t.calc = Calc::new();
let expect = dir.join("[gc_cell_info_de.xlsx]Info").to_string_lossy().into_owned();
t.set("B2", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B2"), Value::from(expect.as_str()));
// The two formulas from the report: the sheet name, and the test that the `]` is there.
t.set("B3", "=MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)");
assert_eq!(t.get("B3"), Value::from("Info"), "the sheet-name formula");
t.set("B4", "=ISNUMBER(FIND(\"]\",CELL(\"filename\",A1)))");
assert_eq!(t.get("B4"), Value::Bool(true));
// The sheet is the one the reference is on, not the active one.
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Second")));
t.set("B6", "=CELL(\"filename\",Second!A1)");
assert_eq!(t.get("B6"), Value::from(dir.join("[gc_cell_info_de.xlsx]Second").to_string_lossy().into_owned().as_str()));
// A workbook that arrived as bytes knows its name but no folder: still splittable.
t.wb.source_path = Some(std::path::PathBuf::from("Book.xlsx"));
t.calc = Calc::new();
t.set("B7", "=CELL(\"filename\",A1)");
assert_eq!(t.get("B7"), Value::from("[Book.xlsx]Info"));
}
+14
View File
@@ -58,6 +58,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
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);
@@ -122,6 +129,13 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
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());
}
+6
View File
@@ -235,6 +235,12 @@ pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
}
/// The file at `path` as `CELL("filename")` reports it: absolute, so the folder is the one the
/// user sees. A workbook that arrived as bytes has no path and keeps only its name.
pub fn source_path(path: &str) -> Option<std::path::PathBuf> {
std::path::absolute(path).ok()
}
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
/// transferred to other file formats*): number formats applied, never column-width `#####`.
+55
View File
@@ -3541,3 +3541,58 @@ fn csv_as_text_keeps_identifier_columns_as_written() {
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes());
}
/// Open a workbook by path, and `CELL("filename")` names the file it came from, as Excel does;
/// Save As makes the new file the one it names. The sheet-name formula of #1489 depends on it.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn cell_filename_follows_the_file_the_workbook_lives_in() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-cellname-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let name = "=CELL(\"filename\",A1)";
let sheet_of = "=MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)";
// A new workbook has no file: empty, and the sheet-name formula cannot work yet.
let mut fresh = s();
fresh.execute("cell.set", json!({"cell": "A1", "input": name})).unwrap();
fresh.execute("cell.set", json!({"cell": "A2", "input": sheet_of})).unwrap();
assert_eq!(v(&fresh, "A1"), Value::from(""));
assert_eq!(v(&fresh, "A2"), Value::Error(gridcraft_core::CellError::Value));
// Saved: it names the file. `CELL` is volatile, so the cells holding it answer from the next
// recalculation (F9), the same as every other volatile function; the save itself is not one.
let first = dir.join("book one.xlsx");
fresh.execute("file.saveAs", json!({"path": first})).unwrap();
let sheet = fresh.doc().unwrap().wb.active().unwrap().name.clone();
// Saving did not leave the workbook looking modified.
assert!(!fresh.doc().unwrap().is_dirty());
fresh.execute("formulas.calculateNow", json!({})).unwrap();
assert_eq!(v(&fresh, "A1"), Value::from(dir.join(format!("[book one.xlsx]{sheet}")).to_string_lossy().into_owned().as_str()));
assert_eq!(v(&fresh, "A2"), Value::from(sheet.as_str()));
// Opened from that path in another session: the same answer, from the file alone.
let mut opened = s();
opened.execute("file.open", json!({"path": first})).unwrap();
opened.take_ui_requests();
assert_eq!(v(&opened, "A2"), Value::from(sheet.as_str()));
// Save As into another folder: it follows the new file, not the old one.
let other = dir.join("sub");
std::fs::create_dir(&other).unwrap();
opened.execute("file.saveAs", json!({"path": other.join("book two.xlsx")})).unwrap();
assert!(!opened.doc().unwrap().is_dirty());
opened.execute("formulas.calculateNow", json!({})).unwrap();
assert_eq!(v(&opened, "A1"), Value::from(other.join(format!("[book two.xlsx]{sheet}")).to_string_lossy().into_owned().as_str()));
// Opened from bytes there is no folder, only a name — still splittable on the `]`.
let saved = opened.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let mut from_bytes = s();
from_bytes.execute("file.open", json!({"name": "from bytes.xlsx", "base64": saved["base64"]})).unwrap();
from_bytes.take_ui_requests();
assert_eq!(v(&from_bytes, "A1"), Value::from(format!("[from bytes.xlsx]{sheet}").as_str()));
assert_eq!(v(&from_bytes, "A2"), Value::from(sheet.as_str()));
std::fs::remove_dir_all(dir).unwrap();
}
+8
View File
@@ -120,6 +120,13 @@ pub struct Workbook {
/// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON.
#[serde(skip)]
pub source_dir: Option<std::path::PathBuf>,
/// The file this workbook reports as its own, which is what `CELL("filename")` returns.
/// `None` = no file yet, which Excel reports as an empty string until the workbook has been
/// saved once. Deliberately separate from `source_dir`: that one names the folder relative
/// links are relative to and must keep naming it after a Save As into another folder, since
/// the links in memory are still written for the old one. Not in JSON.
#[serde(skip)]
pub source_path: Option<std::path::PathBuf>,
}
impl Default for Workbook {
@@ -148,6 +155,7 @@ impl Workbook {
csv_delimiter: None,
preserved: None,
source_dir: None,
source_path: None,
}
}
pub fn sheet(&self, i: usize) -> Option<&Sheet> {