mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 19:26:50 -06:00
Merge PR #1580: Calc: CELL("filename") names the workbook's file
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 `#####`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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