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 cbcdd9932..89365ce3f 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" @@ -872,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" @@ -901,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", @@ -932,40 +777,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 +827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde", @@ -1022,12 +840,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 +868,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,7 +878,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" name = "wasm" version = "0.5.0" dependencies = [ + "ironcalc", "ironcalc_base", + "js-sys", "serde", "serde-wasm-bindgen", "wasm-bindgen", @@ -1081,23 +889,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 +927,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 +937,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 +950,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" @@ -1252,45 +1064,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/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/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 08fc1072f..a4d684243 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -59,6 +59,14 @@ 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::Diff; +pub use user_model::DiffType; +pub use user_model::EventEmitter; +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 7ceb5363f..4ccb12aed 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::{Deserialize, Serialize}; + /// 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,14 @@ 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/types.rs b/base/src/types.rs index 07b41bc87..2b193324e 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, Eq)] 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..3fe9e62cd 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -6,24 +6,33 @@ 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}, }, - model::Model, + model::{CellReference, Model}, types::{ Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState, Style, VerticalAlignment, }, + user_model::{ + event::{EventEmitter, Subscription}, + history::{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; + +/// Events that the `UserModel` can emit. +pub enum ModelEvent { + /// 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), +} + /// Data for the clipboard pub type ClipboardData = HashMap>; @@ -223,6 +232,7 @@ pub struct UserModel { history: History, send_queue: Vec, pause_evaluation: bool, + event_emitter: EventEmitter, } impl Debug for UserModel { @@ -239,6 +249,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), } } @@ -253,6 +264,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), }) } @@ -267,6 +279,7 @@ impl UserModel { history: History::default(), send_queue: vec![], pause_evaluation: false, + event_emitter: EventEmitter::default(), }) } @@ -351,13 +364,43 @@ impl UserModel { self.pause_evaluation = false; } + /// 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 + where + F: Fn(&ModelEvent) + 'static, + { + self.event_emitter.subscribe(listener) + } + + /// 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 + where + F: Fn(&ModelEvent) + Send + Sync + 'static, + { + self.event_emitter.subscribe(listener) + } + /// Forces an evaluation of the model /// /// See also: /// * [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 the list of pending diffs and removes them from the queue @@ -487,11 +530,14 @@ 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 @@ -704,7 +750,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -734,7 +780,7 @@ impl UserModel { sheet, row: row.r, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -786,7 +832,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -841,7 +887,7 @@ impl UserModel { sheet, row, column, - old_style: Box::new(None), + old_style: Box::new(Some(old_style)), }); } } @@ -1680,13 +1726,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 @@ -1868,7 +1914,6 @@ impl UserModel { self.evaluate_if_not_paused(); Ok(()) } - // **** Private methods ****** // pub(crate) fn push_diff_list(&mut self, diff_list: DiffList) { @@ -1876,6 +1921,8 @@ impl UserModel { r#type: DiffType::Redo, list: diff_list.clone(), }); + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); self.history.push(diff_list); } @@ -2003,7 +2050,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)?; } @@ -2155,16 +2202,14 @@ 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)?; } } } + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); if needs_evaluation { self.evaluate_if_not_paused(); } @@ -2366,12 +2411,28 @@ impl UserModel { } } } - + self.event_emitter + .emit(&ModelEvent::Diffs(diff_list.clone())); if needs_evaluation { self.evaluate_if_not_paused(); } 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() + } + + /// 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)] diff --git a/base/src/user_model/event.rs b/base/src/user_model/event.rs new file mode 100644 index 000000000..0e34d4b57 --- /dev/null +++ b/base/src/user_model/event.rs @@ -0,0 +1,125 @@ +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, RwLock, Weak, + }, +}; + +// 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, + inner: Weak>, +} + +impl Subscription { + /// explicitly unsubscribe early + pub fn unsubscribe(self) { + if let Some(inner) = self.inner.upgrade() { + if let Ok(mut listeners) = inner.listeners.write() { + listeners.remove(&self.id); + } + } + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + if let Ok(mut listeners) = inner.listeners.write() { + listeners.remove(&self.id); + } + } + } +} + +struct Inner { + 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(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); + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.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(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); + if let Ok(mut listeners) = self.inner.listeners.write() { + listeners.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 { + 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 + if let Ok(read) = self.inner.listeners.read() { + 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..15fc1367d 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -1,23 +1,35 @@ 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 { +/// Represents the type of a diff operation +#[derive(Clone, Encode, Decode, Serialize)] +pub enum DiffType { + /// An undo operation + Undo, + /// A redo operation + Redo, +} + +#[allow(missing_docs)] +#[derive(Clone, Encode, Decode, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Diff { // Cell diffs SetCellValue { sheet: u32, @@ -199,14 +211,13 @@ impl History { } } -#[derive(Clone, Encode, Decode)] -pub enum DiffType { - Undo, - Redo, -} - -#[derive(Clone, Encode, Decode)] +/// 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, + /// 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..623ceaffc 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -2,14 +2,20 @@ mod border; mod border_utils; -mod common; -mod history; -mod ui; +pub mod common; +pub mod event; +pub mod history; +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/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/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/nodejs/src/user_model.rs b/bindings/nodejs/src/user_model.rs index b1ebcb9fe..ef9b98679 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")] @@ -651,4 +651,12 @@ 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, env: Env) -> Result { + let diffs = self.model.get_recent_diffs(); + env + .to_js_value(&diffs) + .map_err(|e| to_js_error(e.to_string())) + } } diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 231521091..400835628 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -8,16 +8,20 @@ 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_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] } +ironcalc = { path = "../../xlsx", version = "0.5.0" } +ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite", "single_threaded"] } +wasm-bindgen = "0.2.100" serde = { version = "1.0", features = ["derive"] } -wasm-bindgen = "0.2.92" -serde-wasm-bindgen = "0.4" +serde-wasm-bindgen = "0.6" +js-sys = "0.3" [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.pkg.md b/bindings/wasm/README.pkg.md index 8e62d0ec5..9472adfbc 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. Note that it does not contain the xlsx writer and reader, only the engine. - +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,4 +32,58 @@ 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"); +``` + +### 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 = toXLSXBytes(model.toBytes()); + +// load from those bytes +const roundTrippedBytes = fromXLSXBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); +const roundTripped = Model.fromBytes(roundTrippedBytes); +``` + + +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/fix_types.py b/bindings/wasm/fix_types.py index fd466ee33..d38d6418a 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -1,247 +1,35 @@ -# 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.") + + 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: + 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 - + return text if __name__ == "__main__": - types_file = "pkg/wasm.d.ts" + 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")) - - js_file = "pkg/wasm.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")) - - 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 5767a2edf..bc6068260 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,7 +1,10 @@ -use serde::Serialize; +use js_sys::Function; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; use wasm_bindgen::{ prelude::{wasm_bindgen, JsError}, - JsValue, + JsCast, JsValue, }; use ironcalc_base::{ @@ -16,7 +19,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) @@ -37,6 +40,12 @@ struct DefinedName { formula: String, } +#[derive(Serialize)] +struct NewSheetResult { + name: String, + sheet_index: u32, +} + #[wasm_bindgen] pub struct Model { model: BaseModel, @@ -50,6 +59,7 @@ 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 }) @@ -83,6 +93,74 @@ 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 |event| { + 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 + } + } + } + }); + + // 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 + } + } + } + }); + + // 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(); } @@ -104,9 +182,11 @@ impl Model { .map_err(to_js_error) } - #[wasm_bindgen(js_name = "newSheet")] - pub fn new_sheet(&mut self) -> Result<(), JsError> { - self.model.new_sheet().map_err(to_js_error) + #[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())) } #[wasm_bindgen(js_name = "deleteSheet")] @@ -335,10 +415,26 @@ impl Model { .unwrap_or_default()) } + #[wasm_bindgen( + js_name = "getSheetDimensions", + unchecked_return_type = "WorksheetDimension" + )] + 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, - range: JsValue, + #[wasm_bindgen(unchecked_param_type = "Area")] range: JsValue, style_path: &str, value: &str, ) -> Result<(), JsError> { @@ -349,7 +445,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 +461,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 +490,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 +512,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 +571,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 +586,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 +667,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 +695,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 +709,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 +724,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 +736,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 @@ -672,4 +786,24 @@ impl Model { .delete_defined_name(name, scope) .map_err(|e| to_js_error(e.to_string())) } + + #[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", 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()) + } +} + +#[wasm_bindgen] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CellReference { + pub sheet: u32, + pub row: i32, + pub column: i32, } 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 374669966..dc0060c2e 100644 --- a/bindings/wasm/tests/test.mjs +++ b/bindings/wasm/tests/test.mjs @@ -1,9 +1,18 @@ import test from 'node:test'; import assert from 'node:assert' -import { Model } from "../pkg/wasm.js"; +import { setTimeout } from 'node:timers/promises'; +import { Model } from "../pkg/ironcalc.js"; +import { fromXLSXBytes, toXLSXBytes } from "../pkg/xlsx.js"; 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); @@ -75,7 +84,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,6 +102,30 @@ 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(); + 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(() => { @@ -103,7 +136,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 +146,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 +154,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,5 +163,850 @@ test("autofill", () => { assert.strictEqual(result, "23"); }); +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'); +}); + +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'); + + const expectedDiffs = [ + { + type: 'Redo', + list: [{ + type: "setCellValue", + sheet: 0, + row: 2, + column: 3, + new_value: "99", + old_value: undefined + }] + } + ]; + + // Verify we got the expected number of diffs + assert.strictEqual(diffs.length, expectedDiffs.length, `Should have exactly ${expectedDiffs.length} diffs`); + + // 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.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', + ); + + const diffs = model.getRecentDiffs(); + + 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'); + + // Initial action + model.setUserInput(0, 1, 1, '100'); // QueueDiffs[0] (Redo) + + // Undo that action -> QueueDiffs[1] (Undo) + model.undo(); + + 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 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'); + + // 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 + }); +}); + +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'); +}); + +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'); +}); + +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'); +}); + +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'); +}); + +test('onDiffs', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diffs => { + events.push(...diffs); + }); + + model.setUserInput(0, 1, 1, 'test'); + await setTimeout(0); + + const expectedEvents = [ + { + type: "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('toXLSXBytes returns data', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const bytes = toXLSXBytes(model.toBytes()); + assert.ok(bytes instanceof Uint8Array); + assert.ok(bytes.length > 0); +}); + +test('toBytes returns data', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const bytes = model.toBytes(); + assert.ok(bytes instanceof Uint8Array); + assert.ok(bytes.length > 0); +}); + +test('onDiffs emits correct diff types for various operations', async () => { + const model = new Model('Workbook1', 'en', 'UTC'); + const events = []; + + model.onDiffs(diffs => { + events.push(...diffs); + }); + + // 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 = [ + { + type: "setCellValue", + sheet: 0, + row: 1, + column: 1, + new_value: "42", + old_value: undefined + }, + { + type: "insertRow", + sheet: 0, + row: 2 + }, + { + type: "setRowHeight", + sheet: 0, + row: 1, + new_value: 35, + old_value: 28 + }, + { + type: "insertColumn", + sheet: 0, + column: 2 + }, + { + type: "setColumnWidth", + sheet: 0, + column: 1, + new_value: 120, + old_value: 125 + }, + { + type: "newSheet", + index: 1, + name: "Sheet2" + }, + { + type: "renameSheet", + index: 1, + old_value: "Sheet2", + new_value: "TestSheet" + }, + { + type: "setFrozenRowsCount", + sheet: 0, + new_value: 2, + old_value: 0 + }, + { + type: "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(diffs => { + events.push(...diffs); + }); + + // 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) + { + type: "setCellValue", + sheet: 0, + row: 1, + column: 1, + new_value: "Hello", + old_value: undefined + }, + { + type: "setCellValue", + sheet: 0, + row: 1, + column: 2, + new_value: "World", + old_value: undefined + }, + { + type: "insertRow", + sheet: 0, + row: 2 + }, + // Undo operations (2 events) - Note: these emit the same diff structures as the forward operations + { + type: "insertRow", + sheet: 0, + row: 2 + }, + { + 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 + { + type: "setCellValue", + sheet: 0, + row: 1, + column: 2, + new_value: "World", + old_value: undefined + }, + { + type: "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(diffs => { + events.push(...diffs); + }); + + // 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) + { + type: "setCellValue", + sheet: 0, + row: 1, + column: 1, + new_value: "=SUM(A2:A5)", + old_value: undefined + }, + { + type: "setCellValue", + sheet: 0, + row: 2, + column: 1, + new_value: "10", + old_value: undefined + }, + { + type: "setCellValue", + sheet: 0, + row: 3, + column: 1, + new_value: "20", + old_value: undefined + }, + { + type: "setCellValue", + sheet: 0, + row: 4, + column: 1, + new_value: "30", + old_value: undefined + }, + // Row operations (2 events) + { + type: "insertRow", + sheet: 0, + row: 2 + }, + { + type: "deleteRow", + sheet: 0, + row: 2, + old_data: { + data: new Map(), + row: undefined, + } + }, + // Range clear operations (2 events) + { + type: "cellClearContents", + sheet: 0, + row: 2, + column: 1, + old_value: { + NumberCell: { + v: 10, + s: 0 + } + } + }, + { + type: "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(diffs => { + events1.push(...diffs); + }); + + const unregister2 = model.onDiffs(diffs => { + events2.push(...diffs); + }); + + // 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(); +}); + +test('fromBytes loads model', () => { + const model = new Model('Workbook1', 'en', 'UTC'); + model.setUserInput(0, 1, 1, '42'); + 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 xlsxBytes = toXLSXBytes(model.toBytes()); + const modelBytes = fromXLSXBytes(xlsxBytes, 'Workbook1', 'en', 'UTC'); + const m2 = Model.fromBytes(modelBytes); + 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 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 bytes', () => { + const m1 = new Model('Workbook1', 'en', 'UTC'); + m1.setUserInput(0, 1, 1, '9'); + m1.setUserInput(0, 1, 2, '=A1*4'); + const bytes = m1.toBytes(); + const m2 = Model.fromBytes(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 7af55b8c3..315118990 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; @@ -209,6 +214,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; @@ -233,4 +246,319 @@ export interface DefinedName { name: string; scope?: number; formula: string; -} \ No newline at end of file +} + +// 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 + 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 { + type: "setCellValue"; + sheet: number; + row: number; + column: number; + new_value: string; + old_value?: Cell | null; +} + +export interface CellClearContentsDiff { + type: "cellClearContents"; + sheet: number; + row: number; + column: number; + old_value?: Cell | null; +} + +export interface CellClearAllDiff { + type: "cellClearAll"; + sheet: number; + row: number; + column: number; + old_value?: Cell | null; + old_style: Style; +} + +export interface CellClearFormattingDiff { + type: "cellClearFormatting"; + sheet: number; + row: number; + column: number; + old_style?: Style | null; +} + +export interface SetCellStyleDiff { + type: "setCellStyle"; + sheet: number; + row: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetColumnWidthDiff { + type: "setColumnWidth"; + sheet: number; + column: number; + new_value: number; + old_value: number; +} + +export interface SetRowHeightDiff { + type: "setRowHeight"; + sheet: number; + row: number; + new_value: number; + old_value: number; +} + +export interface SetColumnStyleDiff { + type: "setColumnStyle"; + sheet: number; + column: number; + old_value?: Style | null; + new_value: Style; +} + +export interface SetRowStyleDiff { + type: "setRowStyle"; + sheet: number; + row: number; + old_value?: Style | null; + new_value: Style; +} + +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; + new_name: string; + new_scope?: number; + new_formula: string; +} + +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"; + list: Diff[]; +} +export interface WorksheetDimension { + min_row: number; + max_row: number; + min_column: number; + max_column: number; +} 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 = { 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();