From aff9153dac69a9ab292ad47ba4089e54a3c1dd04 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 16 Oct 2026 01:09:12 +0500 Subject: [PATCH] Calc: CELL("filename") names the workbook's file (#1489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It returned an empty string for every workbook, saved or not, so the classic sheet-name formula `MID(CELL("filename",A1),FIND("]",…)+1,31)` gave #VALUE!: with no `]` in the string there is nothing for FIND to find. Excel returns `\[]`, and empty only before the first save. The model had no file to report. `source_dir` could not serve: it names the folder relative links to other workbooks are relative to, and must keep naming it after a Save As into another folder, because the links held in memory are still written for the old one — only the saved copy is rebased. `source_path` is therefore its own field, with that distinction written down. It is set when a workbook is opened, from the path made absolute so the folder is the one the user sees, and when a full-fidelity save makes a file the document's own — 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 for nothing. A workbook that arrived as bytes knows its name but never a folder and reports `[name]Sheet`, which still splits on the `]`; an import reports the file it came from, as Excel does. The sheet is the one the reference is on, not the active one. CELL is volatile and already in the volatile sets, so a cell holding it answers from the next recalculation; after a Save As that means F9 rather than the save itself, which the engine test asserts rather than papers over. Making a save recalculate is a larger decision than this issue. `check` still skips CELL("filename") as volatile, as docs/cli.md says: the cached value in a file is the folder of the machine that saved it. --- crates/calc/src/eval.rs | 11 ++++++- crates/calc/src/tests.rs | 37 +++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 14 +++++++++ crates/engine/src/io.rs | 6 ++++ crates/engine/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++ crates/model/src/workbook.rs | 8 +++++ 6 files changed, 130 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..b1bd40cdb 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -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 `/[]`, 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" => { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 97f7a8350..6d1b73b5d 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -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 `/[]`, 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")); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9ddc05611..2a925f951 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -58,6 +58,13 @@ fn open(s: &mut Session, p: &Json) -> Result { 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 { 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()); } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 14717a3df..50fbb6f42 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -235,6 +235,12 @@ pub fn source_dir(path: &str) -> Option { 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::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 `#####`. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..605b89450 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3344,3 +3344,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(); +} diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 828ca692e..ff9d851bd 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -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, + /// 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, } 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> {