diff --git a/README.md b/README.md index 6216718..bb75451 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ IronCalc is a new, modern, work-in-progress spreadsheet engine and set of tools to work with spreadsheets in diverse settings. -This repository contains the main engine and the xlsx importer and exporter. +This repository contains the main engine and the xlsx reader and writer. -Programmed in Rust, you can use it from a variety of programming languages like [Python](https://github.com/ironcalc/bindings-python), [JavaScript (wasm)](https://github.com/ironcalc/bindings-js), [nodejs](https://github.com/ironcalc/bindings-nodejs) and soon R, Julia, Go and possibly others. +Programmed in Rust, you will be able to use it from a variety of programming languages like Python, JavaScript (wasm), nodejs and possibly R, Julia or Go. -It has several different _skins_. You can use it in the [terminal](https://github.com/ironcalc/skin-terminal), as a [desktop application](https://github.com/ironcalc/bindings-desktop) or use it in you own [web application](https://github.com/ironcalc/skin-web). +We will build different _skins_: in the terminal, as a desktop application or use it in you own web application. # 🛠️ Building @@ -51,9 +51,54 @@ $ cargo doc --no-deps > [!WARNING] > This is work-in-progress. IronCalc in developed in the open. Expect things to be broken and change quickly until version 0.5 -* We intend to have a working version by mid January 2024 (version 0.5, MVP) -* Version 1.0.0 will come later in 2024 +Major milestones: +* MVP, version 0.5.0: We intend to have a working version by mid January 2024 (version 0.5, MVP) +* Stable, version 1.0.0 will come later in December 2024 + +MVP stands for _Minimum Viable Product_ + +### Version 0.5 or MVP (15 January 2024) + +Version 0.5 includes the engine, javascript and nodejs bindings and a web application + +Features of the engine include: + +* Read and write xlsx files +* API to set and read values from cells +* Implemented 192 Excel functions +* Time functions with timezones +* Prepared for i18n but will only support English +* Wide test coverage + +UI features of the web application (backed by the engine): + +* Enter values and formulas. Browse mode +* Italics, bold, underline, horizontal alignment +* Number formatting +* Add/remove/rename sheets +* Copy/Paste extend values +* Keyboard navigation +* Delete/Add rows and columns +* Resize rows and columns +* Correct scrolling and navigation + +### Version 1.0 or Stable (December 2024) + +Minor milestones in the ROADMAD for version 1.0.0 (engine and UI): + +* Implementation of arrays and array formulas +* Formula documentation and context help +* Merge cells +* Pivot tables +* Define name manager (mostly UI) +* Update main evaluation algorithm with a support graph +* Dynamic arrays (SORT, UNIQUE, ..) +* Full i18n support with different locales and languages +* Python bindings +* Full test coverage + +I will be creating issues during the first two months of 2024 # License diff --git a/base/src/cell.rs b/base/src/cell.rs index 84e33c7..ab0c08e 100644 --- a/base/src/cell.rs +++ b/base/src/cell.rs @@ -127,7 +127,6 @@ impl Cell { Cell::BooleanCell { .. } => CellType::LogicalValue, Cell::NumberCell { .. } => CellType::Number, Cell::ErrorCell { .. } => CellType::ErrorValue, - // TODO: An empty string should likely be considered a Number (like an empty cell). Cell::SharedString { .. } => CellType::Text, Cell::CellFormula { .. } => CellType::Number, Cell::CellFormulaBoolean { .. } => CellType::LogicalValue, diff --git a/base/src/model.rs b/base/src/model.rs index 2533d6a..9a3a8d1 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -7,7 +7,7 @@ use std::vec::Vec; use crate::{ calc_result::{CalcResult, CellReference, Range}, cell::CellValue, - constants, + constants::{self, LAST_COLUMN, LAST_ROW}, expressions::token::{Error, OpCompare, OpProduct, OpSum, OpUnary}, expressions::{ parser::move_formula::{move_formula, MoveContext}, @@ -1207,7 +1207,6 @@ impl Model { } } - // FIXME: Can't put it in Workbook, because language is outside of workbook, sic! /// Gets the Excel Value (Bool, Number, String) of a cell pub fn get_cell_value_by_ref(&self, cell_ref: &str) -> Result { let cell_reference = match self.parse_reference(cell_ref) { @@ -1221,7 +1220,6 @@ impl Model { self.get_cell_value_by_index(sheet_index, row, column) } - // FIXME: Can't put it in Workbook, because language is outside of workbook, sic! pub fn get_cell_value_by_index( &self, sheet_index: u32, @@ -1238,7 +1236,6 @@ impl Model { Ok(cell_value) } - // FIXME: Can't put it in Workbook, because locale and language are outside of workbook, sic! pub fn formatted_cell_value( &self, sheet_index: u32, @@ -1259,6 +1256,28 @@ impl Model { Ok(formatted_value) } + /// Returns a string with the cell content. If there is a formula returns the formula + /// If the cell is empty returns the empty string + /// Raises an error if there is no worksheet + pub fn get_cell_content(&self, sheet: u32, row: i32, column: i32) -> Result { + let worksheet = self.workbook.worksheet(sheet)?; + let cell = match worksheet.cell(row, column) { + Some(c) => c, + None => return Ok("".to_string()), + }; + match cell.get_formula() { + Some(formula_index) => { + let formula = &self.parsed_formulas[sheet as usize][formula_index as usize]; + let cell_ref = CellReferenceRC { + sheet: worksheet.get_name(), + row, + column, + }; + Ok(format!("={}", to_string(formula, &cell_ref))) + } + None => Ok(cell.get_text(&self.workbook.shared_strings, &self.language)), + } + } /// Returns a list of all cells pub fn get_all_cells(&self) -> Vec { let mut cells = Vec::new(); @@ -1316,7 +1335,6 @@ impl Model { Ok(()) } - // FIXME: expect pub fn get_cell_style_index(&self, sheet: u32, row: i32, column: i32) -> i32 { // First check the cell, then row, the column let cell = self @@ -1414,6 +1432,50 @@ impl Model { }; Ok(()) } + + pub fn get_frozen_rows(&self, sheet: u32) -> Result { + if let Some(worksheet) = self.workbook.worksheets.get(sheet as usize) { + Ok(worksheet.frozen_rows) + } else { + Err("Invalid sheet".to_string()) + } + } + + pub fn get_frozen_columns(&self, sheet: u32) -> Result { + if let Some(worksheet) = self.workbook.worksheets.get(sheet as usize) { + Ok(worksheet.frozen_columns) + } else { + Err("Invalid sheet".to_string()) + } + } + + pub fn set_frozen_rows(&mut self, sheet: u32, frozen_rows: i32) -> Result<(), String> { + if let Some(worksheet) = self.workbook.worksheets.get_mut(sheet as usize) { + if frozen_rows < 0 { + return Err("Frozen rows cannot be negative".to_string()); + } else if frozen_rows >= LAST_ROW { + return Err("Too many rows".to_string()); + } + worksheet.frozen_rows = frozen_rows; + Ok(()) + } else { + Err("Invalid sheet".to_string()) + } + } + + pub fn set_frozen_columns(&mut self, sheet: u32, frozen_columns: i32) -> Result<(), String> { + if let Some(worksheet) = self.workbook.worksheets.get_mut(sheet as usize) { + if frozen_columns < 0 { + return Err("Frozen columns cannot be negative".to_string()); + } else if frozen_columns >= LAST_COLUMN { + return Err("Too many columns".to_string()); + } + worksheet.frozen_columns = frozen_columns; + Ok(()) + } else { + Err("Invalid sheet".to_string()) + } + } } #[cfg(test)] diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index b416250..d5775a6 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -47,5 +47,6 @@ mod test_number_format; mod test_escape_quotes; mod test_fn_type; +mod test_get_cell_content; mod test_percentage; mod test_today; diff --git a/base/src/test/test_frozen_rows_and_columns.rs b/base/src/test/test_frozen_rows_and_columns.rs new file mode 100644 index 0000000..c175362 --- /dev/null +++ b/base/src/test/test_frozen_rows_and_columns.rs @@ -0,0 +1,71 @@ +#![allow(clippy::unwrap_used)] + +use crate::expressions::utils::{LAST_COLUMN, LAST_ROW}; +use crate::test::util::new_empty_model; + +#[test] +fn test_empty_model() { + let mut model = new_empty_model(); + assert_eq!(model.get_frozen_rows(0), Ok(0)); + assert_eq!(model.get_frozen_columns(0), Ok(0)); + + let e = model.set_frozen_rows(0, 3); + assert!(e.is_ok()); + assert_eq!(model.get_frozen_rows(0), Ok(3)); + assert_eq!(model.get_frozen_columns(0), Ok(0)); + + let e = model.set_frozen_columns(0, 53); + assert!(e.is_ok()); + assert_eq!(model.get_frozen_rows(0), Ok(3)); + assert_eq!(model.get_frozen_columns(0), Ok(53)); + + // Set them back to zero + let e = model.set_frozen_rows(0, 0); + assert!(e.is_ok()); + let e = model.set_frozen_columns(0, 0); + assert!(e.is_ok()); + assert_eq!(model.get_frozen_rows(0), Ok(0)); + assert_eq!(model.get_frozen_columns(0), Ok(0)); +} + +#[test] +fn test_invalid_sheet() { + let mut model = new_empty_model(); + assert_eq!(model.get_frozen_rows(1), Err("Invalid sheet".to_string())); + assert_eq!( + model.get_frozen_columns(3), + Err("Invalid sheet".to_string()) + ); + + assert_eq!( + model.set_frozen_rows(3, 3), + Err("Invalid sheet".to_string()) + ); + assert_eq!( + model.set_frozen_columns(3, 5), + Err("Invalid sheet".to_string()) + ); +} + +#[test] +fn test_invalid_rows_columns() { + let mut model = new_empty_model(); + + assert_eq!( + model.set_frozen_rows(0, -3), + Err("Frozen rows cannot be negative".to_string()) + ); + assert_eq!( + model.set_frozen_columns(0, -5), + Err("Frozen columns cannot be negative".to_string()) + ); + + assert_eq!( + model.set_frozen_rows(0, LAST_ROW), + Err("Too many rows".to_string()) + ); + assert_eq!( + model.set_frozen_columns(0, LAST_COLUMN), + Err("Too many columns".to_string()) + ); +} diff --git a/base/src/test/test_get_cell_content.rs b/base/src/test/test_get_cell_content.rs new file mode 100644 index 0000000..e36846b --- /dev/null +++ b/base/src/test/test_get_cell_content.rs @@ -0,0 +1,18 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_formulas() { + let mut model = new_empty_model(); + model.set_user_input(0, 1, 1, "$100.348".to_string()); + model.set_user_input(0, 1, 2, "=ISNUMBER(A1)".to_string()); + + model.evaluate(); + + assert_eq!(model.get_cell_content(0, 1, 1).unwrap(), "100.348"); + assert_eq!(model.get_cell_content(0, 1, 2).unwrap(), "=ISNUMBER(A1)"); + assert_eq!(model.get_cell_content(0, 5, 5).unwrap(), ""); + + assert!(model.get_cell_content(1, 1, 2).is_err()); +} diff --git a/base/src/types.rs b/base/src/types.rs index 7024de8..21158fc 100644 --- a/base/src/types.rs +++ b/base/src/types.rs @@ -651,3 +651,13 @@ pub struct Border { #[serde(skip_serializing_if = "Option::is_none")] pub diagonal: Option, } + +/// Information need to show a sheet tab in the UI +/// The color is serialized only if it is not Color::None +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct SheetInfo { + pub name: String, + pub state: String, + pub sheet_id: u32, + pub color: Option, +} diff --git a/base/src/workbook.rs b/base/src/workbook.rs index 791acb1..7a78af1 100644 --- a/base/src/workbook.rs +++ b/base/src/workbook.rs @@ -27,4 +27,16 @@ impl Workbook { .get_mut(worksheet_index as usize) .ok_or_else(|| "Invalid sheet index".to_string()) } + + pub fn get_worksheets_info(&self) -> Vec { + self.worksheets + .iter() + .map(|worksheet| SheetInfo { + name: worksheet.get_name(), + state: worksheet.state.to_string(), + color: worksheet.color.clone(), + sheet_id: worksheet.sheet_id, + }) + .collect() + } }