From 03e227fbb2320c4d6761224524a3783a968bd5d1 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sat, 7 Jun 2025 22:10:00 -0700 Subject: [PATCH 01/25] fix grammar spelling --- base/src/model.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/base/src/model.rs b/base/src/model.rs index 7ceb5363f..f2879cb3a 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -106,15 +106,15 @@ pub struct Model { pub(crate) shared_strings: HashMap, /// An instance of the parser pub(crate) parser: Parser, - /// The list of cells with formulas that are evaluated of being evaluated + /// The list of cells with formulas that are evaluated or being evaluated pub(crate) cells: HashMap<(u32, i32, i32), CellState>, /// The locale of the model pub(crate) locale: Locale, - /// Tha language used + /// The language used pub(crate) language: Language, /// The timezone used to evaluate the model pub(crate) tz: Tz, - /// The view id. A view consist of a selected sheet and ranges. + /// The view id. A view consists of a selected sheet and ranges. pub(crate) view_id: u32, } From cc121c71f5f8f961fac2954af858c812b59d2944 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 8 Jun 2025 01:58:27 -0500 Subject: [PATCH 02/25] feat: add tracking of changed cells for evaluation status --- base/src/model.rs | 21 ++++++++++-- base/src/user_model/common.rs | 7 +++- bindings/wasm/src/lib.rs | 16 +++++++++- bindings/wasm/tests/test.mjs | 60 +++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/base/src/model.rs b/base/src/model.rs index 7ceb5363f..020acbf9b 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -35,6 +35,8 @@ use chrono_tz::Tz; #[cfg(test)] pub use crate::mock_time::get_milliseconds_since_epoch; +use serde::{Serialize, Deserialize}; + /// Number of milliseconds since January 1, 1970 /// Used by time and date functions. It takes the value from the environment: /// * The Operative System @@ -106,15 +108,15 @@ pub struct Model { pub(crate) shared_strings: HashMap, /// An instance of the parser pub(crate) parser: Parser, - /// The list of cells with formulas that are evaluated of being evaluated + /// The list of cells with formulas that are evaluated or being evaluated pub(crate) cells: HashMap<(u32, i32, i32), CellState>, /// The locale of the model pub(crate) locale: Locale, - /// Tha language used + /// The language used pub(crate) language: Language, /// The timezone used to evaluate the model pub(crate) tz: Tz, - /// The view id. A view consist of a selected sheet and ranges. + /// The view id. A view consists of a selected sheet and ranges. pub(crate) view_id: u32, } @@ -129,6 +131,14 @@ pub struct CellIndex { pub column: i32, } +// Struct to represent a cell reference for serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CellReference { + pub sheet: u32, + pub row: i32, + pub column: i32, +} + impl Model { pub(crate) fn evaluate_node_with_reference( &mut self, @@ -2258,6 +2268,11 @@ impl Model { pub fn delete_row_style(&mut self, sheet: u32, row: i32) -> Result<(), String> { self.workbook.worksheet_mut(sheet)?.delete_row_style(row) } + + /// Returns a list of all cells that have been changed or are being evaluated + pub fn get_changed_cells(&self) -> Vec { + self.cells.keys().map(|&(sheet, row, column)| CellReference { sheet, row, column }).collect() + } } #[cfg(test)] diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 0b4b172d1..fb402efc6 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -11,7 +11,7 @@ use crate::{ types::{Area, CellReferenceIndex}, utils::{is_valid_column_number, is_valid_row}, }, - model::Model, + model::{Model, CellReference}, types::{ Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState, Style, VerticalAlignment, @@ -2372,6 +2372,11 @@ impl UserModel { } Ok(()) } + + /// Returns a list of all cells that have been changed or are being evaluated + pub fn get_changed_cells(&self) -> Vec { + self.model.get_changed_cells() + } } #[cfg(test)] diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2edf..049a3d431 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,4 +1,4 @@ -use serde::Serialize; +use serde::{Serialize, Deserialize}; use wasm_bindgen::{ prelude::{wasm_bindgen, JsError}, JsValue, @@ -672,4 +672,18 @@ impl Model { .delete_defined_name(name, scope) .map_err(|e| to_js_error(e.to_string())) } + + #[wasm_bindgen(js_name = "getChangedCells")] + pub fn get_changed_cells(&self) -> JsValue { + let changed_cells = self.model.get_changed_cells(); + serde_wasm_bindgen::to_value(&changed_cells).unwrap() + } +} + +#[wasm_bindgen] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CellReference { + pub sheet: u32, + pub row: i32, + pub column: i32, } diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 374669966..7064641c8 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -130,5 +130,65 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); +test('Track changed cells', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, "10"); + model.setUserInput(0, 1, 2, "=A1*2"); + model.evaluate(); + + const changedCells = model.getChangedCells(); + assert.strictEqual(changedCells.length, 1, 'Changed cells should include directly set cell and dependent cell'); + assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 2 }, 'Second changed cell should be B1'); +}); + +test('Track changed cells - Circular Dependency with External Dependent', (t) => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Setup circular dependency: A1 -> B1 -> C1 -> A1 + model.setUserInput(0, 1, 1, "=B1"); + model.setUserInput(0, 1, 2, "=C1"); + model.setUserInput(0, 1, 3, "=A1"); + // Setup external dependent: D1 depends on A1 + model.setUserInput(0, 1, 4, "=A1+1"); + // Evaluate to set initial state + model.evaluate(); + // Update A1 to trigger circular dependency error + model.setUserInput(0, 1, 1, "=B1+10"); + model.evaluate(); + // Get changed cells + const changedCells = model.getChangedCells(); + // Check if dependent cells are tracked as changed, excluding A1 which was directly updated + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 2), true, 'B1 should be tracked as changed due to circular dependency'); + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 3), true, 'C1 should be tracked as changed due to circular dependency'); + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 4), true, 'D1 should be tracked as changed due to dependency on A1'); +}); + +test('Track changed cells - Multi-Sheet Cascading with Defined Name', (t) => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Add additional sheets + model.newSheet(); + model.renameSheet(1, "Sheet2"); + model.newSheet(); + model.renameSheet(2, "Sheet3"); + // Define a name 'TotalSales' for Sheet1!A1:A2 + model.newDefinedName("TotalSales", 0, "=Sheet1!A1:A2"); + // Set values in Sheet1 + model.setUserInput(0, 1, 1, "100"); + model.setUserInput(0, 2, 1, "200"); + // Set formula in Sheet2 using defined name + model.setUserInput(1, 2, 2, "=SUM(TotalSales)"); + // Set formula in Sheet3 referencing Sheet2!B2 + model.setUserInput(2, 3, 3, "=Sheet2!B2*2"); + // Evaluate initial state + model.evaluate(); + // Update Sheet1!A1 to trigger cascading changes + model.setUserInput(0, 1, 1, "150"); + model.evaluate(); + // Get changed cells + const changedCells = model.getChangedCells(); + // Verify only dependent cells are tracked, excluding Sheet1!A1 which was directly updated + assert.strictEqual(changedCells.some(c => c.sheet === 1 && c.row === 2 && c.column === 2), true, 'Sheet2!B2 should be tracked as changed'); + assert.strictEqual(changedCells.some(c => c.sheet === 2 && c.row === 3 && c.column === 3), true, 'Sheet3!C3 should be tracked as changed'); +}); + From 381e55697049c85e619ad7dde0bb9e403e401ca4 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 8 Jun 2025 02:05:37 -0500 Subject: [PATCH 03/25] run cargo format --- base/src/model.rs | 7 +++++-- base/src/user_model/common.rs | 2 +- bindings/wasm/src/lib.rs | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/base/src/model.rs b/base/src/model.rs index 020acbf9b..4ccb12aed 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -35,7 +35,7 @@ use chrono_tz::Tz; #[cfg(test)] pub use crate::mock_time::get_milliseconds_since_epoch; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /// Number of milliseconds since January 1, 1970 /// Used by time and date functions. It takes the value from the environment: @@ -2271,7 +2271,10 @@ impl Model { /// Returns a list of all cells that have been changed or are being evaluated pub fn get_changed_cells(&self) -> Vec { - self.cells.keys().map(|&(sheet, row, column)| CellReference { sheet, row, column }).collect() + self.cells + .keys() + .map(|&(sheet, row, column)| CellReference { sheet, row, column }) + .collect() } } diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index fb402efc6..a271c5a00 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -11,7 +11,7 @@ use crate::{ types::{Area, CellReferenceIndex}, utils::{is_valid_column_number, is_valid_row}, }, - model::{Model, CellReference}, + model::{CellReference, Model}, types::{ Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState, Style, VerticalAlignment, diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 049a3d431..aa21555ad 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use wasm_bindgen::{ prelude::{wasm_bindgen, JsError}, JsValue, From 0baa5e19ea833e8a7acefef2288eb5563a7d5fd8 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 8 Jun 2025 10:55:57 -0500 Subject: [PATCH 04/25] fix cargo clippy --- bindings/wasm/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index aa21555ad..57b43d51f 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -676,7 +676,7 @@ impl Model { #[wasm_bindgen(js_name = "getChangedCells")] pub fn get_changed_cells(&self) -> JsValue { let changed_cells = self.model.get_changed_cells(); - serde_wasm_bindgen::to_value(&changed_cells).unwrap() + serde_wasm_bindgen::to_value(&changed_cells).unwrap_or_else(|_| JsValue::undefined()) } } From a86060b85aeaa4933aefc034a9dc010927e81ba8 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 8 Jun 2025 13:29:14 -0500 Subject: [PATCH 05/25] add get changed cells to fix_types --- bindings/wasm/fix_types.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index fd466ee33..2d369358d 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -167,6 +167,20 @@ copyToClipboard(): Clipboard; """ +get_changed_cells = r""" +/** +* @returns {any} +*/ + getChangedCells(): any; +""" + +get_changed_cells_types = r""" +/** +* @returns {ChangedCell[]} +*/ + getChangedCells(): ChangedCell[]; +""" + paste_from_clipboard = r""" /** * @param {number} source_sheet @@ -213,6 +227,7 @@ def fix_types(text): text = text.replace(set_area_border, set_area_border_types) text = text.replace(paste_csv_string, paste_csv_string_types) text = text.replace(clipboard, clipboard_types) + text = text.replace(get_changed_cells, get_changed_cells_types) text = text.replace(paste_from_clipboard, paste_from_clipboard_types) text = text.replace(defined_name_list, defined_name_list_types) with open("types.ts") as f: @@ -221,6 +236,9 @@ def fix_types(text): text = text.replace(header, header_types) if text.find("any") != -1: print("There are 'unfixed' types. Please check.") + for i, line in enumerate(text.splitlines()): + if 'any' in line: + print(f"Line {i+1}: {line}") exit(1) return text From 8a02e0483ca8e21c7a3bb26dc76490dc33c786bd Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 8 Jun 2025 15:19:09 -0500 Subject: [PATCH 06/25] add row col test --- bindings/wasm/tests/test.mjs | 143 +++++++++++++++++++++-------------- 1 file changed, 85 insertions(+), 58 deletions(-) diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 7064641c8..9be9638ab 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -130,65 +130,92 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); -test('Track changed cells', () => { - const model = new Model('Workbook1', 'en', 'UTC'); - model.setUserInput(0, 1, 1, "10"); - model.setUserInput(0, 1, 2, "=A1*2"); - model.evaluate(); - - const changedCells = model.getChangedCells(); - assert.strictEqual(changedCells.length, 1, 'Changed cells should include directly set cell and dependent cell'); - assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 2 }, 'Second changed cell should be B1'); -}); - -test('Track changed cells - Circular Dependency with External Dependent', (t) => { - const model = new Model('Workbook1', 'en', 'UTC'); - // Setup circular dependency: A1 -> B1 -> C1 -> A1 - model.setUserInput(0, 1, 1, "=B1"); - model.setUserInput(0, 1, 2, "=C1"); - model.setUserInput(0, 1, 3, "=A1"); - // Setup external dependent: D1 depends on A1 - model.setUserInput(0, 1, 4, "=A1+1"); - // Evaluate to set initial state - model.evaluate(); - // Update A1 to trigger circular dependency error - model.setUserInput(0, 1, 1, "=B1+10"); - model.evaluate(); - // Get changed cells - const changedCells = model.getChangedCells(); - // Check if dependent cells are tracked as changed, excluding A1 which was directly updated - assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 2), true, 'B1 should be tracked as changed due to circular dependency'); - assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 3), true, 'C1 should be tracked as changed due to circular dependency'); - assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 4), true, 'D1 should be tracked as changed due to dependency on A1'); -}); +test('track changed cells', (t) => { + t.test('basic update', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, "10"); + model.setUserInput(0, 1, 2, "=A1*2"); + model.evaluate(); + const changedCells = model.getChangedCells(); + assert.strictEqual(changedCells.length, 1, 'Changed cells should include directly set cell and dependent cell'); + assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 2 }, 'Second changed cell should be B1'); + }); -test('Track changed cells - Multi-Sheet Cascading with Defined Name', (t) => { - const model = new Model('Workbook1', 'en', 'UTC'); - // Add additional sheets - model.newSheet(); - model.renameSheet(1, "Sheet2"); - model.newSheet(); - model.renameSheet(2, "Sheet3"); - // Define a name 'TotalSales' for Sheet1!A1:A2 - model.newDefinedName("TotalSales", 0, "=Sheet1!A1:A2"); - // Set values in Sheet1 - model.setUserInput(0, 1, 1, "100"); - model.setUserInput(0, 2, 1, "200"); - // Set formula in Sheet2 using defined name - model.setUserInput(1, 2, 2, "=SUM(TotalSales)"); - // Set formula in Sheet3 referencing Sheet2!B2 - model.setUserInput(2, 3, 3, "=Sheet2!B2*2"); - // Evaluate initial state - model.evaluate(); - // Update Sheet1!A1 to trigger cascading changes - model.setUserInput(0, 1, 1, "150"); - model.evaluate(); - // Get changed cells - const changedCells = model.getChangedCells(); - // Verify only dependent cells are tracked, excluding Sheet1!A1 which was directly updated - assert.strictEqual(changedCells.some(c => c.sheet === 1 && c.row === 2 && c.column === 2), true, 'Sheet2!B2 should be tracked as changed'); - assert.strictEqual(changedCells.some(c => c.sheet === 2 && c.row === 3 && c.column === 3), true, 'Sheet3!C3 should be tracked as changed'); -}); + t.test('circular dependency with external dependent', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Setup circular dependency: A1 -> B1 -> C1 -> A1 + model.setUserInput(0, 1, 1, "=B1"); + model.setUserInput(0, 1, 2, "=C1"); + model.setUserInput(0, 1, 3, "=A1"); + // Setup external dependent: D1 depends on A1 + model.setUserInput(0, 1, 4, "=A1+1"); + // Evaluate to set initial state + model.evaluate(); + // Update A1 to trigger circular dependency error + model.setUserInput(0, 1, 1, "=B1+10"); + model.evaluate(); + // Get changed cells + const changedCells = model.getChangedCells(); + // Check if dependent cells are tracked as changed, excluding A1 which was directly updated + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 2), true, 'B1 should be tracked as changed due to circular dependency'); + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 3), true, 'C1 should be tracked as changed due to circular dependency'); + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 4), true, 'D1 should be tracked as changed due to dependency on A1'); + }); + t.test('multi-sheet cascading with defined name', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Add additional sheets + model.newSheet(); + model.renameSheet(1, "Sheet2"); + model.newSheet(); + model.renameSheet(2, "Sheet3"); + // Define a name 'TotalSales' for Sheet1!A1:A2 + model.newDefinedName("TotalSales", 0, "=Sheet1!A1:A2"); + // Set values in Sheet1 + model.setUserInput(0, 1, 1, "100"); + model.setUserInput(0, 2, 1, "200"); + // Set formula in Sheet2 using defined name + model.setUserInput(1, 2, 2, "=SUM(TotalSales)"); + // Set formula in Sheet3 referencing Sheet2!B2 + model.setUserInput(2, 3, 3, "=Sheet2!B2*2"); + // Evaluate initial state + model.evaluate(); + // Update Sheet1!A1 to trigger cascading changes + model.setUserInput(0, 1, 1, "150"); + model.evaluate(); + // Get changed cells + const changedCells = model.getChangedCells(); + // Verify only dependent cells are tracked, excluding Sheet1!A1 which was directly updated + assert.strictEqual(changedCells.some(c => c.sheet === 1 && c.row === 2 && c.column === 2), true, 'Sheet2!B2 should be tracked as changed'); + assert.strictEqual(changedCells.some(c => c.sheet === 2 && c.row === 3 && c.column === 3), true, 'Sheet3!C3 should be tracked as changed'); + }); + t.test('move row updates formulas', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, "10"); + model.setUserInput(0, 2, 2, "=A1*2"); + model.evaluate(); + assert.strictEqual(model.getFormattedCellValue(0, 2, 2), "20"); + // Move row 1 to row 3 + model.insertRow(0, 1); + model.insertRow(0, 1); + model.evaluate(); + const changedCells = model.getChangedCells(); + assert.strictEqual(changedCells.length, 1, 'One cell should be marked as changed after row insertion'); + assert.deepEqual(changedCells[0], { sheet: 0, row: 4, column: 2 }, 'Changed cell should be B4 due to formula update after row shift'); + }); + t.test('move column updates formulas', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, "5"); + model.setUserInput(0, 1, 2, "=A1+3"); + model.evaluate(); + assert.strictEqual(model.getFormattedCellValue(0, 1, 2), "8"); + // Insert a column before column 1, shifting existing columns + model.insertColumn(0, 1); + model.evaluate(); + const changedCells = model.getChangedCells(); + assert.strictEqual(changedCells.length, 1, 'One cell should be marked as changed after column insertion'); + assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 3 }, 'Changed cell should be C1 due to formula update after column shift'); + }); +}); From 469f65ecd5c31a799eb39f9d671a65fd93016ce8 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 8 Jun 2025 15:33:28 -0500 Subject: [PATCH 07/25] split changed cell tests to avoid timeout --- bindings/wasm/tests/test.mjs | 176 +++++++++++++++++------------------ 1 file changed, 87 insertions(+), 89 deletions(-) diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 9be9638ab..e0b39f529 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -75,7 +75,7 @@ test('Styles work', () => { }); }); -test("Add sheets", (t) => { +test("add sheets", (t) => { const model = new Model('Workbook1', 'en', 'UTC'); model.newSheet(); model.renameSheet(1, "NewName"); @@ -93,7 +93,7 @@ test("Add sheets", (t) => { ]); }); -test("invalid sheet index throws an exception", () => { +test('invalid sheet index throws an exception', () => { const model = new Model('Workbook1', 'en', 'UTC'); assert.throws(() => { model.setRowsHeight(1, 1, 1, 100); @@ -103,7 +103,7 @@ test("invalid sheet index throws an exception", () => { }); }); -test("invalid column throws an exception", () => { +test('invalid column throws an exception', () => { const model = new Model('Workbook1', 'en', 'UTC'); assert.throws(() => { model.setRowsHeight(0, -1, 0, 100); @@ -113,7 +113,7 @@ test("invalid column throws an exception", () => { }); }); -test("floating column numbers get truncated", () => { +test('floating column numbers get truncated', () => { const model = new Model('Workbook1', 'en', 'UTC'); model.setRowsHeight(0.8, 5.2, 5.5, 100.5); @@ -121,7 +121,7 @@ test("floating column numbers get truncated", () => { assert.strictEqual(model.getRowHeight(0, 5), 100.5); }); -test("autofill", () => { +test('autofill', () => { const model = new Model('Workbook1', 'en', 'UTC'); model.setUserInput(0, 1, 1, "23"); model.autoFillRows({sheet: 0, row: 1, column: 1, width: 1, height: 1}, 2); @@ -130,92 +130,90 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); -test('track changed cells', (t) => { - t.test('basic update', () => { - const model = new Model('Workbook1', 'en', 'UTC'); - model.setUserInput(0, 1, 1, "10"); - model.setUserInput(0, 1, 2, "=A1*2"); - model.evaluate(); - const changedCells = model.getChangedCells(); - assert.strictEqual(changedCells.length, 1, 'Changed cells should include directly set cell and dependent cell'); - assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 2 }, 'Second changed cell should be B1'); - }); +test('track changed cells - basic update', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, "10"); + model.setUserInput(0, 1, 2, "=A1*2"); + model.evaluate(); + const changedCells = model.getChangedCells(); + assert.strictEqual(changedCells.length, 1, 'Changed cells should include directly set cell and dependent cell'); + assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 2 }, 'Second changed cell should be B1'); +}); - t.test('circular dependency with external dependent', () => { - const model = new Model('Workbook1', 'en', 'UTC'); - // Setup circular dependency: A1 -> B1 -> C1 -> A1 - model.setUserInput(0, 1, 1, "=B1"); - model.setUserInput(0, 1, 2, "=C1"); - model.setUserInput(0, 1, 3, "=A1"); - // Setup external dependent: D1 depends on A1 - model.setUserInput(0, 1, 4, "=A1+1"); - // Evaluate to set initial state - model.evaluate(); - // Update A1 to trigger circular dependency error - model.setUserInput(0, 1, 1, "=B1+10"); - model.evaluate(); - // Get changed cells - const changedCells = model.getChangedCells(); - // Check if dependent cells are tracked as changed, excluding A1 which was directly updated - assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 2), true, 'B1 should be tracked as changed due to circular dependency'); - assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 3), true, 'C1 should be tracked as changed due to circular dependency'); - assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 4), true, 'D1 should be tracked as changed due to dependency on A1'); - }); +test('track changed cells - circular dependency with external dependent', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Setup circular dependency: A1 -> B1 -> C1 -> A1 + model.setUserInput(0, 1, 1, "=B1"); + model.setUserInput(0, 1, 2, "=C1"); + model.setUserInput(0, 1, 3, "=A1"); + // Setup external dependent: D1 depends on A1 + model.setUserInput(0, 1, 4, "=A1+1"); + // Evaluate to set initial state + model.evaluate(); + // Update A1 to trigger circular dependency error + model.setUserInput(0, 1, 1, "=B1+10"); + model.evaluate(); + // Get changed cells + const changedCells = model.getChangedCells(); + // Check if dependent cells are tracked as changed, excluding A1 which was directly updated + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 2), true, 'B1 should be tracked as changed due to circular dependency'); + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 3), true, 'C1 should be tracked as changed due to circular dependency'); + assert.strictEqual(changedCells.some(c => c.sheet === 0 && c.row === 1 && c.column === 4), true, 'D1 should be tracked as changed due to dependency on A1'); +}); - t.test('multi-sheet cascading with defined name', () => { - const model = new Model('Workbook1', 'en', 'UTC'); - // Add additional sheets - model.newSheet(); - model.renameSheet(1, "Sheet2"); - model.newSheet(); - model.renameSheet(2, "Sheet3"); - // Define a name 'TotalSales' for Sheet1!A1:A2 - model.newDefinedName("TotalSales", 0, "=Sheet1!A1:A2"); - // Set values in Sheet1 - model.setUserInput(0, 1, 1, "100"); - model.setUserInput(0, 2, 1, "200"); - // Set formula in Sheet2 using defined name - model.setUserInput(1, 2, 2, "=SUM(TotalSales)"); - // Set formula in Sheet3 referencing Sheet2!B2 - model.setUserInput(2, 3, 3, "=Sheet2!B2*2"); - // Evaluate initial state - model.evaluate(); - // Update Sheet1!A1 to trigger cascading changes - model.setUserInput(0, 1, 1, "150"); - model.evaluate(); - // Get changed cells - const changedCells = model.getChangedCells(); - // Verify only dependent cells are tracked, excluding Sheet1!A1 which was directly updated - assert.strictEqual(changedCells.some(c => c.sheet === 1 && c.row === 2 && c.column === 2), true, 'Sheet2!B2 should be tracked as changed'); - assert.strictEqual(changedCells.some(c => c.sheet === 2 && c.row === 3 && c.column === 3), true, 'Sheet3!C3 should be tracked as changed'); - }); +test('track changed cells - multi-sheet cascading with defined name', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Add additional sheets + model.newSheet(); + model.renameSheet(1, "Sheet2"); + model.newSheet(); + model.renameSheet(2, "Sheet3"); + // Define a name 'TotalSales' for Sheet1!A1:A2 + model.newDefinedName("TotalSales", 0, "=Sheet1!A1:A2"); + // Set values in Sheet1 + model.setUserInput(0, 1, 1, "100"); + model.setUserInput(0, 2, 1, "200"); + // Set formula in Sheet2 using defined name + model.setUserInput(1, 2, 2, "=SUM(TotalSales)"); + // Set formula in Sheet3 referencing Sheet2!B2 + model.setUserInput(2, 3, 3, "=Sheet2!B2*2"); + // Evaluate initial state + model.evaluate(); + // Update Sheet1!A1 to trigger cascading changes + model.setUserInput(0, 1, 1, "150"); + model.evaluate(); + // Get changed cells + const changedCells = model.getChangedCells(); + // Verify only dependent cells are tracked, excluding Sheet1!A1 which was directly updated + assert.strictEqual(changedCells.some(c => c.sheet === 1 && c.row === 2 && c.column === 2), true, 'Sheet2!B2 should be tracked as changed'); + assert.strictEqual(changedCells.some(c => c.sheet === 2 && c.row === 3 && c.column === 3), true, 'Sheet3!C3 should be tracked as changed'); +}); - t.test('move row updates formulas', () => { - const model = new Model('Workbook1', 'en', 'UTC'); - model.setUserInput(0, 1, 1, "10"); - model.setUserInput(0, 2, 2, "=A1*2"); - model.evaluate(); - assert.strictEqual(model.getFormattedCellValue(0, 2, 2), "20"); - // Move row 1 to row 3 - model.insertRow(0, 1); - model.insertRow(0, 1); - model.evaluate(); - const changedCells = model.getChangedCells(); - assert.strictEqual(changedCells.length, 1, 'One cell should be marked as changed after row insertion'); - assert.deepEqual(changedCells[0], { sheet: 0, row: 4, column: 2 }, 'Changed cell should be B4 due to formula update after row shift'); - }); +test('track changed cells - move row updates formulas', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, "10"); + model.setUserInput(0, 2, 2, "=A1*2"); + model.evaluate(); + assert.strictEqual(model.getFormattedCellValue(0, 2, 2), "20"); + // Move row 1 to row 3 + model.insertRow(0, 1); + model.insertRow(0, 1); + model.evaluate(); + const changedCells = model.getChangedCells(); + assert.strictEqual(changedCells.length, 1, 'One cell should be marked as changed after row insertion'); + assert.deepEqual(changedCells[0], { sheet: 0, row: 4, column: 2 }, 'Changed cell should be B4 due to formula update after row shift'); +}); - t.test('move column updates formulas', () => { - const model = new Model('Workbook1', 'en', 'UTC'); - model.setUserInput(0, 1, 1, "5"); - model.setUserInput(0, 1, 2, "=A1+3"); - model.evaluate(); - assert.strictEqual(model.getFormattedCellValue(0, 1, 2), "8"); - // Insert a column before column 1, shifting existing columns - model.insertColumn(0, 1); - model.evaluate(); - const changedCells = model.getChangedCells(); - assert.strictEqual(changedCells.length, 1, 'One cell should be marked as changed after column insertion'); - assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 3 }, 'Changed cell should be C1 due to formula update after column shift'); - }); +test('track changed cells - move column updates formulas', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, "5"); + model.setUserInput(0, 1, 2, "=A1+3"); + model.evaluate(); + assert.strictEqual(model.getFormattedCellValue(0, 1, 2), "8"); + // Insert a column before column 1, shifting existing columns + model.insertColumn(0, 1); + model.evaluate(); + const changedCells = model.getChangedCells(); + assert.strictEqual(changedCells.length, 1, 'One cell should be marked as changed after column insertion'); + assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 3 }, 'Changed cell should be C1 due to formula update after column shift'); }); From 8f2256e2965a7c8444ffab109c97b7347c966840 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Sun, 8 Jun 2025 23:44:26 -0500 Subject: [PATCH 08/25] feat: add getRecentDiffs API with TypeScript interfaces --- base/src/types.rs | 16 +- base/src/user_model/common.rs | 12 +- base/src/user_model/history.rs | 27 +-- bindings/nodejs/src/user_model.rs | 5 + bindings/wasm/fix_types.py | 15 ++ bindings/wasm/src/lib.rs | 5 + bindings/wasm/tests/test.mjs | 106 ++++++++++++ bindings/wasm/types.ts | 278 ++++++++++++++++++++++++++++++ 8 files changed, 442 insertions(+), 22 deletions(-) diff --git a/base/src/types.rs b/base/src/types.rs index 07b41bc87..74156ef4a 100644 --- a/base/src/types.rs +++ b/base/src/types.rs @@ -64,7 +64,7 @@ pub struct DefinedName { /// * state: /// 18.18.68 ST_SheetState (Sheet Visibility Types) /// hidden, veryHidden, visible -#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)] +#[derive(Debug, Clone, Encode, Decode, Serialize, PartialEq)] pub enum SheetState { Visible, Hidden, @@ -84,7 +84,7 @@ impl Display for SheetState { /// Represents the state of the worksheet as seen by the user. This includes /// details such as the currently selected cell, the visible range, and the /// position of the viewport. -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct WorksheetView { /// The row index of the currently selected cell. pub row: i32, @@ -99,13 +99,13 @@ pub struct WorksheetView { } /// Internal representation of a worksheet Excel object -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Debug, Clone, Encode, Decode, Serialize, PartialEq)] pub struct Worksheet { pub dimension: String, pub cols: Vec, pub rows: Vec, pub name: String, - pub sheet_data: SheetData, + pub sheet_data: HashMap>, pub shared_formulas: Vec, pub sheet_id: u32, pub state: SheetState, @@ -124,7 +124,7 @@ pub struct Worksheet { pub type SheetData = HashMap>; // ECMA-376-1:2016 section 18.3.1.73 -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Debug, Clone, Encode, Decode, Serialize, PartialEq)] pub struct Row { /// Row index pub r: i32, @@ -136,7 +136,7 @@ pub struct Row { } // ECMA-376-1:2016 section 18.3.1.13 -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Debug, Clone, Encode, Decode, Serialize, PartialEq)] pub struct Col { // Column definitions are defined on ranges, unlike rows which store unique, per-row entries. /// First column affected by this record. Settings apply to column in \[min, max\] range. @@ -159,7 +159,7 @@ pub enum CellType { CompoundData = 128, } -#[derive(Encode, Decode, Debug, Clone, PartialEq)] +#[derive(Encode, Decode, Debug, Clone, PartialEq, Serialize)] pub enum Cell { EmptyCell { s: i32, @@ -226,7 +226,7 @@ impl Default for Cell { } } -#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Serialize)] pub struct Comment { pub text: String, pub author_name: String, diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 0b4b172d1..c355c9da8 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -1868,7 +1868,6 @@ impl UserModel { self.evaluate_if_not_paused(); Ok(()) } - // **** Private methods ****** // pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) { @@ -2372,6 +2371,16 @@ impl UserModel { } Ok(()) } + + /// Returns the current send queue as a vector of QueueDiffs without removing the diffs. + /// + /// This is used to inspect recent changes without affecting the queue. + /// + /// See also: + /// * [UserModel::flush_send_queue] + pub fn get_recent_diffs(&self) -> Vec { + self.send_queue.clone() + } } #[cfg(test)] @@ -2412,3 +2421,4 @@ mod tests { } } } + diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index 53268e12d..7022acfe5 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -1,23 +1,30 @@ use std::collections::HashMap; use bitcode::{Decode, Encode}; +use serde::Serialize; use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet}; -#[derive(Clone, Encode, Decode)] -pub(crate) struct RowData { +#[derive(Clone, Encode, Decode, Serialize)] +pub struct RowData { pub(crate) row: Option, pub(crate) data: HashMap, } -#[derive(Clone, Encode, Decode)] -pub(crate) struct ColumnData { +#[derive(Clone, Encode, Decode, Serialize)] +pub struct ColumnData { pub(crate) column: Option, pub(crate) data: HashMap, } -#[derive(Clone, Encode, Decode)] -pub(crate) enum Diff { +#[derive(Clone, Encode, Decode, Serialize)] +pub enum DiffType { + Undo, + Redo, +} + +#[derive(Clone, Encode, Decode, Serialize)] +pub enum Diff { // Cell diffs SetCellValue { sheet: u32, @@ -199,13 +206,7 @@ impl History { } } -#[derive(Clone, Encode, Decode)] -pub enum DiffType { - Undo, - Redo, -} - -#[derive(Clone, Encode, Decode)] +#[derive(Clone, Encode, Decode, Serialize)] pub struct QueueDiffs { pub r#type: DiffType, pub list: DiffList, diff --git a/bindings/nodejs/src/user_model.rs b/bindings/nodejs/src/user_model.rs index b1ebcb9fe..67c7ac0c1 100644 --- a/bindings/nodejs/src/user_model.rs +++ b/bindings/nodejs/src/user_model.rs @@ -651,4 +651,9 @@ impl UserModel { .delete_defined_name(&name, scope) .map_err(|e| to_js_error(e.to_string())) } + + #[napi(js_name = "getRecentDiffs")] + pub fn get_recent_diffs(&self) -> Vec { + self.model.get_recent_diffs() + } } diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index fd466ee33..846036214 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -201,6 +201,20 @@ getDefinedNameList(): DefinedName[]; """ +get_recent_diffs = r""" +/** +* @returns {any} +*/ + getRecentDiffs(): any; +""" + +get_recent_diffs_types = r""" +/** +* @returns {QueueDiffs[]} +*/ + getRecentDiffs(): QueueDiffs[]; +""" + def fix_types(text): text = text.replace(get_tokens_str, get_tokens_str_types) text = text.replace(update_style_str, update_style_str_types) @@ -215,6 +229,7 @@ def fix_types(text): text = text.replace(clipboard, clipboard_types) text = text.replace(paste_from_clipboard, paste_from_clipboard_types) text = text.replace(defined_name_list, defined_name_list_types) + text = text.replace(get_recent_diffs, get_recent_diffs_types) with open("types.ts") as f: types_str = f.read() header_types = "{}\n\n{}".format(header, types_str) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2edf..3efef403d 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -672,4 +672,9 @@ impl Model { .delete_defined_name(name, scope) .map_err(|e| to_js_error(e.to_string())) } + + #[wasm_bindgen(js_name = "getRecentDiffs")] + pub fn get_recent_diffs(&self) -> JsValue { + serde_wasm_bindgen::to_value(&self.model.get_recent_diffs()).unwrap_or_else(|_| JsValue::undefined()) + } } diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 374669966..5dc9b4ebb 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -130,5 +130,111 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); +test('getRecentDiffs returns recent diffs without modifying queue', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Perform some actions to generate diffs + model.setUserInput(0, 1, 1, "42"); + model.setUserInput(0, 1, 2, "=A1*2"); + + // Get recent diffs + const diffs = model.getRecentDiffs(); + assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after actions'); + + // Check structure of diffs - regular operations are marked as "Redo" type + const firstDiff = diffs[0]; + assert.strictEqual(firstDiff.type, 'Redo', 'Regular operations should be of type Redo'); + assert.strictEqual(Array.isArray(firstDiff.list), true, 'Diff entry should have a list of diffs'); + assert.strictEqual(firstDiff.list.length > 0, true, 'Diff list should not be empty'); + + // Look for SetCellValue diff in any of the diff entries + let foundSetCellValue = false; + for (const diffEntry of diffs) { + const setCellDiff = diffEntry.list.find(d => d.SetCellValue && d.SetCellValue.row === 1 && d.SetCellValue.column === 1); + if (setCellDiff) { + assert.strictEqual(setCellDiff.SetCellValue.new_value, '42', 'New value for A1 should be 42'); + foundSetCellValue = true; + break; + } + } + assert.ok(foundSetCellValue, 'Should find a SetCellValue diff for cell A1 somewhere in the diffs'); + + // Verify queue is not modified by checking again + const diffsAgain = model.getRecentDiffs(); + assert.strictEqual(diffsAgain.length, diffs.length, 'Queue length should remain the same after multiple calls'); + assert.deepStrictEqual(diffsAgain, diffs, 'Queue contents should remain unchanged after multiple calls'); +}); + +test('getRecentDiffs captures style changes', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Perform a style change + model.updateRangeStyle({ sheet: 0, row: 1, column: 1, width: 1, height: 1 }, 'font.b', 'true'); + + // Get recent diffs + const diffs = model.getRecentDiffs(); + assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after style change'); + + // Look for SetCellStyle diff in any of the diff entries + let foundStyleDiff = false; + for (const diffEntry of diffs) { + const styleDiff = diffEntry.list.find(d => d.SetCellStyle); + if (styleDiff) { + assert.strictEqual(styleDiff.SetCellStyle.sheet, 0, 'Sheet index should be 0'); + assert.strictEqual(styleDiff.SetCellStyle.row, 1, 'Row should be 1'); + assert.strictEqual(styleDiff.SetCellStyle.column, 1, 'Column should be 1'); + assert.ok(styleDiff.SetCellStyle.new_value.font.b, 'New style should have bold set to true'); + foundStyleDiff = true; + break; + } + } + assert.ok(foundStyleDiff, 'Should find a SetCellStyle diff after style update'); +}); + +test('getRecentDiffs captures undo and redo diffs', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Perform an action and undo it + model.setUserInput(0, 1, 1, "100"); + model.undo(); + + // Get recent diffs + const diffs = model.getRecentDiffs(); + assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after undo'); + + // Check for Undo type in diffs + const undoDiff = diffs.find(d => d.type === 'Undo'); + assert.ok(undoDiff, 'Should find an Undo diff entry after undo operation'); + assert.strictEqual(undoDiff.list.length > 0, true, 'Undo diff list should not be empty'); + + // Redo the action + model.redo(); + const diffsAfterRedo = model.getRecentDiffs(); + const redoDiff = diffsAfterRedo.find(d => d.type === 'Redo'); + assert.ok(redoDiff, 'Should find a Redo diff entry after redo operation'); +}); + +test('getRecentDiffs captures setCellValue diff', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + // Set a cell value to generate a SetCellValue diff + model.setUserInput(0, 2, 3, "99"); + + // Get recent diffs + const diffs = model.getRecentDiffs(); + assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after setting cell value'); + + // Look for SetCellValue diff in any of the diff entries + let foundSetCellDiff = false; + for (const diffEntry of diffs) { + const setCellDiff = diffEntry.list.find(d => d.SetCellValue); + if (setCellDiff) { + assert.strictEqual(setCellDiff.SetCellValue.sheet, 0, 'Sheet index should be 0'); + assert.strictEqual(setCellDiff.SetCellValue.row, 2, 'Row should be 2'); + assert.strictEqual(setCellDiff.SetCellValue.column, 3, 'Column should be 3'); + assert.strictEqual(setCellDiff.SetCellValue.new_value, '99', 'New value should be 99'); + foundSetCellDiff = true; + break; + } + } + assert.ok(foundSetCellDiff, 'Should find a SetCellValue diff after setting cell value'); +}); + diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 7af55b8c3..18e9f8560 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -233,4 +233,282 @@ export interface DefinedName { name: string; scope?: number; formula: string; +} + +// Types for Diffs and QueueDiffs used in getRecentDiffs +export interface Cell { + type: string; // e.g., "NumberCell", "SharedString", "BooleanCell" + v?: number | boolean | string; // value, if applicable + s: number; // style index + f?: number; // formula index, if applicable + ei?: string; // error type, if applicable + o?: string; // origin, if applicable + m?: string; // error message, if applicable +} + +export interface Style { + read_only?: boolean; + quote_prefix: boolean; + fill: { + pattern_type: string; + fg_color?: string; + bg_color?: string; + }; + font: { + u: boolean; + b: boolean; + i: boolean; + strike: boolean; + sz: number; + color?: string; + name: string; + family: number; + scheme: string; + }; + border: { + diagonal_up?: boolean; + diagonal_down?: boolean; + left?: { style: string; color?: string }; + right?: { style: string; color?: string }; + top?: { style: string; color?: string }; + bottom?: { style: string; color?: string }; + diagonal?: { style: string; color?: string }; + }; + num_fmt: string; + alignment?: { + horizontal: HorizontalAlignment; + vertical: VerticalAlignment; + wrap_text: boolean; + }; +} + +export interface RowData { + row?: { r: number; height: number; custom_format: boolean; custom_height: boolean; s: number; hidden: boolean }; + data: Record; +} + +export interface ColumnData { + column?: { min: number; max: number; width: number; custom_width: boolean; style?: number }; + data: Record; +} + +export interface Worksheet { + dimension: string; + cols: Array<{ min: number; max: number; width: number; custom_width: boolean; style?: number }>; + rows: Array<{ r: number; height: number; custom_format: boolean; custom_height: boolean; s: number; hidden: boolean }>; + name: string; + sheet_data: Record>; + shared_formulas: string[]; + sheet_id: number; + state: string; // e.g., "Visible", "Hidden", "VeryHidden" + color?: string; + merge_cells: string[]; + comments: Array<{ text: string; author_name: string; author_id?: string; cell_ref: string }>; + frozen_rows: number; + frozen_columns: number; + views: Record; + show_grid_lines: boolean; +} + +// Individual Diff type interfaces for better developer experience +export interface SetCellValueDiff { + sheet: number; + row: number; + column: number; + new_value: string; + old_value?: Cell | null; +} + +export interface CellClearContentsDiff { + sheet: number; + row: number; + column: number; + old_value?: Cell | null; +} + +export interface CellClearAllDiff { + sheet: number; + row: number; + column: number; + old_value?: Cell | null; + old_style: Style; +} + +export interface CellClearFormattingDiff { + sheet: number; + row: number; + column: number; + old_style?: Style | null; +} + +export interface SetCellStyleDiff { + sheet: number; + row: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetColumnWidthDiff { + sheet: number; + column: number; + new_value: number; + old_value: number; +} + +export interface SetRowHeightDiff { + sheet: number; + row: number; + new_value: number; + old_value: number; +} + +export interface SetColumnStyleDiff { + sheet: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetRowStyleDiff { + sheet: number; + row: number; + old_value?: Style | null; + new_value: Style; +} + +export interface DeleteColumnStyleDiff { + sheet: number; + column: number; + old_value?: Style | null; +} + +export interface DeleteRowStyleDiff { + sheet: number; + row: number; + old_value?: Style | null; +} + +export interface InsertRowDiff { + sheet: number; + row: number; +} + +export interface DeleteRowDiff { + sheet: number; + row: number; + old_data: RowData; +} + +export interface InsertColumnDiff { + sheet: number; + column: number; +} + +export interface DeleteColumnDiff { + sheet: number; + column: number; + old_data: ColumnData; +} + +export interface DeleteSheetDiff { + sheet: number; + old_data: Worksheet; +} + +export interface SetFrozenRowsCountDiff { + sheet: number; + new_value: number; + old_value: number; +} + +export interface SetFrozenColumnsCountDiff { + sheet: number; + new_value: number; + old_value: number; +} + +export interface NewSheetDiff { + index: number; + name: string; +} + +export interface RenameSheetDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetSheetColorDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetSheetStateDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetShowGridLinesDiff { + sheet: number; + old_value: boolean; + new_value: boolean; +} + +export interface CreateDefinedNameDiff { + name: string; + scope?: number; + value: string; +} + +export interface DeleteDefinedNameDiff { + name: string; + scope?: number; + old_value: string; +} + +export interface UpdateDefinedNameDiff { + name: string; + scope?: number; + old_formula: string; + new_name: string; + new_scope?: number; + new_formula: string; +} + +// Union type for all Diff variants - these are serialized as tagged enums with variant names as keys +export type Diff = + | { SetCellValue: SetCellValueDiff } + | { CellClearContents: CellClearContentsDiff } + | { CellClearAll: CellClearAllDiff } + | { CellClearFormatting: CellClearFormattingDiff } + | { SetCellStyle: SetCellStyleDiff } + | { SetColumnWidth: SetColumnWidthDiff } + | { SetRowHeight: SetRowHeightDiff } + | { SetColumnStyle: SetColumnStyleDiff } + | { SetRowStyle: SetRowStyleDiff } + | { DeleteColumnStyle: DeleteColumnStyleDiff } + | { DeleteRowStyle: DeleteRowStyleDiff } + | { InsertRow: InsertRowDiff } + | { DeleteRow: DeleteRowDiff } + | { InsertColumn: InsertColumnDiff } + | { DeleteColumn: DeleteColumnDiff } + | { DeleteSheet: DeleteSheetDiff } + | { SetFrozenRowsCount: SetFrozenRowsCountDiff } + | { SetFrozenColumnsCount: SetFrozenColumnsCountDiff } + | { NewSheet: NewSheetDiff } + | { RenameSheet: RenameSheetDiff } + | { SetSheetColor: SetSheetColorDiff } + | { SetSheetState: SetSheetStateDiff } + | { SetShowGridLines: SetShowGridLinesDiff } + | { CreateDefinedName: CreateDefinedNameDiff } + | { DeleteDefinedName: DeleteDefinedNameDiff } + | { UpdateDefinedName: UpdateDefinedNameDiff }; + +// Interface for QueueDiffs +export interface QueueDiffs { + type: "Undo" | "Redo"; + list: Diff[]; } \ No newline at end of file From 8eca4c3346a7d5393c6064297ccd03a02f286cc9 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 9 Jun 2025 00:54:15 -0500 Subject: [PATCH 09/25] fix cargo build with QueueDiffs and DiffType exports --- base/src/lib.rs | 2 ++ base/src/user_model/history.rs | 7 +++++++ base/src/user_model/mod.rs | 2 ++ bindings/nodejs/src/user_model.rs | 7 +++++-- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/base/src/lib.rs b/base/src/lib.rs index 08fc1072f..3785acf07 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -62,3 +62,5 @@ pub use model::Model; pub use user_model::BorderArea; pub use user_model::ClipboardData; pub use user_model::UserModel; +pub use user_model::QueueDiffs; +pub use user_model::DiffType; diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index 7022acfe5..4cffb672e 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -17,9 +17,12 @@ pub struct ColumnData { pub(crate) data: HashMap, } +/// Represents the type of a diff operation #[derive(Clone, Encode, Decode, Serialize)] pub enum DiffType { + /// An undo operation Undo, + /// A redo operation Redo, } @@ -206,8 +209,12 @@ impl History { } } +/// A collection of diffs that can be applied to a model. +/// This represents a single operation that can be undone or redone. #[derive(Clone, Encode, Decode, Serialize)] pub struct QueueDiffs { + /// The type of operation this represents (Undo or Redo) pub r#type: DiffType, + /// The list of individual diffs that make up this operation pub list: DiffList, } diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index 06cd85c48..a1abbaa50 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -13,3 +13,5 @@ pub use ui::SelectedView; pub use common::BorderArea; pub use common::ClipboardData; +pub use history::QueueDiffs; +pub use history::DiffType; diff --git a/bindings/nodejs/src/user_model.rs b/bindings/nodejs/src/user_model.rs index 67c7ac0c1..9d8f63829 100644 --- a/bindings/nodejs/src/user_model.rs +++ b/bindings/nodejs/src/user_model.rs @@ -653,7 +653,10 @@ impl UserModel { } #[napi(js_name = "getRecentDiffs")] - pub fn get_recent_diffs(&self) -> Vec { - self.model.get_recent_diffs() + pub fn get_recent_diffs(&self, env: Env) -> Result { + let diffs = self.model.get_recent_diffs(); + env + .to_js_value(&diffs) + .map_err(|e| to_js_error(e.to_string())) } } From 3590e7ff24054748c56b811c40264f069c9656c1 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 9 Jun 2025 00:59:07 -0500 Subject: [PATCH 10/25] run cargo format --- base/src/lib.rs | 4 ++-- base/src/user_model/common.rs | 1 - base/src/user_model/mod.rs | 2 +- bindings/wasm/src/lib.rs | 3 ++- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/base/src/lib.rs b/base/src/lib.rs index 3785acf07..f441d56fe 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -61,6 +61,6 @@ pub use model::get_milliseconds_since_epoch; pub use model::Model; pub use user_model::BorderArea; pub use user_model::ClipboardData; -pub use user_model::UserModel; -pub use user_model::QueueDiffs; pub use user_model::DiffType; +pub use user_model::QueueDiffs; +pub use user_model::UserModel; diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index c355c9da8..f45944053 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -2421,4 +2421,3 @@ mod tests { } } } - diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index a1abbaa50..01584d761 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -13,5 +13,5 @@ pub use ui::SelectedView; pub use common::BorderArea; pub use common::ClipboardData; -pub use history::QueueDiffs; pub use history::DiffType; +pub use history::QueueDiffs; diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 3efef403d..96cfa270e 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -675,6 +675,7 @@ impl Model { #[wasm_bindgen(js_name = "getRecentDiffs")] pub fn get_recent_diffs(&self) -> JsValue { - serde_wasm_bindgen::to_value(&self.model.get_recent_diffs()).unwrap_or_else(|_| JsValue::undefined()) + serde_wasm_bindgen::to_value(&self.model.get_recent_diffs()) + .unwrap_or_else(|_| JsValue::undefined()) } } From 3ccfc5ec5a2291d57aca532ffed3b2146b5f62bd Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 9 Jun 2025 01:17:43 -0500 Subject: [PATCH 11/25] fix fix_types --- bindings/wasm/fix_types.py | 3 +++ bindings/wasm/types.ts | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index 846036214..92f8366ef 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -236,6 +236,9 @@ def fix_types(text): text = text.replace(header, header_types) if text.find("any") != -1: print("There are 'unfixed' types. Please check.") + for i, line in enumerate(text.splitlines()): + if 'any' in line: + print(f"Line {i+1}: {line}") exit(1) return text diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 18e9f8560..c9b21b65a 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -209,6 +209,14 @@ export interface SelectedView { left_column: number; } +export interface WorksheetView { + row: number; + column: number; + range: [number, number, number, number]; + top_row: number; + left_column: number; +} + // type ClipboardData = { // [row: number]: { // [column: number]: ClipboardCell; @@ -306,7 +314,7 @@ export interface Worksheet { comments: Array<{ text: string; author_name: string; author_id?: string; cell_ref: string }>; frozen_rows: number; frozen_columns: number; - views: Record; + views: Record; show_grid_lines: boolean; } @@ -337,7 +345,7 @@ export interface CellClearAllDiff { export interface CellClearFormattingDiff { sheet: number; row: number; - column: number; + column: number old_style?: Style | null; } From acf47ebe6661d7103128f861ec962ba2efc779a5 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 9 Jun 2025 15:25:46 -0700 Subject: [PATCH 12/25] feat: return sheet name and index from newSheet method --- base/src/user_model/common.rs | 6 +++--- bindings/nodejs/src/user_model.rs | 2 +- bindings/wasm/fix_types.py | 18 ++++++++++++++++++ bindings/wasm/src/lib.rs | 17 +++++++++++++++-- bindings/wasm/tests/test.mjs | 25 +++++++++++++++++++++++++ bindings/wasm/types.ts | 5 +++++ 6 files changed, 67 insertions(+), 6 deletions(-) diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 0b4b172d1..048dbdbfe 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -487,11 +487,11 @@ impl UserModel { /// /// See also: /// * [Model::new_sheet] - pub fn new_sheet(&mut self) -> Result<(), String> { + pub fn new_sheet(&mut self) -> Result<(String, u32), String> { let (name, index) = self.model.new_sheet(); self.set_selected_sheet(index)?; - self.push_diff_list(vec![Diff::NewSheet { index, name }]); - Ok(()) + self.push_diff_list(vec![Diff::NewSheet { index, name: name.clone() }]); + Ok((name, index)) } /// Deletes sheet by index diff --git a/bindings/nodejs/src/user_model.rs b/bindings/nodejs/src/user_model.rs index b1ebcb9fe..579f521ce 100644 --- a/bindings/nodejs/src/user_model.rs +++ b/bindings/nodejs/src/user_model.rs @@ -92,7 +92,7 @@ impl UserModel { #[napi(js_name = "newSheet")] pub fn new_sheet(&mut self) -> Result<()> { - self.model.new_sheet().map_err(to_js_error) + self.model.new_sheet().map(|_| ()).map_err(to_js_error) } #[napi(js_name = "deleteSheet")] diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index fd466ee33..cf15a325f 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -201,6 +201,20 @@ getDefinedNameList(): DefinedName[]; """ +new_sheet = r""" +/** +* @returns {any} +*/ + newSheet(): any; +""" + +new_sheet_types = r""" +/** +* @returns {NewSheetResult} +*/ + newSheet(): NewSheetResult; +""" + def fix_types(text): text = text.replace(get_tokens_str, get_tokens_str_types) text = text.replace(update_style_str, update_style_str_types) @@ -215,12 +229,16 @@ def fix_types(text): text = text.replace(clipboard, clipboard_types) text = text.replace(paste_from_clipboard, paste_from_clipboard_types) text = text.replace(defined_name_list, defined_name_list_types) + text = text.replace(new_sheet, new_sheet_types) with open("types.ts") as f: types_str = f.read() header_types = "{}\n\n{}".format(header, types_str) text = text.replace(header, header_types) if text.find("any") != -1: print("There are 'unfixed' types. Please check.") + for line in text.split("\n"): + if "any" in line: + print(line) exit(1) return text diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2edf..4f0a23ed5 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -37,6 +37,12 @@ struct DefinedName { formula: String, } +#[derive(Serialize)] +struct NewSheetResult { + name: String, + sheet_index: u32, +} + #[wasm_bindgen] pub struct Model { model: BaseModel, @@ -105,8 +111,15 @@ impl Model { } #[wasm_bindgen(js_name = "newSheet")] - pub fn new_sheet(&mut self) -> Result<(), JsError> { - self.model.new_sheet().map_err(to_js_error) + pub fn new_sheet(&mut self) -> Result { + let (name, sheet_index) = self.model.new_sheet().map_err(to_js_error)?; + + let result = NewSheetResult { + name, + sheet_index, + }; + + serde_wasm_bindgen::to_value(&result).map_err(|e| to_js_error(e.to_string())) } #[wasm_bindgen(js_name = "deleteSheet")] diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 374669966..b3bc13ed0 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -93,6 +93,31 @@ test("Add sheets", (t) => { ]); }); +test("newSheet returns sheet result", (t) => { + const model = new Model('Workbook1', 'en', 'UTC'); + + // Test first new sheet - should be at index 1 + const result1 = model.newSheet(); + console.log(result1); + assert.strictEqual(result1.name, "Sheet2"); + assert.strictEqual(result1.sheet_index, 1); // This is the sheet index (position) + + // Test second new sheet - should be at index 2 + const result2 = model.newSheet(); + assert.strictEqual(result2.name, "Sheet3"); + assert.strictEqual(result2.sheet_index, 2); // This is the sheet index (position) + + // Verify we can use the returned index with other API methods + model.renameSheet(result1.sheet_index, "FirstNewSheet"); + model.renameSheet(result2.sheet_index, "SecondNewSheet"); + + // Verify the sheets actually exist and were renamed + const props = model.getWorksheetsProperties(); + assert.strictEqual(props.length, 3); + assert.strictEqual(props[1].name, "FirstNewSheet"); + assert.strictEqual(props[2].name, "SecondNewSheet"); +}); + test("invalid sheet index throws an exception", () => { const model = new Model('Workbook1', 'en', 'UTC'); assert.throws(() => { diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 7af55b8c3..9b6f61aa1 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -116,6 +116,11 @@ export interface WorksheetProperties { state: string; } +export interface NewSheetResult { + name: string; + sheet_index: number; +} + interface CellStyleFill { pattern_type: string; fg_color?: string; From 8ec3703e876c1929b998e38683b43a179c9a8e4b Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 9 Jun 2025 15:53:44 -0700 Subject: [PATCH 13/25] cargo fmt --- base/src/user_model/common.rs | 5 ++++- bindings/wasm/src/lib.rs | 9 +++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 048dbdbfe..c4f8f093d 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -490,7 +490,10 @@ impl UserModel { pub fn new_sheet(&mut self) -> Result<(String, u32), String> { let (name, index) = self.model.new_sheet(); self.set_selected_sheet(index)?; - self.push_diff_list(vec![Diff::NewSheet { index, name: name.clone() }]); + self.push_diff_list(vec![Diff::NewSheet { + index, + name: name.clone(), + }]); Ok((name, index)) } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 4f0a23ed5..d76d9cb2b 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -113,12 +113,9 @@ impl Model { #[wasm_bindgen(js_name = "newSheet")] pub fn new_sheet(&mut self) -> Result { let (name, sheet_index) = self.model.new_sheet().map_err(to_js_error)?; - - let result = NewSheetResult { - name, - sheet_index, - }; - + + let result = NewSheetResult { name, sheet_index }; + serde_wasm_bindgen::to_value(&result).map_err(|e| to_js_error(e.to_string())) } From 22aedf9212dc7d25e92c11976defa9b39d5fd511 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 9 Jun 2025 16:38:34 -0700 Subject: [PATCH 14/25] feat: add getSheetDimensions method to WASM bindings --- base/src/worksheet.rs | 2 +- bindings/wasm/fix_types.py | 17 +++++++++++++++++ bindings/wasm/src/lib.rs | 13 +++++++++++++ bindings/wasm/tests/test.mjs | 33 +++++++++++++++++++++++++++++++++ bindings/wasm/types.ts | 7 +++++++ 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/base/src/worksheet.rs b/base/src/worksheet.rs index bf9089954..fa9c3cd66 100644 --- a/base/src/worksheet.rs +++ b/base/src/worksheet.rs @@ -5,7 +5,7 @@ use crate::{expressions::token::Error, types::*}; use std::collections::HashMap; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct WorksheetDimension { pub min_row: i32, pub max_row: i32, diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index fd466ee33..3aa237efe 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -201,6 +201,22 @@ getDefinedNameList(): DefinedName[]; """ +get_sheet_dimensions = r""" +/** +* @param {number} sheet +* @returns {any} +*/ + getSheetDimensions(sheet: number): any; +""" + +get_sheet_dimensions_types = r""" +/** +* @param {number} sheet +* @returns {WorksheetDimension} +*/ + getSheetDimensions(sheet: number): WorksheetDimension; +""" + def fix_types(text): text = text.replace(get_tokens_str, get_tokens_str_types) text = text.replace(update_style_str, update_style_str_types) @@ -215,6 +231,7 @@ def fix_types(text): text = text.replace(clipboard, clipboard_types) text = text.replace(paste_from_clipboard, paste_from_clipboard_types) text = text.replace(defined_name_list, defined_name_list_types) + text = text.replace(get_sheet_dimensions, get_sheet_dimensions_types) with open("types.ts") as f: types_str = f.read() header_types = "{}\n\n{}".format(header, types_str) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2edf..3110689a9 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -335,6 +335,19 @@ impl Model { .unwrap_or_default()) } + #[wasm_bindgen(js_name = "getSheetDimensions")] + pub fn get_sheet_dimensions(&self, sheet: u32) -> Result { + let dimension = self + .model + .get_model() + .workbook + .worksheet(sheet) + .map_err(to_js_error)? + .dimension(); + + serde_wasm_bindgen::to_value(&dimension).map_err(|e| to_js_error(e.to_string())) + } + #[wasm_bindgen(js_name = "updateRangeStyle")] pub fn update_range_style( &mut self, diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 374669966..62d513dbe 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -130,5 +130,38 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); +test("getSheetDimensions", () => { + const model = new Model('Workbook1', 'en', 'UTC'); + + // Test empty sheet - should return default dimensions + let dimensions = model.getSheetDimensions(0); + assert.deepEqual(dimensions, { + min_row: 1, + max_row: 1, + min_column: 1, + max_column: 1 + }); + + // Add a single cell at A1 + model.setUserInput(0, 1, 1, "Hello"); + dimensions = model.getSheetDimensions(0); + assert.deepEqual(dimensions, { + min_row: 1, + max_row: 1, + min_column: 1, + max_column: 1 + }); + + // Add another cell to expand the range + model.setUserInput(0, 5, 8, "World"); + dimensions = model.getSheetDimensions(0); + assert.deepEqual(dimensions, { + min_row: 1, + max_row: 5, + min_column: 1, + max_column: 8 + }); +}); + diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 7af55b8c3..ae18cc24f 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -233,4 +233,11 @@ export interface DefinedName { name: string; scope?: number; formula: string; +} + +export interface WorksheetDimension { + min_row: number; + max_row: number; + min_column: number; + max_column: number; } \ No newline at end of file From 722141b250bc39a44d6c17d0aceb2e3d03d16cba Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 10 Jun 2025 16:12:12 -0700 Subject: [PATCH 15/25] feat: add event emitter for real-time diff notifications --- Cargo.lock | 1 + base/src/lib.rs | 6 +- base/src/model.rs | 6 +- base/src/types.rs | 14 +- base/src/user_model/common.rs | 33 +++ base/src/user_model/event.rs | 117 ++++++++++ base/src/user_model/history.rs | 22 +- base/src/user_model/mod.rs | 3 + bindings/wasm/Cargo.toml | 1 + bindings/wasm/README.pkg.md | 17 ++ bindings/wasm/src/lib.rs | 65 +++++- bindings/wasm/tests/test.mjs | 381 +++++++++++++++++++++++++++++++++ bindings/wasm/types.ts | 273 ++++++++++++++++++++++- 13 files changed, 914 insertions(+), 25 deletions(-) create mode 100644 base/src/user_model/event.rs diff --git a/Cargo.lock b/Cargo.lock index cbcdd9932..05b011218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,6 +1073,7 @@ name = "wasm" version = "0.5.0" dependencies = [ "ironcalc_base", + "js-sys", "serde", "serde-wasm-bindgen", "wasm-bindgen", diff --git a/base/src/lib.rs b/base/src/lib.rs index 08fc1072f..9ca28fcea 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -59,6 +59,6 @@ pub mod mock_time; pub use model::get_milliseconds_since_epoch; pub use model::Model; -pub use user_model::BorderArea; -pub use user_model::ClipboardData; -pub use user_model::UserModel; +pub use user_model::{ + BorderArea, ClipboardData, Diff, EventEmitter, Subscription, UserModel, +}; diff --git a/base/src/model.rs b/base/src/model.rs index f2879cb3a..7ceb5363f 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -106,15 +106,15 @@ pub struct Model { pub(crate) shared_strings: HashMap, /// An instance of the parser pub(crate) parser: Parser, - /// The list of cells with formulas that are evaluated or being evaluated + /// The list of cells with formulas that are evaluated of being evaluated pub(crate) cells: HashMap<(u32, i32, i32), CellState>, /// The locale of the model pub(crate) locale: Locale, - /// The language used + /// Tha language used pub(crate) language: Language, /// The timezone used to evaluate the model pub(crate) tz: Tz, - /// The view id. A view consists of a selected sheet and ranges. + /// The view id. A view consist of a selected sheet and ranges. pub(crate) view_id: u32, } diff --git a/base/src/types.rs b/base/src/types.rs index 07b41bc87..90f9600fd 100644 --- a/base/src/types.rs +++ b/base/src/types.rs @@ -64,7 +64,7 @@ pub struct DefinedName { /// * state: /// 18.18.68 ST_SheetState (Sheet Visibility Types) /// hidden, veryHidden, visible -#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Serialize)] pub enum SheetState { Visible, Hidden, @@ -84,7 +84,7 @@ impl Display for SheetState { /// Represents the state of the worksheet as seen by the user. This includes /// details such as the currently selected cell, the visible range, and the /// position of the viewport. -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct WorksheetView { /// The row index of the currently selected cell. pub row: i32, @@ -99,7 +99,7 @@ pub struct WorksheetView { } /// Internal representation of a worksheet Excel object -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct Worksheet { pub dimension: String, pub cols: Vec, @@ -124,7 +124,7 @@ pub struct Worksheet { pub type SheetData = HashMap>; // ECMA-376-1:2016 section 18.3.1.73 -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct Row { /// Row index pub r: i32, @@ -136,7 +136,7 @@ pub struct Row { } // ECMA-376-1:2016 section 18.3.1.13 -#[derive(Encode, Decode, Debug, PartialEq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Clone, Serialize)] pub struct Col { // Column definitions are defined on ranges, unlike rows which store unique, per-row entries. /// First column affected by this record. Settings apply to column in \[min, max\] range. @@ -159,7 +159,7 @@ pub enum CellType { CompoundData = 128, } -#[derive(Encode, Decode, Debug, Clone, PartialEq)] +#[derive(Encode, Decode, Debug, Clone, PartialEq, Serialize)] pub enum Cell { EmptyCell { s: i32, @@ -226,7 +226,7 @@ impl Default for Cell { } } -#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)] +#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Serialize)] pub struct Comment { pub text: String, pub author_name: String, diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 0b4b172d1..224c4f38a 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -24,6 +24,7 @@ use crate::user_model::history::{ }; use super::border_utils::is_max_border; +use super::event::{EventEmitter, Subscription}; /// Data for the clipboard pub type ClipboardData = HashMap>; @@ -223,6 +224,7 @@ pub struct UserModel { history: History, send_queue: Vec, pause_evaluation: bool, + event_emitter: EventEmitter, } impl Debug for UserModel { @@ -239,6 +241,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), } } @@ -253,6 +256,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), }) } @@ -267,6 +271,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), }) } @@ -351,6 +356,26 @@ impl UserModel { self.pause_evaluation = false; } + /// Subscribes to diff events. + /// Returns a Subscription handle that automatically unsubscribes when dropped. + #[cfg(target_arch = "wasm32")] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&Diff) + 'static, + { + self.event_emitter.subscribe(listener) + } + + /// Subscribes to diff events. + /// Returns a Subscription handle that automatically unsubscribes when dropped. + #[cfg(not(target_arch = "wasm32"))] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&Diff) + Send + Sync + 'static, + { + self.event_emitter.subscribe(listener) + } + /// Forces an evaluation of the model /// /// See also: @@ -1872,6 +1897,11 @@ impl UserModel { // **** Private methods ****** // pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) { + // Emit events for each diff before storing them + for diff in &diff_list { + self.event_emitter.emit(diff); + } + self.send_queue.push(QueueDiffs { r#type: DiffType::Redo, list: diff_list.clone(), @@ -1888,6 +1918,7 @@ impl UserModel { fn apply_undo_diff_list(&mut self, diff_list: &DiffList) -> Result<(), String> { let mut needs_evaluation = false; for diff in diff_list.iter().rev() { + self.event_emitter.emit(diff); match diff { Diff::SetCellValue { sheet, @@ -2175,6 +2206,7 @@ impl UserModel { fn apply_diff_list(&mut self, diff_list: &DiffList) -> Result<(), String> { let mut needs_evaluation = false; for diff in diff_list { + self.event_emitter.emit(diff); match diff { Diff::SetCellValue { sheet, @@ -2412,3 +2444,4 @@ mod tests { } } } + diff --git a/base/src/user_model/event.rs b/base/src/user_model/event.rs new file mode 100644 index 000000000..16e165fd3 --- /dev/null +++ b/base/src/user_model/event.rs @@ -0,0 +1,117 @@ +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, RwLock, Weak, + }, +}; + +/// RAII handle: dropping it (or calling `.unsubscribe()`) removes the listener. +pub struct Subscription { + id: u32, + inner: Weak>, +} + +impl Subscription { + /// explicitly unsubscribe early + pub fn unsubscribe(self) { + if let Some(inner) = self.inner.upgrade() { + inner.listeners.write().unwrap().remove(&self.id); + } + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + inner.listeners.write().unwrap().remove(&self.id); + } + } +} + +struct Inner { + #[cfg(target_arch = "wasm32")] + listeners: RwLock>>, + #[cfg(not(target_arch = "wasm32"))] + listeners: RwLock>>, + next_id: AtomicU32, +} + +/// A tiny, generic event emitter. +#[derive(Clone)] +pub struct EventEmitter { + inner: Arc>, +} + +impl EventEmitter { + /// Create a new emitter. + pub fn new() -> Self { + Self { + inner: Arc::new(Inner { + listeners: RwLock::new(HashMap::new()), + next_id: AtomicU32::new(0), + }), + } + } + + /// Subscribe to events carrying `&T`. + /// Returns a `Subscription` which unsubscribes on drop. + #[cfg(target_arch = "wasm32")] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&T) + 'static, + { + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + self.inner + .listeners + .write() + .unwrap() + .insert(id, Box::new(listener)); + + Subscription { + id, + inner: Arc::downgrade(&self.inner), + } + } + + /// Subscribe to events carrying `&T`. + /// Returns a `Subscription` which unsubscribes on drop. + #[cfg(not(target_arch = "wasm32"))] + pub fn subscribe(&self, listener: F) -> Subscription + where + F: Fn(&T) + Send + Sync + 'static, + { + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + self.inner + .listeners + .write() + .unwrap() + .insert(id, Box::new(listener)); + + Subscription { + id, + inner: Arc::downgrade(&self.inner), + } + } + + /// Manually unsubscribe by ID. Returns `true` if removed. + pub fn unsubscribe(&self, id: u32) -> bool { + self.inner.listeners.write().unwrap().remove(&id).is_some() + } + + /// Emit a payload to all current listeners. + pub fn emit(&self, payload: &T) { + // Call listeners directly under read lock + // This is safe because we're not modifying the map during iteration + let read = self.inner.listeners.read().unwrap(); + for callback in read.values() { + callback(payload); + } + } +} + +impl Default for EventEmitter { + fn default() -> Self { + Self::new() + } +} diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index 53268e12d..4befeefba 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -1,23 +1,25 @@ use std::collections::HashMap; use bitcode::{Decode, Encode}; +use serde::Serialize; use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet}; -#[derive(Clone, Encode, Decode)] -pub(crate) struct RowData { - pub(crate) row: Option, - pub(crate) data: HashMap, +#[derive(Clone, Encode, Decode, Serialize)] +pub struct RowData { + pub row: Option, + pub data: HashMap, } -#[derive(Clone, Encode, Decode)] -pub(crate) struct ColumnData { - pub(crate) column: Option, - pub(crate) data: HashMap, +#[derive(Clone, Encode, Decode, Serialize)] +pub struct ColumnData { + pub column: Option, + pub data: HashMap, } -#[derive(Clone, Encode, Decode)] -pub(crate) enum Diff { +#[allow(missing_docs)] +#[derive(Clone, Encode, Decode, Serialize)] +pub enum Diff { // Cell diffs SetCellValue { sheet: u32, diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index 06cd85c48..c34001553 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -4,9 +4,12 @@ mod border; mod border_utils; mod common; mod history; +mod event; mod ui; pub use common::UserModel; +pub use event::{EventEmitter, Subscription}; +pub use history::Diff; #[cfg(test)] pub use ui::SelectedView; diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 231521091..665c1745c 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -18,6 +18,7 @@ ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_l serde = { version = "1.0", features = ["derive"] } wasm-bindgen = "0.2.92" serde-wasm-bindgen = "0.4" +js-sys = "0.3" [dev-dependencies] wasm-bindgen-test = "0.3.38" diff --git a/bindings/wasm/README.pkg.md b/bindings/wasm/README.pkg.md index 8e62d0ec5..79378b78d 100644 --- a/bindings/wasm/README.pkg.md +++ b/bindings/wasm/README.pkg.md @@ -31,3 +31,20 @@ function compute() { compute(); ``` + +To listen to model changes you can subscribe to diff events: + +```TypeScript +import init, { Model } from "@ironcalc/wasm"; + +await init(); + +const model = new Model("Sheet1", "en", "UTC"); + +model.onDiffs(() => { + // React to diff list updates + redraw(); +}); + +model.setUserInput(0, 1, 1, "=1+1"); +``` diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2edf..942acd5d5 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -2,18 +2,49 @@ use serde::Serialize; use wasm_bindgen::{ prelude::{wasm_bindgen, JsError}, JsValue, + JsCast, }; +use js_sys::Function; +use std::cell::RefCell; +use std::rc::Rc; use ironcalc_base::{ expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, types::{CellType, Style}, - BorderArea, ClipboardData, UserModel as BaseModel, + BorderArea, ClipboardData, UserModel as BaseModel, Subscription, }; +use ironcalc_base::Diff; + fn to_js_error(error: String) -> JsError { JsError::new(&error.to_string()) } +/// WASM wrapper for the Rust Subscription that allows JavaScript to unsubscribe +#[wasm_bindgen] +pub struct DiffSubscription { + subscription: Rc>>>, +} + +#[wasm_bindgen] +impl DiffSubscription { + /// Unsubscribe from diff events + pub fn unsubscribe(&mut self) { + if let Ok(mut sub) = self.subscription.try_borrow_mut() { + if let Some(subscription) = sub.take() { + subscription.unsubscribe(); + } + } + } +} + +impl Drop for DiffSubscription { + fn drop(&mut self) { + // Ensure unsubscription on drop as well + self.unsubscribe(); + } +} + /// Return an array with a list of all the tokens from a formula /// This is used by the UI to color them according to a theme. #[wasm_bindgen(js_name = "getTokens")] @@ -83,6 +114,38 @@ impl Model { self.model.resume_evaluation() } + #[wasm_bindgen(js_name = "onDiffs")] + pub fn on_diffs(&mut self, callback: Function) -> Function { + let subscription = self.model.subscribe(move |diff| { + match serde_wasm_bindgen::to_value(diff) { + Ok(js_diff) => { + let _ = callback.call1(&JsValue::NULL, &js_diff); + } + Err(_e) => { + // Silent skip: if serialization fails, we skip this diff event + } + } + }); + + // Store subscription in an Rc> so it can be moved into the closure + let subscription_rc = Rc::new(RefCell::new(Some(subscription))); + let subscription_clone = subscription_rc.clone(); + + // Create the unsubscribe function + let unsubscribe_fn = wasm_bindgen::closure::Closure::wrap(Box::new(move || { + if let Ok(mut sub) = subscription_clone.try_borrow_mut() { + if let Some(subscription) = sub.take() { + subscription.unsubscribe(); + } + } + }) as Box); + + let js_function = unsubscribe_fn.as_ref().unchecked_ref::().clone(); + unsubscribe_fn.forget(); // Prevent the closure from being dropped + + js_function + } + pub fn evaluate(&mut self) { self.model.evaluate(); } diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 374669966..f40190742 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert' import { Model } from "../pkg/wasm.js"; +import { setTimeout } from 'node:timers/promises'; const DEFAULT_ROW_HEIGHT = 28; @@ -131,4 +132,384 @@ test("autofill", () => { }); +test('onDiffs', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diff => { + events.push(diff); + }); + + model.setUserInput(0, 1, 1, 'test'); + await setTimeout(0); + + const expectedEvents = [ + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: 'test', + old_value: undefined + } + }, + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + +test('onDiffs emits correct diff types for various operations', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diff => { + events.push(diff); + }); + + // Test various operations that should emit different diff types + model.setUserInput(0, 1, 1, '42'); + model.insertRow(0, 2); + model.setRowsHeight(0, 1, 1, 35); + model.insertColumn(0, 2); + model.setColumnsWidth(0, 1, 1, 120); + model.newSheet(); + model.renameSheet(1, "TestSheet"); + model.setFrozenRowsCount(0, 2); + model.setFrozenColumnsCount(0, 3); + + // Allow any async operations to complete + await setTimeout(0); + + const expectedEvents = [ + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: '42', + old_value: undefined + } + }, + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + { + SetRowHeight: { + sheet: 0, + row: 1, + new_value: 35, + old_value: 28 + } + }, + { + InsertColumn: { + sheet: 0, + column: 2 + } + }, + { + SetColumnWidth: { + sheet: 0, + column: 1, + new_value: 120, + old_value: 125 + } + }, + { + NewSheet: { + index: 1, + name: 'Sheet2' + } + }, + { + RenameSheet: { + index: 1, + old_value: 'Sheet2', + new_value: 'TestSheet' + } + }, + { + SetFrozenRowsCount: { + sheet: 0, + new_value: 2, + old_value: 0 + } + }, + { + SetFrozenColumnsCount: { + sheet: 0, + new_value: 3, + old_value: 0 + } + } + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + +test('onDiffs emits full diff objects for undo/redo operations', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diff => { + events.push(diff); + }); + + // Perform initial operations + model.setUserInput(0, 1, 1, 'Hello'); + model.setUserInput(0, 1, 2, 'World'); + model.insertRow(0, 2); + + // Test undo - should emit diffs for undoing operations + model.undo(); + model.undo(); + + // Test redo - should emit diffs for redoing operations + model.redo(); + model.redo(); + + await setTimeout(0); + + const expectedEvents = [ + // Initial operations (3 events) + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: 'Hello', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 1, + column: 2, + new_value: 'World', + old_value: undefined + } + }, + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + // Undo operations (2 events) - Note: these emit the same diff structures as the forward operations + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + { + SetCellValue: { + sheet: 0, + row: 1, + column: 2, + new_value: 'World', + old_value: undefined + } + }, + // Redo operations (2 events) - These also emit the same diff structures + { + SetCellValue: { + sheet: 0, + row: 1, + column: 2, + new_value: 'World', + old_value: undefined + } + }, + { + InsertRow: { + sheet: 0, + row: 2 + } + } + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + +test('onDiffs handles multiple subscribers and provides full diff objects', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diff => { + events.push(diff); + }); + + // Perform complex operations that generate multiple diffs + model.setUserInput(0, 1, 1, '=SUM(A2:A5)'); + model.setUserInput(0, 2, 1, '10'); + model.setUserInput(0, 3, 1, '20'); + model.setUserInput(0, 4, 1, '30'); + + // Test row operations + model.insertRow(0, 2); + model.deleteRow(0, 2); + + // Test range operations + model.rangeClearContents(0, 2, 1, 3, 1); + + await setTimeout(0); + + const expectedEvents = [ + // SetUserInput operations (4 events) + { + SetCellValue: { + sheet: 0, + row: 1, + column: 1, + new_value: '=SUM(A2:A5)', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 2, + column: 1, + new_value: '10', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 3, + column: 1, + new_value: '20', + old_value: undefined + } + }, + { + SetCellValue: { + sheet: 0, + row: 4, + column: 1, + new_value: '30', + old_value: undefined + } + }, + // Row operations (2 events) + { + InsertRow: { + sheet: 0, + row: 2 + } + }, + { + DeleteRow: { + sheet: 0, + row: 2, + old_data: { + data: new Map(), + row: undefined + } + } + }, + // Range clear operations (2 events) + { + CellClearContents: { + sheet: 0, + row: 2, + column: 1, + old_value: { + NumberCell: { + v: 10, + s: 0 + } + } + } + }, + { + CellClearContents: { + sheet: 0, + row: 3, + column: 1, + old_value: { + NumberCell: { + v: 20, + s: 0 + } + } + } + } + ]; + + // Verify we got the expected number of events + assert.strictEqual(events.length, expectedEvents.length, `Should have exactly ${expectedEvents.length} diff events`); + + // Compare each event with deep equality + for (let i = 0; i < expectedEvents.length; i++) { + assert.deepStrictEqual(events[i], expectedEvents[i], `Event ${i} should match expected diff`); + } +}); + +test('onDiffs returns unregister function that works correctly', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events1 = []; + const events2 = []; + + // Register two listeners + const unregister1 = model.onDiffs(diff => { + events1.push(diff); + }); + + const unregister2 = model.onDiffs(diff => { + events2.push(diff); + }); + + // Both should be functions + assert.strictEqual(typeof unregister1, 'function', 'onDiffs should return a function'); + assert.strictEqual(typeof unregister2, 'function', 'onDiffs should return a function'); + + // Trigger some events + model.setUserInput(0, 1, 1, 'Test'); + model.setUserInput(0, 1, 2, 'Test2'); + + await setTimeout(0); + + // Both listeners should have received events + assert.strictEqual(events1.length, 2, 'First listener should receive 2 events'); + assert.strictEqual(events2.length, 2, 'Second listener should receive 2 events'); + + // Unregister first listener + unregister1(); + + // Trigger more events + model.setUserInput(0, 1, 3, 'Test3'); + + await setTimeout(0); + + assert.strictEqual(events1.length, 2, 'First listener should receive 2 events'); + assert.strictEqual(events2.length, 3, 'Second listener should receive 2 events'); + + // Call the second unregister too + unregister2(); +}); \ No newline at end of file diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 7af55b8c3..b5cef2590 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -233,4 +233,275 @@ export interface DefinedName { name: string; scope?: number; formula: string; -} \ No newline at end of file +} + +export interface Cell { + type: string; // e.g., "NumberCell", "SharedString", "BooleanCell" + v?: number | boolean | string; // value, if applicable + s: number; // style index + f?: number; // formula index, if applicable + ei?: string; // error type, if applicable + o?: string; // origin, if applicable + m?: string; // error message, if applicable +} + +export interface Style { + read_only?: boolean; + quote_prefix: boolean; + fill: { + pattern_type: string; + fg_color?: string; + bg_color?: string; + }; + font: { + u: boolean; + b: boolean; + i: boolean; + strike: boolean; + sz: number; + color?: string; + name: string; + family: number; + scheme: string; + }; + border: { + diagonal_up?: boolean; + diagonal_down?: boolean; + left?: { style: string; color?: string }; + right?: { style: string; color?: string }; + top?: { style: string; color?: string }; + bottom?: { style: string; color?: string }; + diagonal?: { style: string; color?: string }; + }; + num_fmt: string; + alignment?: { + horizontal: HorizontalAlignment; + vertical: VerticalAlignment; + wrap_text: boolean; + }; +} + +export interface RowData { + row?: { r: number; height: number; custom_format: boolean; custom_height: boolean; s: number; hidden: boolean }; + data: Record; +} + +export interface ColumnData { + column?: { min: number; max: number; width: number; custom_width: boolean; style?: number }; + data: Record; +} + +export interface Worksheet { + dimension: string; + cols: Array<{ min: number; max: number; width: number; custom_width: boolean; style?: number }>; + rows: Array<{ r: number; height: number; custom_format: boolean; custom_height: boolean; s: number; hidden: boolean }>; + name: string; + sheet_data: Record>; + shared_formulas: string[]; + sheet_id: number; + state: string; // e.g., "Visible", "Hidden", "VeryHidden" + color?: string; + merge_cells: string[]; + comments: Array<{ text: string; author_name: string; author_id?: string; cell_ref: string }>; + frozen_rows: number; + frozen_columns: number; + views: Record; + show_grid_lines: boolean; +} + +// Individual Diff type interfaces for better developer experience +export interface SetCellValueDiff { + sheet: number; + row: number; + column: number; + new_value: string; + old_value?: Cell | null; +} + +export interface CellClearContentsDiff { + sheet: number; + row: number; + column: number; + old_value?: Cell | null; +} + +export interface CellClearAllDiff { + sheet: number; + row: number; + column: number; + old_value?: Cell | null; + old_style: Style; +} + +export interface CellClearFormattingDiff { + sheet: number; + row: number; + column: number; + old_style?: Style | null; +} + +export interface SetCellStyleDiff { + sheet: number; + row: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetColumnWidthDiff { + sheet: number; + column: number; + new_value: number; + old_value: number; +} + +export interface SetRowHeightDiff { + sheet: number; + row: number; + new_value: number; + old_value: number; +} + +export interface SetColumnStyleDiff { + sheet: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetRowStyleDiff { + sheet: number; + row: number; + old_value?: Style | null; + new_value: Style; +} + +export interface DeleteColumnStyleDiff { + sheet: number; + column: number; + old_value?: Style | null; +} + +export interface DeleteRowStyleDiff { + sheet: number; + row: number; + old_value?: Style | null; +} + +export interface InsertRowDiff { + sheet: number; + row: number; +} + +export interface DeleteRowDiff { + sheet: number; + row: number; + old_data: RowData; +} + +export interface InsertColumnDiff { + sheet: number; + column: number; +} + +export interface DeleteColumnDiff { + sheet: number; + column: number; + old_data: ColumnData; +} + +export interface DeleteSheetDiff { + sheet: number; + old_data: Worksheet; +} + +export interface SetFrozenRowsCountDiff { + sheet: number; + new_value: number; + old_value: number; +} + +export interface SetFrozenColumnsCountDiff { + sheet: number; + new_value: number; + old_value: number; +} + +export interface NewSheetDiff { + index: number; + name: string; +} + +export interface RenameSheetDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetSheetColorDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetSheetStateDiff { + index: number; + old_value: string; + new_value: string; +} + +export interface SetShowGridLinesDiff { + sheet: number; + old_value: boolean; + new_value: boolean; +} + +export interface CreateDefinedNameDiff { + name: string; + scope?: number; + value: string; +} + +export interface DeleteDefinedNameDiff { + name: string; + scope?: number; + old_value: string; +} + +export interface UpdateDefinedNameDiff { + name: string; + scope?: number; + old_formula: string; + new_name: string; + new_scope?: number; + new_formula: string; +} + +// Union type for all Diff variants - these are serialized as tagged enums with variant names as keys +export type Diff = + | { SetCellValue: SetCellValueDiff } + | { CellClearContents: CellClearContentsDiff } + | { CellClearAll: CellClearAllDiff } + | { CellClearFormatting: CellClearFormattingDiff } + | { SetCellStyle: SetCellStyleDiff } + | { SetColumnWidth: SetColumnWidthDiff } + | { SetRowHeight: SetRowHeightDiff } + | { SetColumnStyle: SetColumnStyleDiff } + | { SetRowStyle: SetRowStyleDiff } + | { DeleteColumnStyle: DeleteColumnStyleDiff } + | { DeleteRowStyle: DeleteRowStyleDiff } + | { InsertRow: InsertRowDiff } + | { DeleteRow: DeleteRowDiff } + | { InsertColumn: InsertColumnDiff } + | { DeleteColumn: DeleteColumnDiff } + | { DeleteSheet: DeleteSheetDiff } + | { SetFrozenRowsCount: SetFrozenRowsCountDiff } + | { SetFrozenColumnsCount: SetFrozenColumnsCountDiff } + | { NewSheet: NewSheetDiff } + | { RenameSheet: RenameSheetDiff } + | { SetSheetColor: SetSheetColorDiff } + | { SetSheetState: SetSheetStateDiff } + | { SetShowGridLines: SetShowGridLinesDiff } + | { CreateDefinedName: CreateDefinedNameDiff } + | { DeleteDefinedName: DeleteDefinedNameDiff } + | { UpdateDefinedName: UpdateDefinedNameDiff }; \ No newline at end of file From 71d596b47b3705074d774c66840e4f924d9e7455 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 10 Jun 2025 17:03:46 -0700 Subject: [PATCH 16/25] refactor event listener --- base/Cargo.toml | 1 + base/src/lib.rs | 4 +-- base/src/user_model/common.rs | 7 ++--- base/src/user_model/event.rs | 52 ++++++++++++++++++++--------------- base/src/user_model/mod.rs | 2 +- bindings/wasm/Cargo.toml | 2 +- bindings/wasm/src/lib.rs | 46 ++++++------------------------- 7 files changed, 46 insertions(+), 68 deletions(-) diff --git a/base/Cargo.toml b/base/Cargo.toml index e43def8b0..323b78fe5 100644 --- a/base/Cargo.toml +++ b/base/Cargo.toml @@ -25,6 +25,7 @@ csv = "1.3.0" default = ["use_regex_full"] use_regex_full = ["regex"] use_regex_lite = ["regex-lite"] +single_threaded = [] [dev-dependencies] serde_json = "1.0" diff --git a/base/src/lib.rs b/base/src/lib.rs index 9ca28fcea..a62006228 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -59,6 +59,4 @@ pub mod mock_time; pub use model::get_milliseconds_since_epoch; pub use model::Model; -pub use user_model::{ - BorderArea, ClipboardData, Diff, EventEmitter, Subscription, UserModel, -}; +pub use user_model::{BorderArea, ClipboardData, Diff, EventEmitter, Subscription, UserModel}; diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 224c4f38a..8c66ff1fd 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -358,7 +358,7 @@ impl UserModel { /// Subscribes to diff events. /// Returns a Subscription handle that automatically unsubscribes when dropped. - #[cfg(target_arch = "wasm32")] + #[cfg(any(target_arch = "wasm32", feature = "single_threaded"))] pub fn subscribe(&self, listener: F) -> Subscription where F: Fn(&Diff) + 'static, @@ -368,7 +368,7 @@ impl UserModel { /// Subscribes to diff events. /// Returns a Subscription handle that automatically unsubscribes when dropped. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(any(target_arch = "wasm32", feature = "single_threaded")))] pub fn subscribe(&self, listener: F) -> Subscription where F: Fn(&Diff) + Send + Sync + 'static, @@ -1901,7 +1901,7 @@ impl UserModel { for diff in &diff_list { self.event_emitter.emit(diff); } - + self.send_queue.push(QueueDiffs { r#type: DiffType::Redo, list: diff_list.clone(), @@ -2444,4 +2444,3 @@ mod tests { } } } - diff --git a/base/src/user_model/event.rs b/base/src/user_model/event.rs index 16e165fd3..0e34d4b57 100644 --- a/base/src/user_model/event.rs +++ b/base/src/user_model/event.rs @@ -6,6 +6,12 @@ use std::{ }, }; +// Type alias to reduce complexity +#[cfg(any(target_arch = "wasm32", feature = "single_threaded"))] +type ListenerMap = HashMap>; +#[cfg(not(any(target_arch = "wasm32", feature = "single_threaded")))] +type ListenerMap = HashMap>; + /// RAII handle: dropping it (or calling `.unsubscribe()`) removes the listener. pub struct Subscription { id: u32, @@ -16,7 +22,9 @@ impl Subscription { /// explicitly unsubscribe early pub fn unsubscribe(self) { if let Some(inner) = self.inner.upgrade() { - inner.listeners.write().unwrap().remove(&self.id); + if let Ok(mut listeners) = inner.listeners.write() { + listeners.remove(&self.id); + } } } } @@ -24,16 +32,15 @@ impl Subscription { impl Drop for Subscription { fn drop(&mut self) { if let Some(inner) = self.inner.upgrade() { - inner.listeners.write().unwrap().remove(&self.id); + if let Ok(mut listeners) = inner.listeners.write() { + listeners.remove(&self.id); + } } } } struct Inner { - #[cfg(target_arch = "wasm32")] - listeners: RwLock>>, - #[cfg(not(target_arch = "wasm32"))] - listeners: RwLock>>, + listeners: RwLock>, next_id: AtomicU32, } @@ -56,17 +63,15 @@ impl EventEmitter { /// Subscribe to events carrying `&T`. /// Returns a `Subscription` which unsubscribes on drop. - #[cfg(target_arch = "wasm32")] + #[cfg(any(target_arch = "wasm32", feature = "single_threaded"))] pub fn subscribe(&self, listener: F) -> Subscription where F: Fn(&T) + 'static, { let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); - self.inner - .listeners - .write() - .unwrap() - .insert(id, Box::new(listener)); + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.insert(id, Box::new(listener)); + } Subscription { id, @@ -76,17 +81,15 @@ impl EventEmitter { /// Subscribe to events carrying `&T`. /// Returns a `Subscription` which unsubscribes on drop. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(any(target_arch = "wasm32", feature = "single_threaded")))] pub fn subscribe(&self, listener: F) -> Subscription where F: Fn(&T) + Send + Sync + 'static, { let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); - self.inner - .listeners - .write() - .unwrap() - .insert(id, Box::new(listener)); + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.insert(id, Box::new(listener)); + } Subscription { id, @@ -96,16 +99,21 @@ impl EventEmitter { /// Manually unsubscribe by ID. Returns `true` if removed. pub fn unsubscribe(&self, id: u32) -> bool { - self.inner.listeners.write().unwrap().remove(&id).is_some() + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.remove(&id).is_some() + } else { + false + } } /// Emit a payload to all current listeners. pub fn emit(&self, payload: &T) { // Call listeners directly under read lock // This is safe because we're not modifying the map during iteration - let read = self.inner.listeners.read().unwrap(); - for callback in read.values() { - callback(payload); + if let Ok(read) = self.inner.listeners.read() { + for callback in read.values() { + callback(payload); + } } } } diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index c34001553..99d0cb7b9 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -3,8 +3,8 @@ mod border; mod border_utils; mod common; -mod history; mod event; +mod history; mod ui; pub use common::UserModel; diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 665c1745c..67855c226 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -14,7 +14,7 @@ crate-type = ["cdylib"] # Uses `../ironcalc/base` when used locally, and uses # the inicated version from crates.io when published. # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations -ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] } +ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite", "single_threaded"] } serde = { version = "1.0", features = ["derive"] } wasm-bindgen = "0.2.92" serde-wasm-bindgen = "0.4" diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 942acd5d5..71ce9cfe9 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,50 +1,22 @@ +use js_sys::Function; use serde::Serialize; +use std::cell::RefCell; +use std::rc::Rc; use wasm_bindgen::{ prelude::{wasm_bindgen, JsError}, - JsValue, - JsCast, + JsCast, JsValue, }; -use js_sys::Function; -use std::cell::RefCell; -use std::rc::Rc; use ironcalc_base::{ expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, types::{CellType, Style}, - BorderArea, ClipboardData, UserModel as BaseModel, Subscription, + BorderArea, ClipboardData, UserModel as BaseModel, }; -use ironcalc_base::Diff; - fn to_js_error(error: String) -> JsError { JsError::new(&error.to_string()) } -/// WASM wrapper for the Rust Subscription that allows JavaScript to unsubscribe -#[wasm_bindgen] -pub struct DiffSubscription { - subscription: Rc>>>, -} - -#[wasm_bindgen] -impl DiffSubscription { - /// Unsubscribe from diff events - pub fn unsubscribe(&mut self) { - if let Ok(mut sub) = self.subscription.try_borrow_mut() { - if let Some(subscription) = sub.take() { - subscription.unsubscribe(); - } - } - } -} - -impl Drop for DiffSubscription { - fn drop(&mut self) { - // Ensure unsubscription on drop as well - self.unsubscribe(); - } -} - /// Return an array with a list of all the tokens from a formula /// This is used by the UI to color them according to a theme. #[wasm_bindgen(js_name = "getTokens")] @@ -126,11 +98,11 @@ impl Model { } } }); - + // Store subscription in an Rc> so it can be moved into the closure let subscription_rc = Rc::new(RefCell::new(Some(subscription))); let subscription_clone = subscription_rc.clone(); - + // Create the unsubscribe function let unsubscribe_fn = wasm_bindgen::closure::Closure::wrap(Box::new(move || { if let Ok(mut sub) = subscription_clone.try_borrow_mut() { @@ -139,10 +111,10 @@ impl Model { } } }) as Box); - + let js_function = unsubscribe_fn.as_ref().unchecked_ref::().clone(); unsubscribe_fn.forget(); // Prevent the closure from being dropped - + js_function } From cf41e7329c7780ca3092b217e85444980be19d06 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 10 Jun 2025 17:05:37 -0700 Subject: [PATCH 17/25] fix spelling regression --- base/src/model.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/base/src/model.rs b/base/src/model.rs index 7ceb5363f..f2879cb3a 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -106,15 +106,15 @@ pub struct Model { pub(crate) shared_strings: HashMap, /// An instance of the parser pub(crate) parser: Parser, - /// The list of cells with formulas that are evaluated of being evaluated + /// The list of cells with formulas that are evaluated or being evaluated pub(crate) cells: HashMap<(u32, i32, i32), CellState>, /// The locale of the model pub(crate) locale: Locale, - /// Tha language used + /// The language used pub(crate) language: Language, /// The timezone used to evaluate the model pub(crate) tz: Tz, - /// The view id. A view consist of a selected sheet and ranges. + /// The view id. A view consists of a selected sheet and ranges. pub(crate) view_id: u32, } From 66c04e192d95258c2f6c4ecee5761be01ce89e7d Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 11 Jun 2025 18:31:42 -0700 Subject: [PATCH 18/25] feat: add import & export for xlsx and icalc --- Cargo.lock | 243 +---------------------------------- bindings/wasm/Cargo.toml | 1 + bindings/wasm/README.pkg.md | 20 ++- bindings/wasm/src/lib.rs | 40 ++++++ bindings/wasm/tests/test.mjs | 47 +++++++ xlsx/Cargo.toml | 4 +- xlsx/src/import/mod.rs | 7 + xlsx/tests/test.rs | 14 +- 8 files changed, 134 insertions(+), 242 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbcdd9932..ef60f4aeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,17 +8,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -55,12 +44,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" -[[package]] -name = "base64ct" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" - [[package]] name = "bitcode" version = "0.6.3" @@ -91,15 +74,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.15.4" @@ -118,36 +92,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.11+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "cc" version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" -dependencies = [ - "jobserver", - "libc", -] [[package]] name = "cfg-if" @@ -190,16 +139,6 @@ dependencies = [ "phf_codegen", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -210,12 +149,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "convert_case" version = "0.6.0" @@ -231,15 +164,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" -[[package]] -name = "cpufeatures" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.4.0" @@ -255,16 +179,6 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "csv" version = "1.3.0" @@ -305,17 +219,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - [[package]] name = "either" version = "1.10.0" @@ -332,16 +235,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.13" @@ -365,15 +258,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "iana-time-zone" version = "0.1.60" @@ -403,27 +287,20 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" -[[package]] -name = "inout" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" -dependencies = [ - "generic-array", -] - [[package]] name = "ironcalc" version = "0.5.0" dependencies = [ "bitcode", "chrono", + "flate2", "ironcalc_base", "itertools", "roxmltree", "serde", "serde_json", "thiserror", + "time", "uuid", "zip", ] @@ -472,15 +349,6 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" -[[package]] -name = "jobserver" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6" -dependencies = [ - "libc", -] - [[package]] name = "js-sys" version = "0.3.69" @@ -625,29 +493,6 @@ dependencies = [ "regex", ] -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core", - "subtle", -] - -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - [[package]] name = "phf" version = "0.11.2" @@ -686,12 +531,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pkg-config" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" - [[package]] name = "portable-atomic" version = "1.7.0" @@ -932,40 +771,12 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" -[[package]] -name = "subtle" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" - [[package]] name = "syn" version = "2.0.77" @@ -1010,6 +821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde", @@ -1022,12 +834,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" -[[package]] -name = "typenum" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" - [[package]] name = "unicode-ident" version = "1.0.12" @@ -1056,12 +862,6 @@ dependencies = [ "serde", ] -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1072,6 +872,7 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" name = "wasm" version = "0.5.0" dependencies = [ + "ironcalc", "ironcalc_base", "serde", "serde-wasm-bindgen", @@ -1252,45 +1053,9 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ - "aes", "byteorder", - "bzip2", - "constant_time_eq", "crc32fast", "crossbeam-utils", "flate2", - "hmac", - "pbkdf2", - "sha1", "time", - "zstd", -] - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.10+zstd.1.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa" -dependencies = [ - "cc", - "pkg-config", ] diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 231521091..9ec795465 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib"] # Uses `../ironcalc/base` when used locally, and uses # the inicated version from crates.io when published. # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations +ironcalc = { path = "../../xlsx", version = "0.5.0" } ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] } serde = { version = "1.0", features = ["derive"] } wasm-bindgen = "0.2.92" diff --git a/bindings/wasm/README.pkg.md b/bindings/wasm/README.pkg.md index 8e62d0ec5..126db7171 100644 --- a/bindings/wasm/README.pkg.md +++ b/bindings/wasm/README.pkg.md @@ -1,6 +1,6 @@ # IronCalc Web bindings -This package contains web bindings for IronCalc. Note that it does not contain the xlsx writer and reader, only the engine. +This package contains web bindings for IronCalc. It exposes the engine and helper functions to import or export workbooks as XLSX or IronCalc (icalc) byte arrays, but it does not bundle a full XLSX reader. ## Usage @@ -31,3 +31,21 @@ function compute() { compute(); ``` + +### Importing and exporting bytes + +The `Model` class provides helpers to load or save workbooks as raw byte arrays. + +```ts +// create a new workbook and export as XLSX bytes +const model = new Model('Workbook1', 'en', 'UTC'); +model.setUserInput(0, 1, 1, '42'); +const xlsxBytes = model.saveToXlsx(); + +// load from those bytes +const roundTripped = Model.fromXlsxBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); + +// same helpers exist for IronCalc's internal format +const icalcBytes = model.saveToIcalc(); +const restored = Model.fromIcalcBytes(icalcBytes); +``` diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2edf..fbc33b7da 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -4,11 +4,15 @@ use wasm_bindgen::{ JsValue, }; +use ironcalc::{ + base::Model as BaseWorkbookModel, export::save_xlsx_to_writer, import::load_from_xlsx_bytes, +}; use ironcalc_base::{ expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, types::{CellType, Style}, BorderArea, ClipboardData, UserModel as BaseModel, }; +use std::io::{BufWriter, Cursor}; fn to_js_error(error: String) -> JsError { JsError::new(&error.to_string()) @@ -55,6 +59,27 @@ impl Model { Ok(Model { model }) } + #[wasm_bindgen(js_name = "fromICalcBytes")] + pub fn from_icalc_bytes(bytes: &[u8]) -> Result { + let model = BaseModel::from_bytes(bytes).map_err(to_js_error)?; + Ok(Model { model }) + } + + #[wasm_bindgen(js_name = "fromXLSXBytes")] + pub fn from_xlsx_bytes( + bytes: &[u8], + name: &str, + locale: &str, + timezone: &str, + ) -> Result { + let workbook = load_from_xlsx_bytes(bytes, name, locale, timezone) + .map_err(|e| to_js_error(e.to_string()))?; + let base_model = + BaseWorkbookModel::from_workbook(workbook).map_err(|e| to_js_error(e.to_string()))?; + let user_model = BaseModel::from_model(base_model); + Ok(Model { model: user_model }) + } + pub fn undo(&mut self) -> Result<(), JsError> { self.model.undo().map_err(to_js_error) } @@ -579,6 +604,21 @@ impl Model { self.model.to_bytes() } + #[wasm_bindgen(js_name = "saveToICalc")] + pub fn save_to_icalc(&self) -> Vec { + self.model.to_bytes() + } + + #[wasm_bindgen(js_name = "saveToXLSX")] + pub fn save_to_xlsx(&self) -> Result, JsError> { + let mut buffer: Vec = Vec::new(); + let cursor = Cursor::new(&mut buffer); + let writer = BufWriter::new(cursor); + save_xlsx_to_writer(self.model.get_model(), writer) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(buffer) + } + #[wasm_bindgen(js_name = "getName")] pub fn get_name(&self) -> String { self.model.get_name() diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 374669966..c3ab1d471 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -130,5 +130,52 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); +test('saveToXLSX returns data', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const bytes = model.saveToXLSX(); + assert.ok(bytes instanceof Uint8Array); + assert.ok(bytes.length > 0); +}); +test('saveToICalc returns data', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const bytes = model.saveToICalc(); + assert.ok(bytes instanceof Uint8Array); + assert.ok(bytes.length > 0); +}); + +test('fromICalcBytes loads model', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, '42'); + const bytes = model.saveToICalc(); + const m2 = Model.fromICalcBytes(bytes); + assert.strictEqual(m2.getCellContent(0, 1, 1), '42'); +}); +test('fromXLSXBytes loads model', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, '5'); + const bytes = model.saveToXLSX(); + const m2 = Model.fromXLSXBytes(bytes, 'Workbook1', 'en', 'UTC'); + assert.strictEqual(m2.getCellContent(0, 1, 1), '5'); +}); + +test('roundtrip via xlsx bytes', () => { + const m1 = new Model('Workbook1', 'en', 'UTC'); + m1.setUserInput(0, 1, 1, '7'); + m1.setUserInput(0, 1, 2, '=A1*3'); + const bytes = m1.saveToXLSX(); + const m2 = Model.fromXLSXBytes(bytes, 'Workbook1', 'en', 'UTC'); + m2.evaluate(); + assert.strictEqual(m2.getFormattedCellValue(0, 1, 2), '21'); +}); + +test('roundtrip via icalc bytes', () => { + const m1 = new Model('Workbook1', 'en', 'UTC'); + m1.setUserInput(0, 1, 1, '9'); + m1.setUserInput(0, 1, 2, '=A1*4'); + const bytes = m1.saveToICalc(); + const m2 = Model.fromICalcBytes(bytes); + m2.evaluate(); + assert.strictEqual(m2.getFormattedCellValue(0, 1, 2), '36'); +}); diff --git a/xlsx/Cargo.toml b/xlsx/Cargo.toml index 713d3bbe1..79d7549c6 100644 --- a/xlsx/Cargo.toml +++ b/xlsx/Cargo.toml @@ -12,7 +12,9 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -zip = "0.6" +zip = { version = "0.6.6", default-features = false, features = ["deflate", "time"] } +flate2 = { version = "1.0", default-features = false, features = ["rust_backend"] } +time = { version = "0.3", features = ["wasm-bindgen"] } roxmltree = "0.19" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/xlsx/src/import/mod.rs b/xlsx/src/import/mod.rs index 7ca509eeb..feaaef804 100644 --- a/xlsx/src/import/mod.rs +++ b/xlsx/src/import/mod.rs @@ -153,3 +153,10 @@ pub fn load_from_icalc(file_name: &str) -> Result { .map_err(|e| XlsxError::IO(format!("Failed to decode file: {}", e)))?; Model::from_workbook(workbook).map_err(XlsxError::Workbook) } + +/// Loads a [`Model`] from the bytes of an `ic` file. +pub fn load_from_icalc_bytes(bytes: &[u8]) -> Result { + let workbook: Workbook = bitcode::decode(bytes) + .map_err(|e| XlsxError::IO(format!("Failed to decode file: {}", e)))?; + Model::from_workbook(workbook).map_err(XlsxError::Workbook) +} diff --git a/xlsx/tests/test.rs b/xlsx/tests/test.rs index 0845da5c0..376bbcdde 100644 --- a/xlsx/tests/test.rs +++ b/xlsx/tests/test.rs @@ -7,7 +7,9 @@ use uuid::Uuid; use ironcalc::compare::{test_file, test_load_and_saving}; use ironcalc::export::save_to_xlsx; -use ironcalc::import::{load_from_icalc, load_from_xlsx, load_from_xlsx_bytes}; +use ironcalc::import::{ + load_from_icalc, load_from_icalc_bytes, load_from_xlsx, load_from_xlsx_bytes, +}; use ironcalc_base::types::{HorizontalAlignment, VerticalAlignment}; use ironcalc_base::{Model, UserModel}; @@ -62,6 +64,16 @@ fn test_load_from_xlsx_bytes() { assert_eq!(workbook.views[&0].sheet, 7); } +#[test] +fn test_load_from_icalc_bytes() { + let mut file = fs::File::open("tests/example.ic").unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + let model = load_from_icalc_bytes(&bytes).unwrap(); + let model_from_file = load_from_icalc("tests/example.ic").unwrap(); + assert_eq!(model.workbook, model_from_file.workbook); +} + #[test] fn no_grid() { let model = load_from_xlsx("tests/NoGrid.xlsx", "en", "UTC").unwrap(); From 4905f609abefcb96c80968a0bc5d9be6434b8561 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Wed, 11 Jun 2025 18:38:00 -0700 Subject: [PATCH 19/25] update readme for wasm bindings --- bindings/wasm/README.md | 3 +-- bindings/wasm/README.pkg.md | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md index b54082e32..fc0b21159 100644 --- a/bindings/wasm/README.md +++ b/bindings/wasm/README.md @@ -1,7 +1,6 @@ # IronCalc Web bindings -This crate is used to build the web bindings for IronCalc. -Note that it does not contain the xlsx writer and reader, only the engine. +This package contains web bindings for IronCalc. It exposes the engine and helper functions to import or export workbooks as XLSX or IronCalc (icalc) byte arrays. The built-in XLSX support focuses on core spreadsheet features like cell values, formulas, and styling. https://www.npmjs.com/package/@ironcalc/wasm?activeTab=readme diff --git a/bindings/wasm/README.pkg.md b/bindings/wasm/README.pkg.md index 126db7171..e26a45301 100644 --- a/bindings/wasm/README.pkg.md +++ b/bindings/wasm/README.pkg.md @@ -1,6 +1,6 @@ # IronCalc Web bindings -This package contains web bindings for IronCalc. It exposes the engine and helper functions to import or export workbooks as XLSX or IronCalc (icalc) byte arrays, but it does not bundle a full XLSX reader. +This package contains web bindings for IronCalc. It exposes the engine and helper functions to import or export workbooks as XLSX or IronCalc (icalc) byte arrays. The built-in XLSX support focuses on core spreadsheet features like cell values, formulas, and styling. ## Usage @@ -49,3 +49,4 @@ const roundTripped = Model.fromXlsxBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); const icalcBytes = model.saveToIcalc(); const restored = Model.fromIcalcBytes(icalcBytes); ``` + From b9f70b0f0ceece6357ff6272dd9229098d1e2973 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Thu, 12 Jun 2025 15:24:53 -0700 Subject: [PATCH 20/25] feat: publish xlsx import export as separate wasm --- .github/workflows/rust-build-test.yaml | 6 + .gitignore | 1 + Cargo.lock | 36 ++- Makefile | 10 +- bindings/wasm/Cargo.toml | 14 +- bindings/wasm/Makefile | 41 ++- bindings/wasm/README.md | 3 +- bindings/wasm/README.pkg.md | 20 +- bindings/wasm/fix_types.py | 250 ++++-------------- bindings/wasm/package.json | 28 ++ bindings/wasm/src/lib.rs | 41 +-- bindings/wasm/src/xlsx.rs | 39 +++ bindings/wasm/tests/test.mjs | 33 +-- .../src/components/tests/model.test.ts | 2 +- .../src/components/tests/util.test.ts | 2 +- 15 files changed, 234 insertions(+), 292 deletions(-) create mode 100644 bindings/wasm/package.json create mode 100644 bindings/wasm/src/xlsx.rs diff --git a/.github/workflows/rust-build-test.yaml b/.github/workflows/rust-build-test.yaml index 6de247a00..084224323 100644 --- a/.github/workflows/rust-build-test.yaml +++ b/.github/workflows/rust-build-test.yaml @@ -18,6 +18,12 @@ jobs: - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Install wasm-bindgen-cli + run: cargo install -f wasm-bindgen-cli --version 0.2.100 + + - name: Add wasm32 target + run: rustup target add wasm32-unknown-unknown - name: Build run: cargo build --release --verbose diff --git a/.gitignore b/.gitignore index 52a2a589a..682e58123 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/* **/node_modules/** .DS_Store +bindings/wasm/pkg/ diff --git a/Cargo.lock b/Cargo.lock index ef60f4aeb..640c2cf6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,6 +711,12 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + [[package]] name = "ryu" version = "1.0.17" @@ -740,9 +746,9 @@ dependencies = [ [[package]] name = "serde-wasm-bindgen" -version = "0.4.5" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b4c031cd0d9014307d82b8abf653c0290fbdaeb4c02d00c63cf52f728628bf" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" dependencies = [ "js-sys", "serde", @@ -882,23 +888,24 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn", @@ -919,9 +926,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -929,9 +936,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -942,9 +949,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-bindgen-test" diff --git a/Makefile b/Makefile index 5f781d174..5885894fc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ .PHONY: lint lint: cargo fmt -- --check - cargo clippy --all-targets --all-features -- -W clippy::unwrap_used -W clippy::expect_used -W clippy::panic -D warnings + cargo clippy --workspace --exclude wasm --all-targets --all-features -- -W clippy::unwrap_used -W clippy::expect_used -W clippy::panic -D warnings + cd bindings/wasm/ && cargo clippy --target wasm32-unknown-unknown -- -W clippy::unwrap_used -W clippy::expect_used -W clippy::panic -D warnings cd webapp/IronCalc/ && npm install && npm run check cd webapp/app.ironcalc.com/frontend/ && npm install && npm run check @@ -15,7 +16,12 @@ tests: lint make remove-artifacts # Regretabbly we need to build the wasm twice, once for the nodejs tests # and a second one for the vitest. - cd bindings/wasm/ && wasm-pack build --target nodejs && node tests/test.mjs && make + cd bindings/wasm/ && \ + wasm-pack build --target nodejs --out-name ironcalc && \ + cargo build --release --target wasm32-unknown-unknown --bin xlsx_wasm && \ + wasm-bindgen --target nodejs --typescript --out-dir pkg --out-name xlsx ../../target/wasm32-unknown-unknown/release/xlsx_wasm.wasm && \ + node tests/test.mjs && \ + make cd webapp/IronCalc/ && npm run test cd bindings/python && ./run_tests.sh && ./run_examples.sh diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 9ec795465..99f5c4195 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -8,17 +8,19 @@ repository = "https://github.com/ironcalc/ironcalc" edition = "2021" [lib] +name = "ironcalc_wasm" crate-type = ["cdylib"] +[[bin]] +name = "xlsx_wasm" +path = "src/xlsx.rs" + [dependencies] -# Uses `../ironcalc/base` when used locally, and uses -# the inicated version from crates.io when published. -# https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations ironcalc = { path = "../../xlsx", version = "0.5.0" } ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] } serde = { version = "1.0", features = ["derive"] } -wasm-bindgen = "0.2.92" -serde-wasm-bindgen = "0.4" +wasm-bindgen = "0.2.100" +serde-wasm-bindgen = "0.6" [dev-dependencies] -wasm-bindgen-test = "0.3.38" +wasm-bindgen-test = "0.3.38" \ No newline at end of file diff --git a/bindings/wasm/Makefile b/bindings/wasm/Makefile index af4084b5e..2f7151d15 100644 --- a/bindings/wasm/Makefile +++ b/bindings/wasm/Makefile @@ -1,3 +1,20 @@ +# --------------------------------------------------------------------------- +# Build strategy +# --------------------------------------------------------------------------- +# We need two separate WASM bundles in the same NPM package: +# 1) Core engine → @ironcalc/wasm (small, always shipped) +# 2) XLSX helpers → @ironcalc/wasm/xlsx (large, optional) +# +# The natural way would be two wasm-pack invocations – one default lib and one +# wasm-pack build --bin xlsx_wasm … +# Unfortunately, on the stable Rust tool-chain wasm-pack's `--bin` flag relies +# on Cargo's unstable `--out-dir`/`--artifact-dir` feature and therefore fails. +# +# Until that becomes stable (or we switch to a nightly tool-chain), we compile +# the second binary with plain `cargo build` and then run `wasm-bindgen` +# ourselves. As soon as the flag is stabilised we can collapse back to the +# two simple wasm-pack commands shown above and delete the hand-rolled steps. +# --------------------------------------------------------------------------- # In some platforms, python is called python3 PYTHON := $(shell command -v python 2>/dev/null || command -v python3 2>/dev/null) @@ -6,15 +23,35 @@ ifeq ($(PYTHON),) $(error No python found. Please install python.) endif +TEMP_DIR := pkg_xlsx + all: - wasm-pack build --target web --scope ironcalc --release + wasm-pack build --target web --scope ironcalc --out-dir pkg --out-name ironcalc --release + + # Build XLSX helper separately (cargo + wasm-bindgen) + cargo build --release --target wasm32-unknown-unknown --bin xlsx_wasm + wasm-bindgen --target web --typescript --out-dir $(TEMP_DIR) --out-name xlsx ../../target/wasm32-unknown-unknown/release/xlsx_wasm.wasm + + # Move generated files into main pkg directory + mv $(TEMP_DIR)/xlsx_bg.wasm pkg/xlsx_bg.wasm + mv $(TEMP_DIR)/xlsx.js pkg/xlsx.js + mv $(TEMP_DIR)/xlsx.d.ts pkg/xlsx.d.ts + cp README.pkg.md pkg/README.md npx tsc types.ts --target esnext --module esnext $(PYTHON) fix_types.py rm -f types.js + rm -rf $(TEMP_DIR) tests: - wasm-pack build --target nodejs && node tests/test.mjs + wasm-pack build --target nodejs --out-dir pkg --out-name ironcalc + cargo build --release --target wasm32-unknown-unknown --bin xlsx_wasm + wasm-bindgen --target nodejs --typescript --out-dir $(TEMP_DIR) --out-name xlsx ../../target/wasm32-unknown-unknown/release/xlsx_wasm.wasm + mv $(TEMP_DIR)/xlsx_bg.wasm pkg/xlsx_bg.wasm || true + mv $(TEMP_DIR)/xlsx.js pkg/xlsx.js || true + mv $(TEMP_DIR)/xlsx.d.ts pkg/xlsx.d.ts || true + rm -rf $(TEMP_DIR) + node tests/test.mjs lint: cargo check diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md index fc0b21159..b54082e32 100644 --- a/bindings/wasm/README.md +++ b/bindings/wasm/README.md @@ -1,6 +1,7 @@ # IronCalc Web bindings -This package contains web bindings for IronCalc. It exposes the engine and helper functions to import or export workbooks as XLSX or IronCalc (icalc) byte arrays. The built-in XLSX support focuses on core spreadsheet features like cell values, formulas, and styling. +This crate is used to build the web bindings for IronCalc. +Note that it does not contain the xlsx writer and reader, only the engine. https://www.npmjs.com/package/@ironcalc/wasm?activeTab=readme diff --git a/bindings/wasm/README.pkg.md b/bindings/wasm/README.pkg.md index e26a45301..916ed9024 100644 --- a/bindings/wasm/README.pkg.md +++ b/bindings/wasm/README.pkg.md @@ -1,7 +1,6 @@ # IronCalc Web bindings -This package contains web bindings for IronCalc. It exposes the engine and helper functions to import or export workbooks as XLSX or IronCalc (icalc) byte arrays. The built-in XLSX support focuses on core spreadsheet features like cell values, formulas, and styling. - +This crate is used to build the web bindings for IronCalc. ## Usage @@ -15,8 +14,11 @@ And then in your TypeScript ```TypeScript import init, { Model } from "@ironcalc/wasm"; +import initXLSX, { toXLSXBytes, fromXLSXBytes } from "@ironcalc/wasm/xlsx"; + await init(); +await initXLSX(); function compute() { const model = new Model('en', 'UTC'); @@ -30,23 +32,15 @@ function compute() { } compute(); -``` - -### Importing and exporting bytes - -The `Model` class provides helpers to load or save workbooks as raw byte arrays. -```ts // create a new workbook and export as XLSX bytes const model = new Model('Workbook1', 'en', 'UTC'); model.setUserInput(0, 1, 1, '42'); -const xlsxBytes = model.saveToXlsx(); +const xlsxBytes = toXLSXBytes(model.toBytes()); // load from those bytes -const roundTripped = Model.fromXlsxBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); +const roundTrippedBytes = fromXLSXBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); +const roundTripped = Model.fromBytes(roundTrippedBytes); -// same helpers exist for IronCalc's internal format -const icalcBytes = model.saveToIcalc(); -const restored = Model.fromIcalcBytes(icalcBytes); ``` diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index fd466ee33..fd69ccef5 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -1,205 +1,52 @@ # Regrettably at the time of writing there is not a perfect way to # generate the TypeScript types from Rust so we basically fix them manually # Hopefully this will suffice for our needs and one day will be automatic +# Updated patterns for wasm-bindgen >=0.2.94 (two-space indent, no @returns docs) header = r""" /* tslint:disable */ /* eslint-disable */ """.strip() -get_tokens_str = r""" -* @returns {any} -*/ -export function getTokens(formula: string): any; -""".strip() -get_tokens_str_types = r""" -* @returns {MarkedToken[]} -*/ -export function getTokens(formula: string): MarkedToken[]; -""".strip() +get_tokens_str = "export function getTokens(formula: string): any;" +get_tokens_str_types = "export function getTokens(formula: string): MarkedToken[];" -update_style_str = r""" -/** -* @param {any} range -* @param {string} style_path -* @param {string} value -*/ - updateRangeStyle(range: any, style_path: string, value: string): void; -""".strip() +update_style_str = " updateRangeStyle(range: any, style_path: string, value: string): void;" +update_style_str_types = " updateRangeStyle(range: Area, style_path: string, value: string): void;" -update_style_str_types = r""" -/** -* @param {Area} range -* @param {string} style_path -* @param {string} value -*/ - updateRangeStyle(range: Area, style_path: string, value: string): void; -""".strip() +properties = " getWorksheetsProperties(): any;" +properties_types = " getWorksheetsProperties(): WorksheetProperties[];" -properties = r""" -/** -* @returns {any} -*/ - getWorksheetsProperties(): any; -""".strip() +style = " getCellStyle(sheet: number, row: number, column: number): any;" +style_types = " getCellStyle(sheet: number, row: number, column: number): CellStyle;" -properties_types = r""" -/** -* @returns {WorksheetProperties[]} -*/ - getWorksheetsProperties(): WorksheetProperties[]; -""".strip() +view = " getSelectedView(): any;" +view_types = " getSelectedView(): SelectedView;" -style = r""" -* @returns {any} -*/ - getCellStyle(sheet: number, row: number, column: number): any; -""".strip() +autofill_rows = " autoFillRows(source_area: any, to_row: number): void;" +autofill_rows_types = " autoFillRows(source_area: Area, to_row: number): void;" -style_types = r""" -* @returns {CellStyle} -*/ - getCellStyle(sheet: number, row: number, column: number): CellStyle; -""".strip() +autofill_columns = " autoFillColumns(source_area: any, to_column: number): void;" +autofill_columns_types = " autoFillColumns(source_area: Area, to_column: number): void;" -view = r""" -* @returns {any} -*/ - getSelectedView(): any; -""".strip() +set_cell_style = " onPasteStyles(styles: any): void;" +set_cell_style_types = " onPasteStyles(styles: CellStyle[][]): void;" -view_types = r""" -* @returns {CellStyle} -*/ - getSelectedView(): SelectedView; -""".strip() +set_area_border = " setAreaWithBorder(area: any, border_area: any): void;" +set_area_border_types = " setAreaWithBorder(area: Area, border_area: BorderArea): void;" + +paste_csv_string = " pasteCsvText(area: any, csv: string): void;" +paste_csv_string_types = " pasteCsvText(area: Area, csv: string): void;" + +clipboard = " copyToClipboard(): any;" +clipboard_types = " copyToClipboard(): Clipboard;" + +paste_from_clipboard = " pasteFromClipboard(source_sheet: number, source_range: any, clipboard: any, is_cut: boolean): void;" +paste_from_clipboard_types = " pasteFromClipboard(source_sheet: number, source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void;" -autofill_rows = r""" -/** -* @param {any} source_area -* @param {number} to_row -*/ - autoFillRows(source_area: any, to_row: number): void; -""" - -autofill_rows_types = r""" -/** -* @param {Area} source_area -* @param {number} to_row -*/ - autoFillRows(source_area: Area, to_row: number): void; -""" - -autofill_columns = r""" -/** -* @param {any} source_area -* @param {number} to_column -*/ - autoFillColumns(source_area: any, to_column: number): void; -""" - -autofill_columns_types = r""" -/** -* @param {Area} source_area -* @param {number} to_column -*/ - autoFillColumns(source_area: Area, to_column: number): void; -""" - -set_cell_style = r""" -/** -* @param {any} styles -*/ - onPasteStyles(styles: any): void; -""" - -set_cell_style_types = r""" -/** -* @param {CellStyle[][]} styles -*/ - onPasteStyles(styles: CellStyle[][]): void; -""" - -set_area_border = r""" -/** -* @param {any} area -* @param {any} border_area -*/ - setAreaWithBorder(area: any, border_area: any): void; -""" - -set_area_border_types = r""" -/** -* @param {Area} area -* @param {BorderArea} border_area -*/ - setAreaWithBorder(area: Area, border_area: BorderArea): void; -""" - -paste_csv_string = r""" -/** -* @param {any} area -* @param {string} csv -*/ - pasteCsvText(area: any, csv: string): void; -""" - -paste_csv_string_types = r""" -/** -* @param {Area} area -* @param {string} csv -*/ - pasteCsvText(area: Area, csv: string): void; -""" - -clipboard = r""" -/** -* @returns {any} -*/ - copyToClipboard(): any; -""" - -clipboard_types = r""" -/** -* @returns {Clipboard} -*/ - copyToClipboard(): Clipboard; -""" - -paste_from_clipboard = r""" -/** -* @param {number} source_sheet -* @param {any} source_range -* @param {any} clipboard -* @param {boolean} is_cut -*/ - pasteFromClipboard(source_sheet: number, source_range: any, clipboard: any, is_cut: boolean): void; -""" - -paste_from_clipboard_types = r""" -/** -* @param {number} source_sheet -* @param {[number, number, number, number]} source_range -* @param {ClipboardData} clipboard -* @param {boolean} is_cut -*/ - pasteFromClipboard(source_sheet: number, source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void; -""" - -defined_name_list = r""" -/** -* @returns {any} -*/ - getDefinedNameList(): any; -""" - -defined_name_list_types = r""" -/** -* @returns {DefinedName[]} -*/ - getDefinedNameList(): DefinedName[]; -""" +defined_name_list = " getDefinedNameList(): any;" +defined_name_list_types = " getDefinedNameList(): DefinedName[];" def fix_types(text): text = text.replace(get_tokens_str, get_tokens_str_types) @@ -219,29 +66,36 @@ def fix_types(text): types_str = f.read() header_types = "{}\n\n{}".format(header, types_str) text = text.replace(header, header_types) - if text.find("any") != -1: - print("There are 'unfixed' types. Please check.") - exit(1) + for line in text.splitlines(): + stripped = line.lstrip() + # Skip internal methods + if stripped.startswith("readonly model_"): + continue + if stripped.find("any") != -1: + print("There are 'unfixed' public types. Please check.") + exit(1) return text if __name__ == "__main__": - types_file = "pkg/wasm.d.ts" - with open(types_file) as f: - text = f.read() - text = fix_types(text) - with open(types_file, "wb") as f: - f.write(bytes(text, "utf8")) - - js_file = "pkg/wasm.js" + dts_files = ["pkg/ironcalc.d.ts", "pkg/xlsx.d.ts"] + for types_file in dts_files: + with open(types_file) as f: + text = f.read() + text = fix_types(text) + with open(types_file, "wb") as f: + f.write(bytes(text, "utf8")) + + js_files = ["pkg/ironcalc.js", "pkg/xlsx.js"] with open("types.js") as f: text_js = f.read() - with open(js_file) as f: - text = f.read() - with open(js_file, "wb") as f: - f.write(bytes("{}\n{}".format(text_js, text), "utf8")) + for js_file in js_files: + with open(js_file) as f: + text = f.read() + with open(js_file, "wb") as f: + f.write(bytes("{}\n{}".format(text_js, text), "utf8")) diff --git a/bindings/wasm/package.json b/bindings/wasm/package.json new file mode 100644 index 000000000..1cad81fa8 --- /dev/null +++ b/bindings/wasm/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ironcalc/wasm", + "version": "0.3.2", + "description": "IronCalc WebAssembly bindings (core engine + optional XLSX helpers)", + "private": false, + "main": "./pkg/ironcalc.js", + "types": "./pkg/ironcalc.d.ts", + "exports": { + ".": { + "require": "./pkg/ironcalc.js", + "import": "./pkg/ironcalc.js", + "types": "./pkg/ironcalc.d.ts" + }, + "./xlsx": { + "require": "./pkg/xlsx.js", + "import": "./pkg/xlsx.js", + "types": "./pkg/xlsx.d.ts" + } + }, + "files": [ + "pkg" + ], + "repository": { + "type": "git", + "url": "https://github.com/ironcalc/ironcalc" + }, + "license": "MIT OR Apache-2.0" +} \ No newline at end of file diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index fbc33b7da..ca5810a18 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -4,15 +4,11 @@ use wasm_bindgen::{ JsValue, }; -use ironcalc::{ - base::Model as BaseWorkbookModel, export::save_xlsx_to_writer, import::load_from_xlsx_bytes, -}; use ironcalc_base::{ expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, types::{CellType, Style}, BorderArea, ClipboardData, UserModel as BaseModel, }; -use std::io::{BufWriter, Cursor}; fn to_js_error(error: String) -> JsError { JsError::new(&error.to_string()) @@ -54,32 +50,12 @@ impl Model { Ok(Model { model }) } + #[wasm_bindgen(js_name = "fromBytes")] pub fn from_bytes(bytes: &[u8]) -> Result { let model = BaseModel::from_bytes(bytes).map_err(to_js_error)?; Ok(Model { model }) } - #[wasm_bindgen(js_name = "fromICalcBytes")] - pub fn from_icalc_bytes(bytes: &[u8]) -> Result { - let model = BaseModel::from_bytes(bytes).map_err(to_js_error)?; - Ok(Model { model }) - } - - #[wasm_bindgen(js_name = "fromXLSXBytes")] - pub fn from_xlsx_bytes( - bytes: &[u8], - name: &str, - locale: &str, - timezone: &str, - ) -> Result { - let workbook = load_from_xlsx_bytes(bytes, name, locale, timezone) - .map_err(|e| to_js_error(e.to_string()))?; - let base_model = - BaseWorkbookModel::from_workbook(workbook).map_err(|e| to_js_error(e.to_string()))?; - let user_model = BaseModel::from_model(base_model); - Ok(Model { model: user_model }) - } - pub fn undo(&mut self) -> Result<(), JsError> { self.model.undo().map_err(to_js_error) } @@ -604,21 +580,6 @@ impl Model { self.model.to_bytes() } - #[wasm_bindgen(js_name = "saveToICalc")] - pub fn save_to_icalc(&self) -> Vec { - self.model.to_bytes() - } - - #[wasm_bindgen(js_name = "saveToXLSX")] - pub fn save_to_xlsx(&self) -> Result, JsError> { - let mut buffer: Vec = Vec::new(); - let cursor = Cursor::new(&mut buffer); - let writer = BufWriter::new(cursor); - save_xlsx_to_writer(self.model.get_model(), writer) - .map_err(|e| JsError::new(&e.to_string()))?; - Ok(buffer) - } - #[wasm_bindgen(js_name = "getName")] pub fn get_name(&self) -> String { self.model.get_name() diff --git a/bindings/wasm/src/xlsx.rs b/bindings/wasm/src/xlsx.rs new file mode 100644 index 000000000..1f85fdfb0 --- /dev/null +++ b/bindings/wasm/src/xlsx.rs @@ -0,0 +1,39 @@ +use ironcalc::{ + base::Model as BaseWorkbookModel, export::save_xlsx_to_writer, import::load_from_xlsx_bytes, +}; +use std::io::{BufWriter, Cursor, Write}; +use wasm_bindgen::prelude::{wasm_bindgen, JsError}; + +fn to_js_error(error: String) -> JsError { + JsError::new(&error.to_string()) +} + +#[wasm_bindgen(js_name = fromXLSXBytes)] +pub fn from_xlsx_bytes( + bytes: &[u8], + name: &str, + locale: &str, + timezone: &str, +) -> Result, JsError> { + let workbook = load_from_xlsx_bytes(bytes, name, locale, timezone) + .map_err(|e| to_js_error(e.to_string()))?; + let base_model = + BaseWorkbookModel::from_workbook(workbook).map_err(|e| to_js_error(e.to_string()))?; + Ok(base_model.to_bytes()) +} + +#[wasm_bindgen(js_name = toXLSXBytes)] +pub fn to_xlsx_bytes(bytes: &[u8]) -> Result, JsError> { + let workbook = BaseWorkbookModel::from_bytes(bytes).map_err(to_js_error)?; + let mut writer = BufWriter::new(Cursor::new(Vec::new())); + save_xlsx_to_writer(&workbook, &mut writer).map_err(|e| to_js_error(e.to_string()))?; + writer.flush().map_err(|e| to_js_error(e.to_string()))?; + Ok(writer + .into_inner() + .map_err(|e| to_js_error(e.to_string()))? + .into_inner()) +} + +fn main() { + // This is required for cargo to compile this as a binary +} diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index c3ab1d471..cc208514a 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert' -import { Model } from "../pkg/wasm.js"; +import { Model } from "../pkg/ironcalc.js"; +import { fromXLSXBytes, toXLSXBytes } from "../pkg/xlsx.js"; const DEFAULT_ROW_HEIGHT = 28; @@ -130,33 +131,34 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); -test('saveToXLSX returns data', () => { +test('toXLSXBytes returns data', () => { const model = new Model('Workbook1', 'en', 'UTC'); - const bytes = model.saveToXLSX(); + const bytes = toXLSXBytes(model.toBytes()); assert.ok(bytes instanceof Uint8Array); assert.ok(bytes.length > 0); }); -test('saveToICalc returns data', () => { +test('toBytes returns data', () => { const model = new Model('Workbook1', 'en', 'UTC'); - const bytes = model.saveToICalc(); + const bytes = model.toBytes(); assert.ok(bytes instanceof Uint8Array); assert.ok(bytes.length > 0); }); -test('fromICalcBytes loads model', () => { +test('fromBytes loads model', () => { const model = new Model('Workbook1', 'en', 'UTC'); model.setUserInput(0, 1, 1, '42'); - const bytes = model.saveToICalc(); - const m2 = Model.fromICalcBytes(bytes); + const bytes = model.toBytes(); + const m2 = Model.fromBytes(bytes); assert.strictEqual(m2.getCellContent(0, 1, 1), '42'); }); test('fromXLSXBytes loads model', () => { const model = new Model('Workbook1', 'en', 'UTC'); model.setUserInput(0, 1, 1, '5'); - const bytes = model.saveToXLSX(); - const m2 = Model.fromXLSXBytes(bytes, 'Workbook1', 'en', 'UTC'); + const xlsxBytes = toXLSXBytes(model.toBytes()); + const modelBytes = fromXLSXBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); + const m2 = Model.fromBytes(modelBytes); assert.strictEqual(m2.getCellContent(0, 1, 1), '5'); }); @@ -164,18 +166,19 @@ test('roundtrip via xlsx bytes', () => { const m1 = new Model('Workbook1', 'en', 'UTC'); m1.setUserInput(0, 1, 1, '7'); m1.setUserInput(0, 1, 2, '=A1*3'); - const bytes = m1.saveToXLSX(); - const m2 = Model.fromXLSXBytes(bytes, 'Workbook1', 'en', 'UTC'); + const xlsxBytes = toXLSXBytes(m1.toBytes()); + const m2Bytes = fromXLSXBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); + const m2 = Model.fromBytes(m2Bytes); m2.evaluate(); assert.strictEqual(m2.getFormattedCellValue(0, 1, 2), '21'); }); -test('roundtrip via icalc bytes', () => { +test('roundtrip via bytes', () => { const m1 = new Model('Workbook1', 'en', 'UTC'); m1.setUserInput(0, 1, 1, '9'); m1.setUserInput(0, 1, 2, '=A1*4'); - const bytes = m1.saveToICalc(); - const m2 = Model.fromICalcBytes(bytes); + const bytes = m1.toBytes(); + const m2 = Model.fromBytes(bytes); m2.evaluate(); assert.strictEqual(m2.getFormattedCellValue(0, 1, 2), '36'); }); diff --git a/webapp/IronCalc/src/components/tests/model.test.ts b/webapp/IronCalc/src/components/tests/model.test.ts index 0d030c8cf..4ff17ca06 100644 --- a/webapp/IronCalc/src/components/tests/model.test.ts +++ b/webapp/IronCalc/src/components/tests/model.test.ts @@ -5,7 +5,7 @@ import { expect, test } from "vitest"; // This is a simple test that showcases how to load the wasm module in the tests test("simple calculation", async () => { - const buffer = await readFile("node_modules/@ironcalc/wasm/wasm_bg.wasm"); + const buffer = await readFile("node_modules/@ironcalc/wasm/ironcalc_bg.wasm"); initSync(buffer); const model = new Model("workbook", "en", "UTC"); model.setUserInput(0, 1, 1, "=21*2"); diff --git a/webapp/IronCalc/src/components/tests/util.test.ts b/webapp/IronCalc/src/components/tests/util.test.ts index 370efaa5d..3811ad881 100644 --- a/webapp/IronCalc/src/components/tests/util.test.ts +++ b/webapp/IronCalc/src/components/tests/util.test.ts @@ -31,7 +31,7 @@ test("decrease decimals", () => { }); test("format range to get the full formula", async () => { - const buffer = await readFile("node_modules/@ironcalc/wasm/wasm_bg.wasm"); + const buffer = await readFile("node_modules/@ironcalc/wasm/ironcalc_bg.wasm"); initSync(buffer); const selectedView: SelectedView = { From 5aa3313bc67c096a3b45084852b9bad6a522732a Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Thu, 12 Jun 2025 16:23:15 -0700 Subject: [PATCH 21/25] feat: use wasm-bindgen unchecked_return_type --- Cargo.lock | 32 +++-- bindings/wasm/Cargo.toml | 2 +- bindings/wasm/fix_types.py | 232 ++----------------------------------- bindings/wasm/src/lib.rs | 46 +++++--- 4 files changed, 66 insertions(+), 246 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbcdd9932..bca3abd5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,6 +872,12 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + [[package]] name = "ryu" version = "1.0.17" @@ -1081,23 +1087,24 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn", @@ -1118,9 +1125,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1128,9 +1135,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -1141,9 +1148,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-bindgen-test" diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 231521091..30455a4ad 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -16,7 +16,7 @@ crate-type = ["cdylib"] # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] } serde = { version = "1.0", features = ["derive"] } -wasm-bindgen = "0.2.92" +wasm-bindgen = "0.2.100" serde-wasm-bindgen = "0.4" [dev-dependencies] diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index fd466ee33..022859898 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -1,230 +1,24 @@ -# Regrettably at the time of writing there is not a perfect way to -# generate the TypeScript types from Rust so we basically fix them manually -# Hopefully this will suffice for our needs and one day will be automatic - header = r""" /* tslint:disable */ /* eslint-disable */ """.strip() -get_tokens_str = r""" -* @returns {any} -*/ -export function getTokens(formula: string): any; -""".strip() - -get_tokens_str_types = r""" -* @returns {MarkedToken[]} -*/ -export function getTokens(formula: string): MarkedToken[]; -""".strip() - -update_style_str = r""" -/** -* @param {any} range -* @param {string} style_path -* @param {string} value -*/ - updateRangeStyle(range: any, style_path: string, value: string): void; -""".strip() - -update_style_str_types = r""" -/** -* @param {Area} range -* @param {string} style_path -* @param {string} value -*/ - updateRangeStyle(range: Area, style_path: string, value: string): void; -""".strip() - -properties = r""" -/** -* @returns {any} -*/ - getWorksheetsProperties(): any; -""".strip() - -properties_types = r""" -/** -* @returns {WorksheetProperties[]} -*/ - getWorksheetsProperties(): WorksheetProperties[]; -""".strip() - -style = r""" -* @returns {any} -*/ - getCellStyle(sheet: number, row: number, column: number): any; -""".strip() - -style_types = r""" -* @returns {CellStyle} -*/ - getCellStyle(sheet: number, row: number, column: number): CellStyle; -""".strip() - -view = r""" -* @returns {any} -*/ - getSelectedView(): any; -""".strip() - -view_types = r""" -* @returns {CellStyle} -*/ - getSelectedView(): SelectedView; -""".strip() - -autofill_rows = r""" -/** -* @param {any} source_area -* @param {number} to_row -*/ - autoFillRows(source_area: any, to_row: number): void; -""" - -autofill_rows_types = r""" -/** -* @param {Area} source_area -* @param {number} to_row -*/ - autoFillRows(source_area: Area, to_row: number): void; -""" - -autofill_columns = r""" -/** -* @param {any} source_area -* @param {number} to_column -*/ - autoFillColumns(source_area: any, to_column: number): void; -""" - -autofill_columns_types = r""" -/** -* @param {Area} source_area -* @param {number} to_column -*/ - autoFillColumns(source_area: Area, to_column: number): void; -""" - -set_cell_style = r""" -/** -* @param {any} styles -*/ - onPasteStyles(styles: any): void; -""" - -set_cell_style_types = r""" -/** -* @param {CellStyle[][]} styles -*/ - onPasteStyles(styles: CellStyle[][]): void; -""" - -set_area_border = r""" -/** -* @param {any} area -* @param {any} border_area -*/ - setAreaWithBorder(area: any, border_area: any): void; -""" - -set_area_border_types = r""" -/** -* @param {Area} area -* @param {BorderArea} border_area -*/ - setAreaWithBorder(area: Area, border_area: BorderArea): void; -""" - -paste_csv_string = r""" -/** -* @param {any} area -* @param {string} csv -*/ - pasteCsvText(area: any, csv: string): void; -""" - -paste_csv_string_types = r""" -/** -* @param {Area} area -* @param {string} csv -*/ - pasteCsvText(area: Area, csv: string): void; -""" - -clipboard = r""" -/** -* @returns {any} -*/ - copyToClipboard(): any; -""" - -clipboard_types = r""" -/** -* @returns {Clipboard} -*/ - copyToClipboard(): Clipboard; -""" - -paste_from_clipboard = r""" -/** -* @param {number} source_sheet -* @param {any} source_range -* @param {any} clipboard -* @param {boolean} is_cut -*/ - pasteFromClipboard(source_sheet: number, source_range: any, clipboard: any, is_cut: boolean): void; -""" - -paste_from_clipboard_types = r""" -/** -* @param {number} source_sheet -* @param {[number, number, number, number]} source_range -* @param {ClipboardData} clipboard -* @param {boolean} is_cut -*/ - pasteFromClipboard(source_sheet: number, source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void; -""" - -defined_name_list = r""" -/** -* @returns {any} -*/ - getDefinedNameList(): any; -""" - -defined_name_list_types = r""" -/** -* @returns {DefinedName[]} -*/ - getDefinedNameList(): DefinedName[]; -""" - -def fix_types(text): - text = text.replace(get_tokens_str, get_tokens_str_types) - text = text.replace(update_style_str, update_style_str_types) - text = text.replace(properties, properties_types) - text = text.replace(style, style_types) - text = text.replace(view, view_types) - text = text.replace(autofill_rows, autofill_rows_types) - text = text.replace(autofill_columns, autofill_columns_types) - text = text.replace(set_cell_style, set_cell_style_types) - text = text.replace(set_area_border, set_area_border_types) - text = text.replace(paste_csv_string, paste_csv_string_types) - text = text.replace(clipboard, clipboard_types) - text = text.replace(paste_from_clipboard, paste_from_clipboard_types) - text = text.replace(defined_name_list, defined_name_list_types) +def fix_types(text: str): with open("types.ts") as f: types_str = f.read() header_types = "{}\n\n{}".format(header, types_str) - text = text.replace(header, header_types) - if text.find("any") != -1: - print("There are 'unfixed' types. Please check.") - exit(1) - return text + text = text.replace(header, header_types) + for line in text.splitlines(): + line = line.lstrip() + # Skip internal methods + if line.startswith("readonly model_"): + continue + if line.find("any") != -1: + print("There are 'unfixed' public types. Please check.") + exit(1) + return text if __name__ == "__main__": types_file = "pkg/wasm.d.ts" @@ -242,6 +36,4 @@ def fix_types(text): with open(js_file, "wb") as f: f.write(bytes("{}\n{}".format(text_js, text), "utf8")) - - - + \ No newline at end of file diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 5767a2edf..0c0b6cb12 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -16,7 +16,7 @@ fn to_js_error(error: String) -> JsError { /// Return an array with a list of all the tokens from a formula /// This is used by the UI to color them according to a theme. -#[wasm_bindgen(js_name = "getTokens")] +#[wasm_bindgen(js_name = "getTokens", unchecked_return_type = "MarkedToken[]")] pub fn get_tokens(formula: &str) -> Result { let tokens = tokenizer(formula); serde_wasm_bindgen::to_value(&tokens).map_err(JsError::from) @@ -338,7 +338,7 @@ impl Model { #[wasm_bindgen(js_name = "updateRangeStyle")] pub fn update_range_style( &mut self, - range: JsValue, + #[wasm_bindgen(unchecked_param_type = "Area")] range: JsValue, style_path: &str, value: &str, ) -> Result<(), JsError> { @@ -349,7 +349,7 @@ impl Model { .map_err(to_js_error) } - #[wasm_bindgen(js_name = "getCellStyle")] + #[wasm_bindgen(js_name = "getCellStyle", unchecked_return_type = "CellStyle")] pub fn get_cell_style( &mut self, sheet: u32, @@ -365,7 +365,10 @@ impl Model { } #[wasm_bindgen(js_name = "onPasteStyles")] - pub fn on_paste_styles(&mut self, styles: JsValue) -> Result<(), JsError> { + pub fn on_paste_styles( + &mut self, + #[wasm_bindgen(unchecked_param_type = "CellStyle[][]")] styles: JsValue, + ) -> Result<(), JsError> { let styles: &Vec> = &serde_wasm_bindgen::from_value(styles).map_err(|e| to_js_error(e.to_string()))?; self.model.on_paste_styles(styles).map_err(to_js_error) @@ -391,7 +394,10 @@ impl Model { // I don't _think_ serializing to JsValue can't fail // FIXME: Remove this clippy directive - #[wasm_bindgen(js_name = "getWorksheetsProperties")] + #[wasm_bindgen( + js_name = "getWorksheetsProperties", + unchecked_return_type = "WorksheetProperties[]" + )] #[allow(clippy::unwrap_used)] pub fn get_worksheets_properties(&self) -> JsValue { serde_wasm_bindgen::to_value(&self.model.get_worksheets_properties()).unwrap() @@ -410,7 +416,7 @@ impl Model { // I don't _think_ serializing to JsValue can't fail // FIXME: Remove this clippy directive - #[wasm_bindgen(js_name = "getSelectedView")] + #[wasm_bindgen(js_name = "getSelectedView", unchecked_return_type = "SelectedView")] #[allow(clippy::unwrap_used)] pub fn get_selected_view(&self) -> JsValue { serde_wasm_bindgen::to_value(&self.model.get_selected_view()).unwrap() @@ -469,7 +475,11 @@ impl Model { } #[wasm_bindgen(js_name = "autoFillRows")] - pub fn auto_fill_rows(&mut self, source_area: JsValue, to_row: i32) -> Result<(), JsError> { + pub fn auto_fill_rows( + &mut self, + #[wasm_bindgen(unchecked_param_type = "Area")] source_area: JsValue, + to_row: i32, + ) -> Result<(), JsError> { let area: Area = serde_wasm_bindgen::from_value(source_area).map_err(|e| to_js_error(e.to_string()))?; self.model @@ -480,7 +490,7 @@ impl Model { #[wasm_bindgen(js_name = "autoFillColumns")] pub fn auto_fill_columns( &mut self, - source_area: JsValue, + #[wasm_bindgen(unchecked_param_type = "Area")] source_area: JsValue, to_column: i32, ) -> Result<(), JsError> { let area: Area = @@ -561,8 +571,8 @@ impl Model { #[wasm_bindgen(js_name = "setAreaWithBorder")] pub fn set_area_with_border( &mut self, - area: JsValue, - border_area: JsValue, + #[wasm_bindgen(unchecked_param_type = "Area")] area: JsValue, + #[wasm_bindgen(unchecked_param_type = "BorderArea")] border_area: JsValue, ) -> Result<(), JsError> { let range: Area = serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?; @@ -589,7 +599,7 @@ impl Model { self.model.set_name(name); } - #[wasm_bindgen(js_name = "copyToClipboard")] + #[wasm_bindgen(js_name = "copyToClipboard", unchecked_return_type = "Clipboard")] pub fn copy_to_clipboard(&self) -> Result { let data = self .model @@ -603,8 +613,9 @@ impl Model { pub fn paste_from_clipboard( &mut self, source_sheet: u32, + #[wasm_bindgen(unchecked_param_type = "[number, number, number, number]")] source_range: JsValue, - clipboard: JsValue, + #[wasm_bindgen(unchecked_param_type = "ClipboardData")] clipboard: JsValue, is_cut: bool, ) -> Result<(), JsError> { let source_range: (i32, i32, i32, i32) = @@ -617,7 +628,11 @@ impl Model { } #[wasm_bindgen(js_name = "pasteCsvText")] - pub fn paste_csv_string(&mut self, area: JsValue, csv: &str) -> Result<(), JsError> { + pub fn paste_csv_string( + &mut self, + #[wasm_bindgen(unchecked_param_type = "Area")] area: JsValue, + csv: &str, + ) -> Result<(), JsError> { let range: Area = serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?; self.model @@ -625,7 +640,10 @@ impl Model { .map_err(|e| to_js_error(e.to_string())) } - #[wasm_bindgen(js_name = "getDefinedNameList")] + #[wasm_bindgen( + js_name = "getDefinedNameList", + unchecked_return_type = "DefinedName[]" + )] pub fn get_defined_name_list(&self) -> Result { let data: Vec = self .model From bcb95b53d86f413057aae2f2e5bd8ab992a3d2c7 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Thu, 12 Jun 2025 17:46:45 -0700 Subject: [PATCH 22/25] Merge PR #380: Changed-cells tracking feature --- base/src/lib.rs | 4 +- base/src/model.rs | 14 ++++ base/src/user_model/common.rs | 77 +++++++++++------- base/src/user_model/history.rs | 8 ++ base/src/user_model/mod.rs | 10 +-- base/src/user_model/ui.rs | 7 ++ bindings/wasm/README.pkg.md | 22 ++++++ bindings/wasm/src/lib.rs | 48 ++++++++++-- bindings/wasm/tests/test.mjs | 137 +++++++++++++++++++++++++++++++++ bindings/wasm/types.ts | 8 +- 10 files changed, 293 insertions(+), 42 deletions(-) diff --git a/base/src/lib.rs b/base/src/lib.rs index 072118441..d010258aa 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -62,9 +62,11 @@ pub use model::Model; pub use user_model::BorderArea; pub use user_model::ClipboardData; +pub use user_model::ModelEvent; pub use user_model::Diff; pub use user_model::DiffType; pub use user_model::QueueDiffs; pub use user_model::UserModel; pub use user_model::EventEmitter; -pub use user_model::Subscription; \ No newline at end of file +pub use user_model::Subscription; +pub use user_model::SelectedView; diff --git a/base/src/model.rs b/base/src/model.rs index 4ccb12aed..9dc5ad976 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -1807,6 +1807,20 @@ impl Model { } } + /// Returns a list of all cells that have been changed or are being evaluated + pub fn get_changed_cells(&self) -> Vec { + self.cells + .keys() + .map( + |&(sheet, row, column)| crate::user_model::history::CellReference { + sheet, + row, + column, + }, + ) + .collect() + } + /// Removes the content of the cell but leaves the style. /// /// See also: diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index c19521dc5..c143e5eb6 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -6,7 +6,7 @@ use csv::{ReaderBuilder, WriterBuilder}; use serde::{Deserialize, Serialize}; use crate::{ - constants::{self, LAST_COLUMN, LAST_ROW}, + constants::{COLUMN_WIDTH_FACTOR, LAST_COLUMN, LAST_ROW}, expressions::{ types::{Area, CellReferenceIndex}, utils::{is_valid_column_number, is_valid_row}, @@ -16,15 +16,25 @@ use crate::{ Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState, Style, VerticalAlignment, }, + user_model::{ + event::{EventEmitter, Subscription}, + history::{ + CellReference, ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, + }, + }, utils::is_valid_hex_color, }; -use crate::user_model::history::{ - ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, -}; - use super::border_utils::is_max_border; -use super::event::{EventEmitter, Subscription}; + +/// Events that the `UserModel` can emit. +pub enum ModelEvent { + /// A diff that can be undone/redone. + Diff(Diff), + /// An informational event that cells have been evaluated. + CellsEvaluated(Vec), +} + /// Data for the clipboard pub type ClipboardData = HashMap>; @@ -224,7 +234,7 @@ pub struct UserModel { history: History, send_queue: Vec, pause_evaluation: bool, - event_emitter: EventEmitter, + event_emitter: EventEmitter, } impl Debug for UserModel { @@ -359,9 +369,9 @@ impl UserModel { /// Subscribes to diff events. /// Returns a Subscription handle that automatically unsubscribes when dropped. #[cfg(any(target_arch = "wasm32", feature = "single_threaded"))] - pub fn subscribe(&self, listener: F) -> Subscription + pub fn subscribe(&self, listener: F) -> Subscription where - F: Fn(&Diff) + 'static, + F: Fn(&ModelEvent) + 'static, { self.event_emitter.subscribe(listener) } @@ -369,9 +379,9 @@ impl UserModel { /// Subscribes to diff events. /// Returns a Subscription handle that automatically unsubscribes when dropped. #[cfg(not(any(target_arch = "wasm32", feature = "single_threaded")))] - pub fn subscribe(&self, listener: F) -> Subscription + pub fn subscribe(&self, listener: F) -> Subscription where - F: Fn(&Diff) + Send + Sync + 'static, + F: Fn(&ModelEvent) + Send + Sync + 'static, { self.event_emitter.subscribe(listener) } @@ -382,7 +392,22 @@ impl UserModel { /// * [Model::evaluate] /// * [UserModel::pause_evaluation] pub fn evaluate(&mut self) { - self.model.evaluate() + // Perform evaluation + self.model.evaluate(); + + // Get the list of cells that were just evaluated + let evaluated_cells = self.model.get_changed_cells(); + + // Emit cells evaluated event if there are any cells that were evaluated + if !evaluated_cells.is_empty() { + self.event_emitter + .emit(&ModelEvent::CellsEvaluated(evaluated_cells)); + } + } + + /// Returns a list of all cells that have been changed or are being evaluated + pub fn get_changed_cells(&self) -> Vec { + self.model.get_changed_cells() } /// Returns the list of pending diffs and removes them from the queue @@ -732,7 +757,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -762,7 +787,7 @@ impl UserModel { sheet, row: row.r, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -814,7 +839,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -869,7 +894,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -1708,13 +1733,13 @@ impl UserModel { // remain in the copied area let source = &CellReferenceIndex { sheet, - column: *source_column, row: *source_row, + column: *source_column, }; let target = &CellReferenceIndex { sheet, - column: target_column, row: target_row, + column: target_column, }; let new_value = if is_cut { self.model @@ -1901,7 +1926,7 @@ impl UserModel { pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) { // Emit events for each diff before storing them for diff in &diff_list { - self.event_emitter.emit(diff); + self.event_emitter.emit(&ModelEvent::Diff(diff.clone())); } self.send_queue.push(QueueDiffs { @@ -1920,7 +1945,7 @@ impl UserModel { fn apply_undo_diff_list(&mut self, diff_list: &DiffList) -> Result<(), String> { let mut needs_evaluation = false; for diff in diff_list.iter().rev() { - self.event_emitter.emit(diff); + self.event_emitter.emit(&ModelEvent::Diff(diff.clone())); match diff { Diff::SetCellValue { sheet, @@ -2036,7 +2061,7 @@ impl UserModel { } // makes sure that the width and style is correct if let Some(col) = &old_data.column { - let width = col.width * constants::COLUMN_WIDTH_FACTOR; + let width = col.width * COLUMN_WIDTH_FACTOR; let style = col.style; worksheet.set_column_width_and_style(*column, width, style)?; } @@ -2188,13 +2213,9 @@ impl UserModel { Diff::DeleteRowStyle { sheet, row, - old_value, + old_value: _, } => { - if let Some(s) = old_value.as_ref() { - self.model.set_row_style(*sheet, *row, s)?; - } else { - self.model.delete_row_style(*sheet, *row)?; - } + self.model.delete_row_style(*sheet, *row)?; } } } @@ -2208,7 +2229,7 @@ impl UserModel { fn apply_diff_list(&mut self, diff_list: &DiffList) -> Result<(), String> { let mut needs_evaluation = false; for diff in diff_list { - self.event_emitter.emit(diff); + self.event_emitter.emit(&ModelEvent::Diff(diff.clone())); match diff { Diff::SetCellValue { sheet, diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index 4cffb672e..c77559b84 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -26,6 +26,14 @@ pub enum DiffType { Redo, } +#[derive(Clone, Encode, Decode, Serialize)] +pub struct CellReference { + pub sheet: u32, + pub row: i32, + pub column: i32, +} + +#[allow(missing_docs)] #[derive(Clone, Encode, Decode, Serialize)] pub enum Diff { // Cell diffs diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index be6475efe..4c66d2d0c 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -2,14 +2,12 @@ mod border; mod border_utils; -mod common; -mod event; -mod history; -mod ui; +pub mod common; +pub mod event; +pub mod history; +pub mod ui; pub use common::UserModel; -pub use event::{EventEmitter, Subscription}; -pub use history::Diff; #[cfg(test)] pub use ui::SelectedView; diff --git a/base/src/user_model/ui.rs b/base/src/user_model/ui.rs index b217b1b4e..fea424427 100644 --- a/base/src/user_model/ui.rs +++ b/base/src/user_model/ui.rs @@ -8,12 +8,19 @@ use super::common::UserModel; #[derive(Serialize, Deserialize)] #[cfg_attr(test, derive(PartialEq, Debug))] +/// Represents the user's current view of the worksheet, including selection and scroll position. pub struct SelectedView { + /// The index of the selected sheet. pub sheet: u32, + /// The selected row. pub row: i32, + /// The selected column. pub column: i32, + /// The selected range, as `[start_row, start_column, end_row, end_column]`. pub range: [i32; 4], + /// The first row visible in the viewport. pub top_row: i32, + /// The first column visible in the viewport. pub left_column: i32, } diff --git a/bindings/wasm/README.pkg.md b/bindings/wasm/README.pkg.md index 446f1f4b1..a1a3399bc 100644 --- a/bindings/wasm/README.pkg.md +++ b/bindings/wasm/README.pkg.md @@ -66,3 +66,25 @@ const icalcBytes = model.saveToIcalc(); const restored = Model.fromIcalcBytes(icalcBytes); ``` + +To listen to cells that change during evaluation (formulas that recalculate): + +```TypeScript +import init, { Model } from "@ironcalc/wasm"; + +await init(); + +const model = new Model("Sheet1", "en", "UTC"); + +model.onCellsEvaluated((cellReferences) => { + // cellReferences is an array of {sheet, row, column} objects + // that represent cells that were recalculated during evaluation + cellReferences.forEach(cell => { + console.log(`Cell ${cell.sheet}:${cell.row}:${cell.column} was evaluated`); + }); +}); + +// Setting a formula will trigger evaluation +model.setUserInput(0, 1, 1, "=SUM(A2:A5)"); +model.setUserInput(0, 2, 1, "10"); // This will trigger re-evaluation of A1 +``` diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 23898f006..fd2640edd 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -119,13 +119,49 @@ impl Model { #[wasm_bindgen(js_name = "onDiffs")] pub fn on_diffs(&mut self, callback: Function) -> Function { - let subscription = self.model.subscribe(move |diff| { - match serde_wasm_bindgen::to_value(diff) { - Ok(js_diff) => { - let _ = callback.call1(&JsValue::NULL, &js_diff); + let subscription = self.model.subscribe(move |event| { + if let ironcalc_base::ModelEvent::Diff(diff) = event { + match serde_wasm_bindgen::to_value(diff) { + Ok(js_diff) => { + let _ = callback.call1(&JsValue::NULL, &js_diff); + } + Err(_e) => { + // Silent skip: if serialization fails, we skip this diff event + } } - Err(_e) => { - // Silent skip: if serialization fails, we skip this diff event + } + }); + + // Store subscription in an Rc> so it can be moved into the closure + let subscription_rc = Rc::new(RefCell::new(Some(subscription))); + let subscription_clone = subscription_rc.clone(); + + // Create the unsubscribe function + let unsubscribe_fn = wasm_bindgen::closure::Closure::wrap(Box::new(move || { + if let Ok(mut sub) = subscription_clone.try_borrow_mut() { + if let Some(subscription) = sub.take() { + subscription.unsubscribe(); + } + } + }) as Box); + + let js_function = unsubscribe_fn.as_ref().unchecked_ref::().clone(); + unsubscribe_fn.forget(); // Prevent the closure from being dropped + + js_function + } + + #[wasm_bindgen(js_name = "onCellsEvaluated")] + pub fn on_cells_evaluated(&mut self, callback: Function) -> Function { + let subscription = self.model.subscribe(move |event| { + if let ironcalc_base::ModelEvent::CellsEvaluated(cells) = event { + match serde_wasm_bindgen::to_value(cells) { + Ok(js_cells) => { + let _ = callback.call1(&JsValue::NULL, &js_cells); + } + Err(_e) => { + // Silent skip: if serialization fails, we skip this event + } } } }); diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index 4a8c1e4ca..7c30fadc1 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -5,6 +5,13 @@ import { setTimeout } from 'node:timers/promises'; const DEFAULT_ROW_HEIGHT = 28; +// Helper to sort cells for consistent comparison +const sortCells = (cells) => cells.sort((a, b) => { + if (a.sheet !== b.sheet) return a.sheet - b.sheet; + if (a.row !== b.row) return a.row - b.row; + return a.column - b.column; +}); + test('Frozen rows and columns', () => { let model = new Model('Workbook1', 'en', 'UTC'); assert.strictEqual(model.getFrozenRowsCount(0), 0); @@ -811,3 +818,133 @@ test('roundtrip via icalc bytes', () => { m2.evaluate(); assert.strictEqual(m2.getFormattedCellValue(0, 1, 2), '36'); }); + +test('onCellsEvaluated tracks formula evaluation', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + // Set up a formula and its dependencies + model.setUserInput(0, 1, 1, '=A2+A3'); // A1 = A2 + A3 + model.setUserInput(0, 2, 1, '10'); // A2 = 10 + model.setUserInput(0, 3, 1, '20'); // A3 = 20 + model.evaluate(); + + // Update a dependency to trigger re-evaluation + model.setUserInput(0, 2, 1, '15'); // Change A2 from 10 to 15 + model.evaluate(); + + await setTimeout(0); + + const expectedCells = [ + // First evaluation of A1 + { sheet: 0, row: 1, column: 1 }, + // Re-evaluation of A1 after dependency changed + { sheet: 0, row: 1, column: 1 }, + ]; + + assert.strictEqual(evaluatedCells.length, expectedCells.length, `Should have exactly ${expectedCells.length} cell evaluation events`); + assert.deepStrictEqual(sortCells(evaluatedCells), sortCells(expectedCells), 'The evaluated cells should match the expected cells'); + + // Verify the formula calculated correctly + const result = model.getFormattedCellValue(0, 1, 1); + assert.strictEqual(result, '35', 'Formula should calculate 15 + 20 = 35'); +}); + +test('onCellsEvaluated tracks complex formula dependencies', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + // Set up a chain of formulas: A1 -> B1 -> C1 + model.setUserInput(0, 1, 1, '10'); // A1 = 10 (base value) + model.setUserInput(0, 1, 2, '=A1*2'); // B1 = A1 * 2 + model.setUserInput(0, 1, 3, '=B1+5'); // C1 = B1 + 5 + model.evaluate(); + + // Update the root value to trigger cascade re-evaluation + model.setUserInput(0, 1, 1, '5'); // Change A1 from 10 to 5 + model.evaluate(); + + await setTimeout(0); + + const expectedCells = [ + // Initial evaluation + { sheet: 0, row: 1, column: 2 }, // B1 + { sheet: 0, row: 1, column: 3 }, // C1 + // Re-evaluation after dependency change + { sheet: 0, row: 1, column: 2 }, // B1 + { sheet: 0, row: 1, column: 3 }, // C1 + ]; + + assert.strictEqual(evaluatedCells.length, expectedCells.length, `Should have exactly ${expectedCells.length} cell evaluation events`); + assert.deepStrictEqual(sortCells(evaluatedCells), sortCells(expectedCells), 'The evaluated cells should match the expected cells'); + + // Verify the formulas calculated correctly + assert.strictEqual(model.getFormattedCellValue(0, 1, 2), '10', 'B1 should be 5 * 2 = 10'); + assert.strictEqual(model.getFormattedCellValue(0, 1, 3), '15', 'C1 should be 10 + 5 = 15'); +}); + +test('onCellsEvaluated only fires after evaluate', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + // Set a formula + model.setUserInput(0, 1, 1, '=1+1'); + + // No cells should be evaluated yet because model.evaluate() has not been called + assert.strictEqual(evaluatedCells.length, 0, 'evaluatedCells should be empty before evaluate()'); + + // Now, trigger the evaluation + model.evaluate(); + await setTimeout(0); + + // Now, the cell should be evaluated + const expectedCells = [ + { sheet: 0, row: 1, column: 1 }, + ]; + + assert.deepStrictEqual(sortCells(evaluatedCells), sortCells(expectedCells), 'evaluatedCells should contain the evaluated cell after evaluate()'); +}); + +test('onCellsEvaluated returns unsubscribe function', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const evaluatedCells = []; + + const unsubscribe = model.onCellsEvaluated(cells => { + evaluatedCells.push(...cells); + }); + + assert.strictEqual(typeof unsubscribe, 'function', 'onCellsEvaluated should return a function'); + + // Set up a formula + model.setUserInput(0, 1, 1, '=A2*2'); + model.setUserInput(0, 2, 1, '5'); + + model.evaluate(); + await setTimeout(0); + + assert.ok(evaluatedCells.length > 0, 'Should have tracked evaluated cells'); + + // Unsubscribe + unsubscribe(); + evaluatedCells.length = 0; + + // Update to trigger re-evaluation + model.setUserInput(0, 2, 1, '10'); + + model.evaluate(); + await setTimeout(0); + + assert.strictEqual(evaluatedCells.length, 0, 'Should not track cells after unsubscribing'); +}); diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 215520f95..3ad22c1ed 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -249,6 +249,12 @@ export interface DefinedName { } // Types for Diffs and QueueDiffs used in getRecentDiffs +export interface CellReference { + sheet: number; + row: number; + column: number; +} + export interface Cell { type: string; // e.g., "NumberCell", "SharedString", "BooleanCell" v?: number | boolean | string; // value, if applicable @@ -520,7 +526,7 @@ export type Diff = | { DeleteDefinedName: DeleteDefinedNameDiff } | { UpdateDefinedName: UpdateDefinedNameDiff }; -// Interface for QueueDiffs + // Interface for QueueDiffs export interface QueueDiffs { type: "Undo" | "Redo"; list: Diff[]; From 9eb1b6868c383b7565bb88201854ca07efb59b67 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Thu, 12 Jun 2025 19:02:36 -0700 Subject: [PATCH 23/25] fix errors and bugs from merge --- base/src/lib.rs | 8 ++++---- base/src/model.rs | 14 -------------- base/src/user_model/common.rs | 11 ++--------- base/src/user_model/history.rs | 7 ------- base/src/user_model/mod.rs | 5 ++++- bindings/wasm/fix_types.py | 29 +++++++++++++---------------- bindings/wasm/src/lib.rs | 15 ++++++++------- bindings/wasm/tests/test.mjs | 2 +- 8 files changed, 32 insertions(+), 59 deletions(-) diff --git a/base/src/lib.rs b/base/src/lib.rs index d010258aa..a4d684243 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -62,11 +62,11 @@ pub use model::Model; pub use user_model::BorderArea; pub use user_model::ClipboardData; -pub use user_model::ModelEvent; pub use user_model::Diff; pub use user_model::DiffType; -pub use user_model::QueueDiffs; -pub use user_model::UserModel; pub use user_model::EventEmitter; -pub use user_model::Subscription; +pub use user_model::ModelEvent; +pub use user_model::QueueDiffs; pub use user_model::SelectedView; +pub use user_model::Subscription; +pub use user_model::UserModel; diff --git a/base/src/model.rs b/base/src/model.rs index 9dc5ad976..4ccb12aed 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -1807,20 +1807,6 @@ impl Model { } } - /// Returns a list of all cells that have been changed or are being evaluated - pub fn get_changed_cells(&self) -> Vec { - self.cells - .keys() - .map( - |&(sheet, row, column)| crate::user_model::history::CellReference { - sheet, - row, - column, - }, - ) - .collect() - } - /// Removes the content of the cell but leaves the style. /// /// See also: diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index c143e5eb6..7a52b38a5 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -18,9 +18,7 @@ use crate::{ }, user_model::{ event::{EventEmitter, Subscription}, - history::{ - CellReference, ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, - }, + history::{ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData}, }, utils::is_valid_hex_color, }; @@ -405,11 +403,6 @@ impl UserModel { } } - /// Returns a list of all cells that have been changed or are being evaluated - pub fn get_changed_cells(&self) -> Vec { - self.model.get_changed_cells() - } - /// Returns the list of pending diffs and removes them from the queue /// /// This is used together with [apply_external_diffs](UserModel::apply_external_diffs) to keep two remote models @@ -2432,7 +2425,7 @@ impl UserModel { pub fn get_changed_cells(&self) -> Vec { self.model.get_changed_cells() } - + /// Returns the current send queue as a vector of QueueDiffs without removing the diffs. /// /// This is used to inspect recent changes without affecting the queue. diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index c77559b84..cee8d82de 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -26,13 +26,6 @@ pub enum DiffType { Redo, } -#[derive(Clone, Encode, Decode, Serialize)] -pub struct CellReference { - pub sheet: u32, - pub row: i32, - pub column: i32, -} - #[allow(missing_docs)] #[derive(Clone, Encode, Decode, Serialize)] pub enum Diff { diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index 4c66d2d0c..623ceaffc 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -9,10 +9,13 @@ pub mod ui; pub use common::UserModel; -#[cfg(test)] pub use ui::SelectedView; pub use common::BorderArea; pub use common::ClipboardData; pub use history::DiffType; pub use history::QueueDiffs; + +pub use common::ModelEvent; +pub use event::{EventEmitter, Subscription}; +pub use history::Diff; diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index 5c8c14c2c..d38d6418a 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -9,30 +9,27 @@ def fix_types(text: str): header_types = "{}\n\n{}".format(header, types_str) text = text.replace(header, header_types) + + lines_with_any = [] for line in text.splitlines(): line = line.lstrip() # Skip internal methods if line.startswith("readonly model_"): continue if line.find("any") != -1: - print("There are 'unfixed' public types. Please check.") - exit(1) + lines_with_any.append(line) + + if len(lines_with_any) > 0: + print("There are 'unfixed' public types. Please check.\n", "\n".join(lines_with_any)) + exit(1) return text if __name__ == "__main__": - dts_files = ["pkg/ironcalc.d.ts", "pkg/xlsx.d.ts"] - for types_file in dts_files: - with open(types_file) as f: - text = f.read() - text = fix_types(text) - with open(types_file, "wb") as f: - f.write(bytes(text, "utf8")) - - js_files = ["pkg/ironcalc.js", "pkg/xlsx.js"] - with open("types.js") as f: - text_js = f.read() - - with open(js_file, "wb") as f: - f.write(bytes("{}\n{}".format(text_js, text), "utf8")) + types_file = "pkg/ironcalc.d.ts" + with open(types_file) as f: + text = f.read() + text = fix_types(text) + with open(types_file, "wb") as f: + f.write(bytes(text, "utf8")) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index a05c3cb71..77f7b3bd4 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use js_sys::Function; +use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::{ @@ -182,12 +182,10 @@ impl Model { .map_err(to_js_error) } - #[wasm_bindgen(js_name = "newSheet")] + #[wasm_bindgen(js_name = "newSheet", unchecked_return_type = "NewSheetResult")] pub fn new_sheet(&mut self) -> Result { let (name, sheet_index) = self.model.new_sheet().map_err(to_js_error)?; - let result = NewSheetResult { name, sheet_index }; - serde_wasm_bindgen::to_value(&result).map_err(|e| to_js_error(e.to_string())) } @@ -417,7 +415,10 @@ impl Model { .unwrap_or_default()) } - #[wasm_bindgen(js_name = "getSheetDimensions")] + #[wasm_bindgen( + js_name = "getSheetDimensions", + unchecked_return_type = "WorksheetDimension" + )] pub fn get_sheet_dimensions(&self, sheet: u32) -> Result { let dimension = self .model @@ -786,13 +787,13 @@ impl Model { .map_err(|e| to_js_error(e.to_string())) } - #[wasm_bindgen(js_name = "getChangedCells")] + #[wasm_bindgen(js_name = "getChangedCells", unchecked_return_type = "CellReference[]")] pub fn get_changed_cells(&self) -> JsValue { let changed_cells = self.model.get_changed_cells(); serde_wasm_bindgen::to_value(&changed_cells).unwrap_or_else(|_| JsValue::undefined()) } - #[wasm_bindgen(js_name = "getRecentDiffs")] + #[wasm_bindgen(js_name = "getRecentDiffs", unchecked_return_type = "Diff[]")] pub fn get_recent_diffs(&self) -> JsValue { serde_wasm_bindgen::to_value(&self.model.get_recent_diffs()) .unwrap_or_else(|_| JsValue::undefined()) diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index a9532e243..ce222e0ea 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -107,7 +107,6 @@ test("newSheet returns sheet result", (t) => { // Test first new sheet - should be at index 1 const result1 = model.newSheet(); - console.log(result1); assert.strictEqual(result1.name, "Sheet2"); assert.strictEqual(result1.sheet_index, 1); // This is the sheet index (position) @@ -389,6 +388,7 @@ test('track changed cells - move column updates formulas', () => { assert.strictEqual(changedCells.length, 1, 'One cell should be marked as changed after column insertion'); assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 3 }, 'Changed cell should be C1 due to formula update after column shift'); }); + test('onDiffs', async () => { const model = new Model('Workbook1', 'en', 'UTC'); const events = []; From 4da4bb7360e7a557d62d31e9bd486c0a11468d8c Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Fri, 13 Jun 2025 01:20:15 -0700 Subject: [PATCH 24/25] refactor: push diffs array instead of diff --- base/src/user_model/common.rs | 18 ++++++++---------- bindings/wasm/src/lib.rs | 8 ++++---- bindings/wasm/tests/test.mjs | 24 ++++++++++++------------ 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 7a52b38a5..3fe9e62cd 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -27,8 +27,8 @@ use super::border_utils::is_max_border; /// Events that the `UserModel` can emit. pub enum ModelEvent { - /// A diff that can be undone/redone. - Diff(Diff), + /// A list of diffs (a single user action) that can be undone/redone. + Diffs(DiffList), /// An informational event that cells have been evaluated. CellsEvaluated(Vec), } @@ -1917,15 +1917,12 @@ impl UserModel { // **** Private methods ****** // pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) { - // Emit events for each diff before storing them - for diff in &diff_list { - self.event_emitter.emit(&ModelEvent::Diff(diff.clone())); - } - self.send_queue.push(QueueDiffs { r#type: DiffType::Redo, list: diff_list.clone(), }); + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); self.history.push(diff_list); } @@ -1938,7 +1935,6 @@ impl UserModel { fn apply_undo_diff_list(&mut self, diff_list: &DiffList) -> Result<(), String> { let mut needs_evaluation = false; for diff in diff_list.iter().rev() { - self.event_emitter.emit(&ModelEvent::Diff(diff.clone())); match diff { Diff::SetCellValue { sheet, @@ -2212,6 +2208,8 @@ impl UserModel { } } } + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); if needs_evaluation { self.evaluate_if_not_paused(); } @@ -2222,7 +2220,6 @@ impl UserModel { fn apply_diff_list(&mut self, diff_list: &DiffList) -> Result<(), String> { let mut needs_evaluation = false; for diff in diff_list { - self.event_emitter.emit(&ModelEvent::Diff(diff.clone())); match diff { Diff::SetCellValue { sheet, @@ -2414,7 +2411,8 @@ impl UserModel { } } } - + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); if needs_evaluation { self.evaluate_if_not_paused(); } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 77f7b3bd4..316dee864 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -96,10 +96,10 @@ impl Model { #[wasm_bindgen(js_name = "onDiffs")] pub fn on_diffs(&mut self, callback: Function) -> Function { let subscription = self.model.subscribe(move |event| { - if let ironcalc_base::ModelEvent::Diff(diff) = event { - match serde_wasm_bindgen::to_value(diff) { - Ok(js_diff) => { - let _ = callback.call1(&JsValue::NULL, &js_diff); + if let ironcalc_base::ModelEvent::Diffs(diffs) = event { + match serde_wasm_bindgen::to_value(diffs) { + Ok(js_diffs) => { + let _ = callback.call1(&JsValue::NULL, &js_diffs); } Err(_e) => { // Silent skip: if serialization fails, we skip this diff event diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index ce222e0ea..c06c345c1 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -393,8 +393,8 @@ test('onDiffs', async () => { const model = new Model('Workbook1', 'en', 'UTC'); const events = []; - model.onDiffs(diff => { - events.push(diff); + model.onDiffs(diffs => { + events.push(...diffs); }); model.setUserInput(0, 1, 1, 'test'); @@ -439,8 +439,8 @@ test('onDiffs emits correct diff types for various operations', async () => { const model = new Model('Workbook1', 'en', 'UTC'); const events = []; - model.onDiffs(diff => { - events.push(diff); + model.onDiffs(diffs => { + events.push(...diffs); }); // Test various operations that should emit different diff types @@ -537,8 +537,8 @@ test('onDiffs emits full diff objects for undo/redo operations', async () => { const model = new Model('Workbook1', 'en', 'UTC'); const events = []; - model.onDiffs(diff => { - events.push(diff); + model.onDiffs(diffs => { + events.push(...diffs); }); // Perform initial operations @@ -629,8 +629,8 @@ test('onDiffs handles multiple subscribers and provides full diff objects', asyn const model = new Model('Workbook1', 'en', 'UTC'); const events = []; - model.onDiffs(diff => { - events.push(diff); + model.onDiffs(diffs => { + events.push(...diffs); }); // Perform complex operations that generate multiple diffs @@ -747,12 +747,12 @@ test('onDiffs returns unregister function that works correctly', async () => { const events2 = []; // Register two listeners - const unregister1 = model.onDiffs(diff => { - events1.push(diff); + const unregister1 = model.onDiffs(diffs => { + events1.push(...diffs); }); - const unregister2 = model.onDiffs(diff => { - events2.push(diff); + const unregister2 = model.onDiffs(diffs => { + events2.push(...diffs); }); // Both should be functions From 2f5346aa87446996c4884564fc9f7478b0e8a6da Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Fri, 13 Jun 2025 14:44:37 -0700 Subject: [PATCH 25/25] refactor: add type to diffs and queue diffs for get_recent_diffs --- base/src/user_model/history.rs | 2 + bindings/wasm/src/lib.rs | 2 +- bindings/wasm/tests/test.mjs | 525 ++++++++++++++++++--------------- bindings/wasm/types.ts | 87 ++++-- 4 files changed, 351 insertions(+), 265 deletions(-) diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index cee8d82de..15fc1367d 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -28,6 +28,7 @@ pub enum DiffType { #[allow(missing_docs)] #[derive(Clone, Encode, Decode, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] pub enum Diff { // Cell diffs SetCellValue { @@ -213,6 +214,7 @@ impl History { /// A collection of diffs that can be applied to a model. /// This represents a single operation that can be undone or redone. #[derive(Clone, Encode, Decode, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] pub struct QueueDiffs { /// The type of operation this represents (Undo or Redo) pub r#type: DiffType, diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 316dee864..bc6068260 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -793,7 +793,7 @@ impl Model { serde_wasm_bindgen::to_value(&changed_cells).unwrap_or_else(|_| JsValue::undefined()) } - #[wasm_bindgen(js_name = "getRecentDiffs", unchecked_return_type = "Diff[]")] + #[wasm_bindgen(js_name = "getRecentDiffs", unchecked_return_type = "QueueDiffs[]")] pub fn get_recent_diffs(&self) -> JsValue { serde_wasm_bindgen::to_value(&self.model.get_recent_diffs()) .unwrap_or_else(|_| JsValue::undefined()) diff --git a/bindings/wasm/tests/test.mjs b/bindings/wasm/tests/test.mjs index c06c345c1..dc0060c2e 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -173,111 +173,195 @@ test('track changed cells - basic update', () => { assert.deepEqual(changedCells[0], { sheet: 0, row: 1, column: 2 }, 'Second changed cell should be B1'); }); -test('getRecentDiffs returns recent diffs without modifying queue', () => { +test('getRecentDiffs captures setCellValue diff', () => { const model = new Model('Workbook1', 'en', 'UTC'); - // Perform some actions to generate diffs - model.setUserInput(0, 1, 1, "42"); - model.setUserInput(0, 1, 2, "=A1*2"); + // Set a cell value to generate a SetCellValue diff + model.setUserInput(0, 2, 3, "99"); // Get recent diffs const diffs = model.getRecentDiffs(); - assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after actions'); - - // Check structure of diffs - regular operations are marked as "Redo" type - const firstDiff = diffs[0]; - assert.strictEqual(firstDiff.type, 'Redo', 'Regular operations should be of type Redo'); - assert.strictEqual(Array.isArray(firstDiff.list), true, 'Diff entry should have a list of diffs'); - assert.strictEqual(firstDiff.list.length > 0, true, 'Diff list should not be empty'); - - // Look for SetCellValue diff in any of the diff entries - let foundSetCellValue = false; - for (const diffEntry of diffs) { - const setCellDiff = diffEntry.list.find(d => d.SetCellValue && d.SetCellValue.row === 1 && d.SetCellValue.column === 1); - if (setCellDiff) { - assert.strictEqual(setCellDiff.SetCellValue.new_value, '42', 'New value for A1 should be 42'); - foundSetCellValue = true; - break; + assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after setting cell value'); + + const expectedDiffs = [ + { + type: 'Redo', + list: [{ + type: "setCellValue", + sheet: 0, + row: 2, + column: 3, + new_value: "99", + old_value: undefined + }] } - } - assert.ok(foundSetCellValue, 'Should find a SetCellValue diff for cell A1 somewhere in the diffs'); + ]; + + // Verify we got the expected number of diffs + assert.strictEqual(diffs.length, expectedDiffs.length, `Should have exactly ${expectedDiffs.length} diffs`); - // Verify queue is not modified by checking again + // Compare each diff with deep equality + for (let i = 0; i < expectedDiffs.length; i++) { + assert.deepStrictEqual(diffs[i], expectedDiffs[i], `Diff ${i} should match expected diff`); + } +}); + +test('getRecentDiffs returns recent diffs without modifying queue', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + + // Perform two separate actions that should generate **two** QueueDiffs in the send queue + model.setUserInput(0, 1, 1, '42'); // first diff list + model.setUserInput(0, 1, 2, '=A1*2'); // second diff list + + const diffs = model.getRecentDiffs(); + + const expectedDiffs = [ + { + type: 'Redo', + list: [ + { + type: 'setCellValue', + sheet: 0, + row: 1, + column: 1, + new_value: '42', + old_value: undefined, + }, + ], + }, + { + type: 'Redo', + list: [ + { + type: 'setCellValue', + sheet: 0, + row: 1, + column: 2, + new_value: '=A1*2', + old_value: undefined, + }, + ], + }, + ]; + + // Validate that we received exactly the expected diff objects + assert.deepStrictEqual(diffs, expectedDiffs, 'Diffs should match expected structure'); + + // Subsequent calls should not modify the queue const diffsAgain = model.getRecentDiffs(); - assert.strictEqual(diffsAgain.length, diffs.length, 'Queue length should remain the same after multiple calls'); assert.deepStrictEqual(diffsAgain, diffs, 'Queue contents should remain unchanged after multiple calls'); }); test('getRecentDiffs captures style changes', () => { const model = new Model('Workbook1', 'en', 'UTC'); + // Perform a style change - model.updateRangeStyle({ sheet: 0, row: 1, column: 1, width: 1, height: 1 }, 'font.b', 'true'); - - // Get recent diffs + model.updateRangeStyle( + { sheet: 0, row: 1, column: 1, width: 1, height: 1 }, + 'font.b', + 'true', + ); + const diffs = model.getRecentDiffs(); - assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after style change'); - - // Look for SetCellStyle diff in any of the diff entries - let foundStyleDiff = false; - for (const diffEntry of diffs) { - const styleDiff = diffEntry.list.find(d => d.SetCellStyle); - if (styleDiff) { - assert.strictEqual(styleDiff.SetCellStyle.sheet, 0, 'Sheet index should be 0'); - assert.strictEqual(styleDiff.SetCellStyle.row, 1, 'Row should be 1'); - assert.strictEqual(styleDiff.SetCellStyle.column, 1, 'Column should be 1'); - assert.ok(styleDiff.SetCellStyle.new_value.font.b, 'New style should have bold set to true'); - foundStyleDiff = true; - break; - } - } - assert.ok(foundStyleDiff, 'Should find a SetCellStyle diff after style update'); + + const expectedDiffs = [ + { + type: 'Redo', + list: [ + { + type: 'setCellStyle', + sheet: 0, + row: 1, + column: 1, + new_value: { + num_fmt: 'general', + fill: { pattern_type: 'none' }, + font: { + sz: 13, + color: '#000000', + name: 'Calibri', + family: 2, + scheme: 'minor', + b: true, + }, + border: {}, + quote_prefix: false, + }, + old_value: undefined, + }, + ], + }, + ]; + + assert.deepStrictEqual(diffs, expectedDiffs, 'Style diff should match expected structure'); }); test('getRecentDiffs captures undo and redo diffs', () => { const model = new Model('Workbook1', 'en', 'UTC'); - // Perform an action and undo it - model.setUserInput(0, 1, 1, "100"); + + // Initial action + model.setUserInput(0, 1, 1, '100'); // QueueDiffs[0] (Redo) + + // Undo that action -> QueueDiffs[1] (Undo) model.undo(); - - // Get recent diffs - const diffs = model.getRecentDiffs(); - assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after undo'); - - // Check for Undo type in diffs - const undoDiff = diffs.find(d => d.type === 'Undo'); - assert.ok(undoDiff, 'Should find an Undo diff entry after undo operation'); - assert.strictEqual(undoDiff.list.length > 0, true, 'Undo diff list should not be empty'); - - // Redo the action + + const diffsAfterUndo = model.getRecentDiffs(); + + const expectedAfterUndo = [ + { + type: 'Redo', + list: [ + { + type: 'setCellValue', + sheet: 0, + row: 1, + column: 1, + new_value: '100', + old_value: undefined, + }, + ], + }, + { + type: 'Undo', + list: [ + { + type: 'setCellValue', + sheet: 0, + row: 1, + column: 1, + new_value: '100', + old_value: undefined, + }, + ], + }, + ]; + + assert.deepStrictEqual(diffsAfterUndo, expectedAfterUndo, 'Undo diff should be recorded correctly'); + + // Redo the action -> QueueDiffs[2] (Redo) model.redo(); + const diffsAfterRedo = model.getRecentDiffs(); - const redoDiff = diffsAfterRedo.find(d => d.type === 'Redo'); - assert.ok(redoDiff, 'Should find a Redo diff entry after redo operation'); -}); -test('getRecentDiffs captures setCellValue diff', () => { - const model = new Model('Workbook1', 'en', 'UTC'); - // Set a cell value to generate a SetCellValue diff - model.setUserInput(0, 2, 3, "99"); - - // Get recent diffs - const diffs = model.getRecentDiffs(); - assert.strictEqual(diffs.length > 0, true, 'Diffs array should not be empty after setting cell value'); - - // Look for SetCellValue diff in any of the diff entries - let foundSetCellDiff = false; - for (const diffEntry of diffs) { - const setCellDiff = diffEntry.list.find(d => d.SetCellValue); - if (setCellDiff) { - assert.strictEqual(setCellDiff.SetCellValue.sheet, 0, 'Sheet index should be 0'); - assert.strictEqual(setCellDiff.SetCellValue.row, 2, 'Row should be 2'); - assert.strictEqual(setCellDiff.SetCellValue.column, 3, 'Column should be 3'); - assert.strictEqual(setCellDiff.SetCellValue.new_value, '99', 'New value should be 99'); - foundSetCellDiff = true; - break; - } - } - assert.ok(foundSetCellDiff, 'Should find a SetCellValue diff after setting cell value'); + const expectedAfterRedo = [ + ...expectedAfterUndo, + { + type: 'Redo', + list: [ + { + type: 'setCellValue', + sheet: 0, + row: 1, + column: 1, + new_value: '100', + old_value: undefined, + }, + ], + }, + ]; + + assert.deepStrictEqual(diffsAfterRedo, expectedAfterRedo, 'Redo diff should be recorded correctly'); }); + test("getSheetDimensions", () => { const model = new Model('Workbook1', 'en', 'UTC'); @@ -402,14 +486,13 @@ test('onDiffs', async () => { const expectedEvents = [ { - SetCellValue: { - sheet: 0, - row: 1, - column: 1, - new_value: 'test', - old_value: undefined - } - }, + type: "setCellValue", + sheet: 0, + row: 1, + column: 1, + new_value: "test", + old_value: undefined + } ]; // Verify we got the expected number of events @@ -459,68 +542,59 @@ test('onDiffs emits correct diff types for various operations', async () => { const expectedEvents = [ { - SetCellValue: { - sheet: 0, - row: 1, - column: 1, - new_value: '42', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 1, + column: 1, + new_value: "42", + old_value: undefined }, { - InsertRow: { - sheet: 0, - row: 2 - } + type: "insertRow", + sheet: 0, + row: 2 }, { - SetRowHeight: { - sheet: 0, - row: 1, - new_value: 35, - old_value: 28 - } + type: "setRowHeight", + sheet: 0, + row: 1, + new_value: 35, + old_value: 28 }, { - InsertColumn: { - sheet: 0, - column: 2 - } + type: "insertColumn", + sheet: 0, + column: 2 }, { - SetColumnWidth: { - sheet: 0, - column: 1, - new_value: 120, - old_value: 125 - } + type: "setColumnWidth", + sheet: 0, + column: 1, + new_value: 120, + old_value: 125 }, { - NewSheet: { - index: 1, - name: 'Sheet2' - } + type: "newSheet", + index: 1, + name: "Sheet2" }, { - RenameSheet: { - index: 1, - old_value: 'Sheet2', - new_value: 'TestSheet' - } + type: "renameSheet", + index: 1, + old_value: "Sheet2", + new_value: "TestSheet" }, { - SetFrozenRowsCount: { - sheet: 0, - new_value: 2, - old_value: 0 - } + type: "setFrozenRowsCount", + sheet: 0, + new_value: 2, + old_value: 0 }, { - SetFrozenColumnsCount: { - sheet: 0, - new_value: 3, - old_value: 0 - } + type: "setFrozenColumnsCount", + sheet: 0, + new_value: 3, + old_value: 0 } ]; @@ -559,60 +633,53 @@ test('onDiffs emits full diff objects for undo/redo operations', async () => { const expectedEvents = [ // Initial operations (3 events) { - SetCellValue: { - sheet: 0, - row: 1, - column: 1, - new_value: 'Hello', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 1, + column: 1, + new_value: "Hello", + old_value: undefined }, { - SetCellValue: { - sheet: 0, - row: 1, - column: 2, - new_value: 'World', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 1, + column: 2, + new_value: "World", + old_value: undefined }, { - InsertRow: { - sheet: 0, - row: 2 - } + type: "insertRow", + sheet: 0, + row: 2 }, // Undo operations (2 events) - Note: these emit the same diff structures as the forward operations { - InsertRow: { - sheet: 0, - row: 2 - } + type: "insertRow", + sheet: 0, + row: 2 }, { - SetCellValue: { - sheet: 0, - row: 1, - column: 2, - new_value: 'World', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 1, + column: 2, + new_value: "World", + old_value: undefined }, // Redo operations (2 events) - These also emit the same diff structures { - SetCellValue: { - sheet: 0, - row: 1, - column: 2, - new_value: 'World', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 1, + column: 2, + new_value: "World", + old_value: undefined }, { - InsertRow: { - sheet: 0, - row: 2 - } + type: "insertRow", + sheet: 0, + row: 2 } ]; @@ -651,82 +718,74 @@ test('onDiffs handles multiple subscribers and provides full diff objects', asyn const expectedEvents = [ // SetUserInput operations (4 events) { - SetCellValue: { - sheet: 0, - row: 1, - column: 1, - new_value: '=SUM(A2:A5)', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 1, + column: 1, + new_value: "=SUM(A2:A5)", + old_value: undefined }, { - SetCellValue: { - sheet: 0, - row: 2, - column: 1, - new_value: '10', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 2, + column: 1, + new_value: "10", + old_value: undefined }, { - SetCellValue: { - sheet: 0, - row: 3, - column: 1, - new_value: '20', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 3, + column: 1, + new_value: "20", + old_value: undefined }, { - SetCellValue: { - sheet: 0, - row: 4, - column: 1, - new_value: '30', - old_value: undefined - } + type: "setCellValue", + sheet: 0, + row: 4, + column: 1, + new_value: "30", + old_value: undefined }, // Row operations (2 events) { - InsertRow: { - sheet: 0, - row: 2 - } + type: "insertRow", + sheet: 0, + row: 2 }, { - DeleteRow: { - sheet: 0, - row: 2, - old_data: { - data: new Map(), - row: undefined - } + type: "deleteRow", + sheet: 0, + row: 2, + old_data: { + data: new Map(), + row: undefined, } }, // Range clear operations (2 events) { - CellClearContents: { - sheet: 0, - row: 2, - column: 1, - old_value: { - NumberCell: { - v: 10, - s: 0 - } + type: "cellClearContents", + sheet: 0, + row: 2, + column: 1, + old_value: { + NumberCell: { + v: 10, + s: 0 } } }, { - CellClearContents: { - sheet: 0, - row: 3, - column: 1, - old_value: { - NumberCell: { - v: 20, - s: 0 - } + type: "cellClearContents", + sheet: 0, + row: 3, + column: 1, + old_value: { + NumberCell: { + v: 20, + s: 0 } } } diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 3ad22c1ed..315118990 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -331,6 +331,7 @@ export interface Worksheet { // Individual Diff type interfaces for better developer experience export interface SetCellValueDiff { + type: "setCellValue"; sheet: number; row: number; column: number; @@ -339,6 +340,7 @@ export interface SetCellValueDiff { } export interface CellClearContentsDiff { + type: "cellClearContents"; sheet: number; row: number; column: number; @@ -346,6 +348,7 @@ export interface CellClearContentsDiff { } export interface CellClearAllDiff { + type: "cellClearAll"; sheet: number; row: number; column: number; @@ -354,6 +357,7 @@ export interface CellClearAllDiff { } export interface CellClearFormattingDiff { + type: "cellClearFormatting"; sheet: number; row: number; column: number; @@ -361,6 +365,7 @@ export interface CellClearFormattingDiff { } export interface SetCellStyleDiff { + type: "setCellStyle"; sheet: number; row: number; column: number; @@ -369,6 +374,7 @@ export interface SetCellStyleDiff { } export interface SetColumnWidthDiff { + type: "setColumnWidth"; sheet: number; column: number; new_value: number; @@ -376,6 +382,7 @@ export interface SetColumnWidthDiff { } export interface SetRowHeightDiff { + type: "setRowHeight"; sheet: number; row: number; new_value: number; @@ -383,6 +390,7 @@ export interface SetRowHeightDiff { } export interface SetColumnStyleDiff { + type: "setColumnStyle"; sheet: number; column: number; old_value?: Style | null; @@ -390,6 +398,7 @@ export interface SetColumnStyleDiff { } export interface SetRowStyleDiff { + type: "setRowStyle"; sheet: number; row: number; old_value?: Style | null; @@ -397,98 +406,115 @@ export interface SetRowStyleDiff { } export interface DeleteColumnStyleDiff { + type: "deleteColumnStyle"; sheet: number; column: number; old_value?: Style | null; } export interface DeleteRowStyleDiff { + type: "deleteRowStyle"; sheet: number; row: number; old_value?: Style | null; } export interface InsertRowDiff { + type: "insertRow"; sheet: number; row: number; } export interface DeleteRowDiff { + type: "deleteRow"; sheet: number; row: number; old_data: RowData; } export interface InsertColumnDiff { + type: "insertColumn"; sheet: number; column: number; } export interface DeleteColumnDiff { + type: "deleteColumn"; sheet: number; column: number; old_data: ColumnData; } export interface DeleteSheetDiff { + type: "deleteSheet"; sheet: number; old_data: Worksheet; } export interface SetFrozenRowsCountDiff { + type: "setFrozenRowsCount"; sheet: number; new_value: number; old_value: number; } export interface SetFrozenColumnsCountDiff { + type: "setFrozenColumnsCount"; sheet: number; new_value: number; old_value: number; } export interface NewSheetDiff { + type: "newSheet"; index: number; name: string; } export interface RenameSheetDiff { + type: "renameSheet"; index: number; old_value: string; new_value: string; } export interface SetSheetColorDiff { + type: "setSheetColor"; index: number; old_value: string; new_value: string; } export interface SetSheetStateDiff { + type: "setSheetState"; index: number; old_value: string; new_value: string; } export interface SetShowGridLinesDiff { + type: "setShowGridLines"; sheet: number; old_value: boolean; new_value: boolean; } export interface CreateDefinedNameDiff { + type: "createDefinedName"; name: string; scope?: number; value: string; } export interface DeleteDefinedNameDiff { + type: "deleteDefinedName"; name: string; scope?: number; old_value: string; } export interface UpdateDefinedNameDiff { + type: "updateDefinedName"; name: string; scope?: number; old_formula: string; @@ -497,38 +523,37 @@ export interface UpdateDefinedNameDiff { new_formula: string; } -// Union type for all Diff variants - these are serialized as tagged enums with variant names as keys -export type Diff = - | { SetCellValue: SetCellValueDiff } - | { CellClearContents: CellClearContentsDiff } - | { CellClearAll: CellClearAllDiff } - | { CellClearFormatting: CellClearFormattingDiff } - | { SetCellStyle: SetCellStyleDiff } - | { SetColumnWidth: SetColumnWidthDiff } - | { SetRowHeight: SetRowHeightDiff } - | { SetColumnStyle: SetColumnStyleDiff } - | { SetRowStyle: SetRowStyleDiff } - | { DeleteColumnStyle: DeleteColumnStyleDiff } - | { DeleteRowStyle: DeleteRowStyleDiff } - | { InsertRow: InsertRowDiff } - | { DeleteRow: DeleteRowDiff } - | { InsertColumn: InsertColumnDiff } - | { DeleteColumn: DeleteColumnDiff } - | { DeleteSheet: DeleteSheetDiff } - | { SetFrozenRowsCount: SetFrozenRowsCountDiff } - | { SetFrozenColumnsCount: SetFrozenColumnsCountDiff } - | { NewSheet: NewSheetDiff } - | { RenameSheet: RenameSheetDiff } - | { SetSheetColor: SetSheetColorDiff } - | { SetSheetState: SetSheetStateDiff } - | { SetShowGridLines: SetShowGridLinesDiff } - | { CreateDefinedName: CreateDefinedNameDiff } - | { DeleteDefinedName: DeleteDefinedNameDiff } - | { UpdateDefinedName: UpdateDefinedNameDiff }; - - // Interface for QueueDiffs +export type Diff = + | SetCellValueDiff + | CellClearContentsDiff + | CellClearAllDiff + | CellClearFormattingDiff + | SetCellStyleDiff + | SetColumnWidthDiff + | SetRowHeightDiff + | SetColumnStyleDiff + | SetRowStyleDiff + | DeleteColumnStyleDiff + | DeleteRowStyleDiff + | InsertRowDiff + | DeleteRowDiff + | InsertColumnDiff + | DeleteColumnDiff + | DeleteSheetDiff + | SetFrozenRowsCountDiff + | SetFrozenColumnsCountDiff + | NewSheetDiff + | RenameSheetDiff + | SetSheetColorDiff + | SetSheetStateDiff + | SetShowGridLinesDiff + | CreateDefinedNameDiff + | DeleteDefinedNameDiff + | UpdateDefinedNameDiff; + +// Interface for QueueDiffs export interface QueueDiffs { - type: "Undo" | "Redo"; + type: "undo" | "redo"; list: Diff[]; } export interface WorksheetDimension {