mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-01 22:56:54 -06:00
Calc: CELL("filename") names the workbook's file (#1489)
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
`<folder>\[<file>]<sheet>`, 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.
This commit is contained in:
+10
-1
@@ -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" => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 `#####`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user