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 5aac8699b..bba440eb9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); +} 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> {