Compare commits

..

4 Commits

Author SHA1 Message Date
Nicolás Hatcher
bf894d0045 WIP 2025-10-03 12:08:06 +02:00
Nicolás Hatcher
3a399fc91a WIP 2025-10-03 12:08:06 +02:00
Nicolás Hatcher
71a2bb2dca WIP 2025-10-03 12:08:06 +02:00
Nicolás Hatcher
889845b948 UPDATE: Dynamic arrays! 2025-10-03 12:08:06 +02:00
100 changed files with 2156 additions and 5881 deletions

10
Cargo.lock generated
View File

@@ -414,7 +414,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc" name = "ironcalc"
version = "0.6.0" version = "0.5.0"
dependencies = [ dependencies = [
"bitcode", "bitcode",
"chrono", "chrono",
@@ -430,7 +430,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc_base" name = "ironcalc_base"
version = "0.6.0" version = "0.5.0"
dependencies = [ dependencies = [
"bitcode", "bitcode",
"chrono", "chrono",
@@ -447,7 +447,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.6.0" version = "0.5.0"
dependencies = [ dependencies = [
"ironcalc", "ironcalc",
"napi", "napi",
@@ -782,7 +782,7 @@ dependencies = [
[[package]] [[package]]
name = "pyroncalc" name = "pyroncalc"
version = "0.6.0" version = "0.5.7"
dependencies = [ dependencies = [
"bitcode", "bitcode",
"ironcalc", "ironcalc",
@@ -1075,7 +1075,7 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm" name = "wasm"
version = "0.6.0" version = "0.5.0"
dependencies = [ dependencies = [
"ironcalc_base", "ironcalc_base",
"serde", "serde",

View File

@@ -1,93 +0,0 @@
FROM rust:latest AS builder
WORKDIR /app
COPY . .
# Tools + wasm toolchain + Node via nvm
RUN apt-get update && apt-get install -y --no-install-recommends \
bash curl ca-certificates make \
&& rustup target add wasm32-unknown-unknown \
&& cargo install wasm-pack \
&& bash -lc "curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash -" \
&& bash -lc '\
export NVM_DIR="$HOME/.nvm" && \
source "$NVM_DIR/nvm.sh" && \
nvm install 22 && nvm alias default 22 && \
nroot="$NVM_DIR/versions/node/$(nvm version default)/bin" && \
ln -sf "$nroot/node" /usr/local/bin/node && \
ln -sf "$nroot/npm" /usr/local/bin/npm && \
ln -sf "$nroot/npx" /usr/local/bin/npx \
' \
&& npm install typescript \
&& rm -rf /var/lib/apt/lists/*
# build the server
RUN cargo build --release --manifest-path webapp/app.ironcalc.com/server/Cargo.toml
# build the wasm
RUN make -C bindings/wasm
# build the widget
WORKDIR /app/webapp/IronCalc
RUN npm install && npm run build
# build the frontend app
WORKDIR /app/webapp/app.ironcalc.com/frontend
RUN npm install && npm run build
# build the xlsx_2_icalc binary (we don't need the release version here)
WORKDIR /app/xlsx
RUN cargo build
WORKDIR /app
# copy the artifacts to a dist/ directory
RUN mkdir dist
RUN mkdir dist/frontend
RUN cp -r webapp/app.ironcalc.com/frontend/dist/* dist/frontend/
RUN mkdir dist/server
RUN cp webapp/app.ironcalc.com/server/target/release/ironcalc_server dist/server/
RUN cp webapp/app.ironcalc.com/server/Rocket.toml dist/server/
RUN cp webapp/app.ironcalc.com/server/ironcalc.sqlite dist/server/
# Create ic files in docs
RUN mkdir -p dist/frontend/models
# Loop over all xlsx files in xlsx/tests/docs & templates and convert them to .ic
RUN bash -lc 'set -euo pipefail; \
mkdir -p dist/frontend/models; \
shopt -s nullglob; \
for xlsx_file in xlsx/tests/docs/*.xlsx; do \
base_name="${xlsx_file##*/}"; base_name="${base_name%.xlsx}"; \
./target/debug/xlsx_2_icalc "$xlsx_file" "dist/frontend/models/${base_name}.ic"; \
done; \
for xlsx_file in xlsx/tests/templates/*.xlsx; do \
base_name="${xlsx_file##*/}"; base_name="${base_name%.xlsx}"; \
./target/debug/xlsx_2_icalc "$xlsx_file" "dist/frontend/models/${base_name}.ic"; \
done'
# ---------- server runtime ----------
FROM debian:bookworm-slim AS server-runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy EVERYTHING you put in dist/server (binary + Rocket.toml + DB)
COPY --from=builder /app/dist/server/ ./
# Make sure Rocket binds to the container IP; explicitly point to the config file
ENV ROCKET_ADDRESS=0.0.0.0 \
ROCKET_PORT=8000 \
ROCKET_CONFIG=/app/Rocket.toml
EXPOSE 8000
# Run from /app so relative paths in Rocket.toml/DB work
CMD ["./ironcalc_server"]
# ---------- caddy runtime (serves frontend + reverse-proxy /api) ----------
FROM caddy:latest AS caddy-runtime
WORKDIR /srv
# Copy the frontend build output to /srv
COPY --from=builder /app/dist/frontend/ /srv/
# Copy the Caddyfile
COPY --from=builder /app/webapp/app.ironcalc.com/Caddyfile.compose /etc/caddy/Caddyfile

View File

@@ -31,7 +31,12 @@ clean: remove-artifacts
rm -r -f base/target rm -r -f base/target
rm -r -f xlsx/target rm -r -f xlsx/target
rm -r -f bindings/python/target rm -r -f bindings/python/target
rm -r -f bindings/wasm/targets rm -r -f bindings/wasm/target
rm -r -f bindings/wasm/pkg
rm -r -f webapp/IronCalc/node_modules
rm -r -f webapp/IronCalc/dist
rm -r -f webapp/app.ironcalc.com/frontend/node_modules
rm -r -f webapp/app.ironcalc.com/frontend/dist
rm -f cargo-test-* rm -f cargo-test-*
rm -f base/cargo-test-* rm -f base/cargo-test-*
rm -f xlsx/cargo-test-* rm -f xlsx/cargo-test-*

View File

@@ -31,17 +31,7 @@ This repository contains the main engine and the xlsx reader and writer.
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. 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.
We will build different _skins_: in the terminal, as a desktop application or use it in your own web application. We will build different _skins_: in the terminal, as a desktop application or use it in you own web application.
# Docker
If you have docker installed just run:
```bash
docker compose up --build
```
head over to <http://localhost:2080> to test the application.
# Building # Building
@@ -94,7 +84,7 @@ And then use this code in `main.rs`:
```rust ```rust
use ironcalc::{ use ironcalc::{
base::{expressions::utils::number_to_column, Model}, base::{expressions::utils::number_to_column, model::Model},
export::save_to_xlsx, export::save_to_xlsx,
}; };

61
base/CALC.md Normal file
View File

@@ -0,0 +1,61 @@
# Evaluation Strategy
We have a list of the spill cells:
```
// Checks if the array starting at cell will cover cells whose values
// has been requested
def CheckSpill(cell, array):
for c in cell+array:
support CellHasBeenRequested(c):
if support is not empty:
return support
return []
// Fills cells with the result (an array)
def FillCells(cell, result):
def EvaluateNodeInContext(node, context):
match node:
case OP(left, right, op):
l = EvaluateNodeInContext(left, context)?
r = EvaluateNodeInContext(left, context)?
return op(l, r)
case FUNCTION(args, fn):
...
case CELL(cell):
EvaluateCell(cell)
case RANGE(start, end):
...
def EvaluateCell(cell):
if IsCellEvaluating(cell):
return CIRC
MarkEvaluating(cell)
result = EvaluateNodeInContext(cell.formula, cell)
if isSpill(result):
CheckSpill(cell, array)?
FillCells(result)
def EvaluateWorkbook():
spill_cells = [cell_1, ...., cell_n];
for cell in spill_cells:
result = evaluate(cell)
```
# When updating a cell value
If it was a spill cell we nee

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironcalc_base" name = "ironcalc_base"
version = "0.6.0" version = "0.5.0"
authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"]
edition = "2021" edition = "2021"
homepage = "https://www.ironcalc.com" homepage = "https://www.ironcalc.com"

View File

@@ -22,7 +22,7 @@ impl Model {
.cell(row, column) .cell(row, column)
.and_then(|c| c.get_formula()) .and_then(|c| c.get_formula())
{ {
let node = &self.parsed_formulas[sheet as usize][f as usize].clone(); let node = &self.parsed_formulas[sheet as usize][f as usize].0.clone();
let cell_reference = CellReferenceRC { let cell_reference = CellReferenceRC {
sheet: self.workbook.worksheets[sheet as usize].get_name(), sheet: self.workbook.worksheets[sheet as usize].get_name(),
row, row,

View File

@@ -77,8 +77,6 @@ impl Model {
match to_f64(&node) { match to_f64(&node) {
Ok(f2) => match op(f1, f2) { Ok(f2) => match op(f1, f2) {
Ok(x) => data_row.push(ArrayNode::Number(x)), Ok(x) => data_row.push(ArrayNode::Number(x)),
Err(Error::DIV) => data_row.push(ArrayNode::Error(Error::DIV)),
Err(Error::VALUE) => data_row.push(ArrayNode::Error(Error::VALUE)),
Err(e) => data_row.push(ArrayNode::Error(e)), Err(e) => data_row.push(ArrayNode::Error(e)),
}, },
Err(err) => data_row.push(ArrayNode::Error(err)), Err(err) => data_row.push(ArrayNode::Error(err)),
@@ -100,8 +98,6 @@ impl Model {
match to_f64(&node) { match to_f64(&node) {
Ok(f1) => match op(f1, f2) { Ok(f1) => match op(f1, f2) {
Ok(x) => data_row.push(ArrayNode::Number(x)), Ok(x) => data_row.push(ArrayNode::Number(x)),
Err(Error::DIV) => data_row.push(ArrayNode::Error(Error::DIV)),
Err(Error::VALUE) => data_row.push(ArrayNode::Error(Error::VALUE)),
Err(e) => data_row.push(ArrayNode::Error(e)), Err(e) => data_row.push(ArrayNode::Error(e)),
}, },
Err(err) => data_row.push(ArrayNode::Error(err)), Err(err) => data_row.push(ArrayNode::Error(err)),
@@ -137,10 +133,6 @@ impl Model {
(Some(v1), Some(v2)) => match (to_f64(v1), to_f64(v2)) { (Some(v1), Some(v2)) => match (to_f64(v1), to_f64(v2)) {
(Ok(f1), Ok(f2)) => match op(f1, f2) { (Ok(f1), Ok(f2)) => match op(f1, f2) {
Ok(x) => data_row.push(ArrayNode::Number(x)), Ok(x) => data_row.push(ArrayNode::Number(x)),
Err(Error::DIV) => data_row.push(ArrayNode::Error(Error::DIV)),
Err(Error::VALUE) => {
data_row.push(ArrayNode::Error(Error::VALUE))
}
Err(e) => data_row.push(ArrayNode::Error(e)), Err(e) => data_row.push(ArrayNode::Error(e)),
}, },
(Err(e), _) | (_, Err(e)) => data_row.push(ArrayNode::Error(e)), (Err(e), _) | (_, Err(e)) => data_row.push(ArrayNode::Error(e)),

View File

@@ -64,12 +64,50 @@ impl Cell {
/// Returns the formula of a cell if any. /// Returns the formula of a cell if any.
pub fn get_formula(&self) -> Option<i32> { pub fn get_formula(&self) -> Option<i32> {
match self { match self {
Cell::CellFormula { f, .. } => Some(*f), Cell::CellFormula { f, .. }
Cell::CellFormulaBoolean { f, .. } => Some(*f), | Cell::CellFormulaBoolean { f, .. }
Cell::CellFormulaNumber { f, .. } => Some(*f), | Cell::CellFormulaNumber { f, .. }
Cell::CellFormulaString { f, .. } => Some(*f), | Cell::CellFormulaString { f, .. }
Cell::CellFormulaError { f, .. } => Some(*f), | Cell::CellFormulaError { f, .. }
_ => None, | Cell::DynamicCellFormula { f, .. }
| Cell::DynamicCellFormulaBoolean { f, .. }
| Cell::DynamicCellFormulaNumber { f, .. }
| Cell::DynamicCellFormulaString { f, .. }
| Cell::DynamicCellFormulaError { f, .. } => Some(*f),
Cell::EmptyCell { .. }
| Cell::BooleanCell { .. }
| Cell::NumberCell { .. }
| Cell::ErrorCell { .. }
| Cell::SharedString { .. }
| Cell::SpillNumberCell { .. }
| Cell::SpillBooleanCell { .. }
| Cell::SpillErrorCell { .. }
| Cell::SpillStringCell { .. } => None,
}
}
/// Returns the dynamic range of a cell if any.
pub fn get_dynamic_range(&self) -> Option<(i32, i32)> {
match self {
Cell::DynamicCellFormula { r, .. } => Some(*r),
Cell::DynamicCellFormulaBoolean { r, .. } => Some(*r),
Cell::DynamicCellFormulaNumber { r, .. } => Some(*r),
Cell::DynamicCellFormulaString { r, .. } => Some(*r),
Cell::DynamicCellFormulaError { r, .. } => Some(*r),
Cell::EmptyCell { .. }
| Cell::BooleanCell { .. }
| Cell::NumberCell { .. }
| Cell::ErrorCell { .. }
| Cell::SharedString { .. }
| Cell::CellFormula { .. }
| Cell::CellFormulaBoolean { .. }
| Cell::CellFormulaNumber { .. }
| Cell::CellFormulaString { .. }
| Cell::CellFormulaError { .. }
| Cell::SpillNumberCell { .. }
| Cell::SpillBooleanCell { .. }
| Cell::SpillErrorCell { .. }
| Cell::SpillStringCell { .. } => None,
} }
} }
@@ -89,6 +127,15 @@ impl Cell {
Cell::CellFormulaNumber { s, .. } => *s = style, Cell::CellFormulaNumber { s, .. } => *s = style,
Cell::CellFormulaString { s, .. } => *s = style, Cell::CellFormulaString { s, .. } => *s = style,
Cell::CellFormulaError { s, .. } => *s = style, Cell::CellFormulaError { s, .. } => *s = style,
Cell::SpillBooleanCell { s, .. } => *s = style,
Cell::SpillNumberCell { s, .. } => *s = style,
Cell::SpillStringCell { s, .. } => *s = style,
Cell::SpillErrorCell { s, .. } => *s = style,
Cell::DynamicCellFormula { s, .. } => *s = style,
Cell::DynamicCellFormulaBoolean { s, .. } => *s = style,
Cell::DynamicCellFormulaNumber { s, .. } => *s = style,
Cell::DynamicCellFormulaString { s, .. } => *s = style,
Cell::DynamicCellFormulaError { s, .. } => *s = style,
}; };
} }
@@ -104,6 +151,15 @@ impl Cell {
Cell::CellFormulaNumber { s, .. } => *s, Cell::CellFormulaNumber { s, .. } => *s,
Cell::CellFormulaString { s, .. } => *s, Cell::CellFormulaString { s, .. } => *s,
Cell::CellFormulaError { s, .. } => *s, Cell::CellFormulaError { s, .. } => *s,
Cell::SpillBooleanCell { s, .. } => *s,
Cell::SpillNumberCell { s, .. } => *s,
Cell::SpillStringCell { s, .. } => *s,
Cell::SpillErrorCell { s, .. } => *s,
Cell::DynamicCellFormula { s, .. } => *s,
Cell::DynamicCellFormulaBoolean { s, .. } => *s,
Cell::DynamicCellFormulaNumber { s, .. } => *s,
Cell::DynamicCellFormulaString { s, .. } => *s,
Cell::DynamicCellFormulaError { s, .. } => *s,
} }
} }
@@ -119,6 +175,15 @@ impl Cell {
Cell::CellFormulaNumber { .. } => CellType::Number, Cell::CellFormulaNumber { .. } => CellType::Number,
Cell::CellFormulaString { .. } => CellType::Text, Cell::CellFormulaString { .. } => CellType::Text,
Cell::CellFormulaError { .. } => CellType::ErrorValue, Cell::CellFormulaError { .. } => CellType::ErrorValue,
Cell::SpillBooleanCell { .. } => CellType::LogicalValue,
Cell::SpillNumberCell { .. } => CellType::Number,
Cell::SpillStringCell { .. } => CellType::Text,
Cell::SpillErrorCell { .. } => CellType::ErrorValue,
Cell::DynamicCellFormula { .. } => CellType::Number,
Cell::DynamicCellFormulaBoolean { .. } => CellType::LogicalValue,
Cell::DynamicCellFormulaNumber { .. } => CellType::Number,
Cell::DynamicCellFormulaString { .. } => CellType::Text,
Cell::DynamicCellFormulaError { .. } => CellType::ErrorValue,
} }
} }
@@ -136,7 +201,7 @@ impl Cell {
Cell::EmptyCell { .. } => CellValue::None, Cell::EmptyCell { .. } => CellValue::None,
Cell::BooleanCell { v, s: _ } => CellValue::Boolean(*v), Cell::BooleanCell { v, s: _ } => CellValue::Boolean(*v),
Cell::NumberCell { v, s: _ } => CellValue::Number(*v), Cell::NumberCell { v, s: _ } => CellValue::Number(*v),
Cell::ErrorCell { ei, .. } => { Cell::ErrorCell { ei, .. } | Cell::SpillErrorCell { ei, .. } => {
let v = ei.to_localized_error_string(language); let v = ei.to_localized_error_string(language);
CellValue::String(v) CellValue::String(v)
} }
@@ -148,14 +213,25 @@ impl Cell {
}; };
CellValue::String(v) CellValue::String(v)
} }
Cell::CellFormula { .. } => CellValue::String("#ERROR!".to_string()), Cell::DynamicCellFormula { .. } | Cell::CellFormula { .. } => {
Cell::CellFormulaBoolean { v, .. } => CellValue::Boolean(*v), CellValue::String("#ERROR!".to_string())
Cell::CellFormulaNumber { v, .. } => CellValue::Number(*v), }
Cell::CellFormulaString { v, .. } => CellValue::String(v.clone()), Cell::DynamicCellFormulaBoolean { v, .. } | Cell::CellFormulaBoolean { v, .. } => {
Cell::CellFormulaError { ei, .. } => { CellValue::Boolean(*v)
}
Cell::DynamicCellFormulaNumber { v, .. } | Cell::CellFormulaNumber { v, .. } => {
CellValue::Number(*v)
}
Cell::DynamicCellFormulaString { v, .. } | Cell::CellFormulaString { v, .. } => {
CellValue::String(v.clone())
}
Cell::DynamicCellFormulaError { ei, .. } | Cell::CellFormulaError { ei, .. } => {
let v = ei.to_localized_error_string(language); let v = ei.to_localized_error_string(language);
CellValue::String(v) CellValue::String(v)
} }
Cell::SpillBooleanCell { v, .. } => CellValue::Boolean(*v),
Cell::SpillNumberCell { v, .. } => CellValue::Number(*v),
Cell::SpillStringCell { v, .. } => CellValue::String(v.clone()),
} }
} }

View File

@@ -186,7 +186,8 @@ pub fn add_implicit_intersection(node: &mut Node, add: bool) {
}; };
} }
pub(crate) enum StaticResult { #[derive(Clone)]
pub enum StaticResult {
Scalar, Scalar,
Array(i32, i32), Array(i32, i32),
Range(i32, i32), Range(i32, i32),
@@ -222,7 +223,7 @@ fn static_analysis_op_nodes(left: &Node, right: &Node) -> StaticResult {
// * Array(a, b) if we know it will be an a x b array. // * Array(a, b) if we know it will be an a x b array.
// * Range(a, b) if we know it will be a a x b range. // * Range(a, b) if we know it will be a a x b range.
// * Unknown if we cannot guaranty either // * Unknown if we cannot guaranty either
fn run_static_analysis_on_node(node: &Node) -> StaticResult { pub(crate) fn run_static_analysis_on_node(node: &Node) -> StaticResult {
match node { match node {
Node::BooleanKind(_) Node::BooleanKind(_)
| Node::NumberKind(_) | Node::NumberKind(_)
@@ -341,8 +342,7 @@ fn static_analysis_offset(args: &[Node]) -> StaticResult {
} }
_ => return StaticResult::Unknown, _ => return StaticResult::Unknown,
}; };
// Both height and width are explicitly 1, so OFFSET will return a single cell StaticResult::Unknown
StaticResult::Scalar
} }
// fn static_analysis_choose(_args: &[Node]) -> StaticResult { // fn static_analysis_choose(_args: &[Node]) -> StaticResult {
@@ -576,37 +576,6 @@ fn args_signature_xnpv(arg_count: usize) -> Vec<Signature> {
} }
} }
// NETWORKDAYS(start_date, end_date, [holidays])
// Parameters: start_date (scalar), end_date (scalar), holidays (optional vector)
fn args_signature_networkdays(arg_count: usize) -> Vec<Signature> {
if arg_count == 2 {
vec![Signature::Scalar, Signature::Scalar]
} else if arg_count == 3 {
vec![Signature::Scalar, Signature::Scalar, Signature::Vector]
} else {
vec![Signature::Error; arg_count]
}
}
// NETWORKDAYS.INTL(start_date, end_date, [weekend], [holidays])
// Parameters: start_date (scalar), end_date (scalar), weekend (optional scalar), holidays (optional vector)
fn args_signature_networkdays_intl(arg_count: usize) -> Vec<Signature> {
if arg_count == 2 {
vec![Signature::Scalar, Signature::Scalar]
} else if arg_count == 3 {
vec![Signature::Scalar, Signature::Scalar, Signature::Scalar]
} else if arg_count == 4 {
vec![
Signature::Scalar,
Signature::Scalar,
Signature::Scalar,
Signature::Vector,
]
} else {
vec![Signature::Error; arg_count]
}
}
// FIXME: This is terrible duplications of efforts. We use the signature in at least three different places: // FIXME: This is terrible duplications of efforts. We use the signature in at least three different places:
// 1. When computing the function // 1. When computing the function
// 2. Checking the arguments to see if we need to insert the implicit intersection operator // 2. Checking the arguments to see if we need to insert the implicit intersection operator
@@ -722,28 +691,13 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Maxifs => vec![Signature::Vector; arg_count], Function::Maxifs => vec![Signature::Vector; arg_count],
Function::Minifs => vec![Signature::Vector; arg_count], Function::Minifs => vec![Signature::Vector; arg_count],
Function::Date => args_signature_scalars(arg_count, 3, 0), Function::Date => args_signature_scalars(arg_count, 3, 0),
Function::Datedif => args_signature_scalars(arg_count, 3, 0),
Function::Datevalue => args_signature_scalars(arg_count, 1, 0),
Function::Day => args_signature_scalars(arg_count, 1, 0), Function::Day => args_signature_scalars(arg_count, 1, 0),
Function::Edate => args_signature_scalars(arg_count, 2, 0), Function::Edate => args_signature_scalars(arg_count, 2, 0),
Function::Eomonth => args_signature_scalars(arg_count, 2, 0), Function::Eomonth => args_signature_scalars(arg_count, 2, 0),
Function::Month => args_signature_scalars(arg_count, 1, 0), Function::Month => args_signature_scalars(arg_count, 1, 0),
Function::Time => args_signature_scalars(arg_count, 3, 0),
Function::Timevalue => args_signature_scalars(arg_count, 1, 0),
Function::Hour => args_signature_scalars(arg_count, 1, 0),
Function::Minute => args_signature_scalars(arg_count, 1, 0),
Function::Second => args_signature_scalars(arg_count, 1, 0),
Function::Now => args_signature_no_args(arg_count), Function::Now => args_signature_no_args(arg_count),
Function::Today => args_signature_no_args(arg_count), Function::Today => args_signature_no_args(arg_count),
Function::Year => args_signature_scalars(arg_count, 1, 0), Function::Year => args_signature_scalars(arg_count, 1, 0),
Function::Days => args_signature_scalars(arg_count, 2, 0),
Function::Days360 => args_signature_scalars(arg_count, 2, 1),
Function::Weekday => args_signature_scalars(arg_count, 1, 1),
Function::Weeknum => args_signature_scalars(arg_count, 1, 1),
Function::Workday => args_signature_scalars(arg_count, 2, 1),
Function::WorkdayIntl => args_signature_scalars(arg_count, 2, 2),
Function::Yearfrac => args_signature_scalars(arg_count, 2, 1),
Function::Isoweeknum => args_signature_scalars(arg_count, 1, 0),
Function::Cumipmt => args_signature_scalars(arg_count, 6, 0), Function::Cumipmt => args_signature_scalars(arg_count, 6, 0),
Function::Cumprinc => args_signature_scalars(arg_count, 6, 0), Function::Cumprinc => args_signature_scalars(arg_count, 6, 0),
Function::Db => args_signature_scalars(arg_count, 4, 1), Function::Db => args_signature_scalars(arg_count, 4, 1),
@@ -832,8 +786,6 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
Function::Formulatext => args_signature_scalars(arg_count, 1, 0), Function::Formulatext => args_signature_scalars(arg_count, 1, 0),
Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0),
Function::Geomean => vec![Signature::Vector; arg_count], Function::Geomean => vec![Signature::Vector; arg_count],
Function::Networkdays => args_signature_networkdays(arg_count),
Function::NetworkdaysIntl => args_signature_networkdays_intl(arg_count),
} }
} }
@@ -945,27 +897,12 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Maxifs => not_implemented(args), Function::Maxifs => not_implemented(args),
Function::Minifs => not_implemented(args), Function::Minifs => not_implemented(args),
Function::Date => not_implemented(args), Function::Date => not_implemented(args),
Function::Datedif => not_implemented(args),
Function::Datevalue => not_implemented(args),
Function::Day => not_implemented(args), Function::Day => not_implemented(args),
Function::Edate => not_implemented(args), Function::Edate => not_implemented(args),
Function::Month => not_implemented(args), Function::Month => not_implemented(args),
Function::Time => not_implemented(args),
Function::Timevalue => not_implemented(args),
Function::Hour => not_implemented(args),
Function::Minute => not_implemented(args),
Function::Second => not_implemented(args),
Function::Now => not_implemented(args), Function::Now => not_implemented(args),
Function::Today => not_implemented(args), Function::Today => not_implemented(args),
Function::Year => not_implemented(args), Function::Year => not_implemented(args),
Function::Days => not_implemented(args),
Function::Days360 => not_implemented(args),
Function::Weekday => not_implemented(args),
Function::Weeknum => not_implemented(args),
Function::Workday => not_implemented(args),
Function::WorkdayIntl => not_implemented(args),
Function::Yearfrac => not_implemented(args),
Function::Isoweeknum => not_implemented(args),
Function::Cumipmt => not_implemented(args), Function::Cumipmt => not_implemented(args),
Function::Cumprinc => not_implemented(args), Function::Cumprinc => not_implemented(args),
Function::Db => not_implemented(args), Function::Db => not_implemented(args),
@@ -1054,7 +991,5 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
Function::Eomonth => scalar_arguments(args), Function::Eomonth => scalar_arguments(args),
Function::Formulatext => not_implemented(args), Function::Formulatext => not_implemented(args),
Function::Geomean => not_implemented(args), Function::Geomean => not_implemented(args),
Function::Networkdays => not_implemented(args),
Function::NetworkdaysIntl => not_implemented(args),
} }
} }

View File

@@ -8,8 +8,6 @@ use crate::constants::EXCEL_DATE_BASE;
use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER; use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER;
use crate::constants::MINIMUM_DATE_SERIAL_NUMBER; use crate::constants::MINIMUM_DATE_SERIAL_NUMBER;
pub const DATE_OUT_OF_RANGE_MESSAGE: &str = "Out of range parameters for date";
#[inline] #[inline]
fn convert_to_serial_number(date: NaiveDate) -> i32 { fn convert_to_serial_number(date: NaiveDate) -> i32 {
date.num_days_from_ce() - EXCEL_DATE_BASE date.num_days_from_ce() - EXCEL_DATE_BASE
@@ -39,7 +37,7 @@ pub fn from_excel_date(days: i64) -> Result<NaiveDate, String> {
pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result<i32, String> { pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result<i32, String> {
match NaiveDate::from_ymd_opt(year, month, day) { match NaiveDate::from_ymd_opt(year, month, day) {
Some(native_date) => Ok(convert_to_serial_number(native_date)), Some(native_date) => Ok(convert_to_serial_number(native_date)),
None => Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()), None => Err("Out of range parameters for date".to_string()),
} }
} }
@@ -57,7 +55,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
return Ok(MINIMUM_DATE_SERIAL_NUMBER); return Ok(MINIMUM_DATE_SERIAL_NUMBER);
} }
let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else { let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else {
return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); return Err("Out of range parameters for date".to_string());
}; };
// One thing to note for example is that even if you started with a year out of range // One thing to note for example is that even if you started with a year out of range
@@ -70,7 +68,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
// As a result, we have to run range checks as we parse the date from the biggest unit to the // As a result, we have to run range checks as we parse the date from the biggest unit to the
// smallest unit. // smallest unit.
if !is_date_within_range(date) { if !is_date_within_range(date) {
return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); return Err("Out of range parameters for date".to_string());
} }
date = { date = {
@@ -82,7 +80,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
date = date + Months::new(abs_month); date = date + Months::new(abs_month);
} }
if !is_date_within_range(date) { if !is_date_within_range(date) {
return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); return Err("Out of range parameters for date".to_string());
} }
date date
}; };
@@ -96,7 +94,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
date = date + Days::new(abs_day); date = date + Days::new(abs_day);
} }
if !is_date_within_range(date) { if !is_date_within_range(date) {
return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); return Err("Out of range parameters for date".to_string());
} }
date date
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -148,30 +148,13 @@ pub enum Function {
// Date and time // Date and time
Date, Date,
Datedif,
Datevalue,
Day, Day,
Edate, Edate,
Eomonth, Eomonth,
Month, Month,
Time,
Timevalue,
Hour,
Minute,
Second,
Now, Now,
Today, Today,
Year, Year,
Networkdays,
NetworkdaysIntl,
Days,
Days360,
Weekday,
Weeknum,
Workday,
WorkdayIntl,
Yearfrac,
Isoweeknum,
// Financial // Financial
Cumipmt, Cumipmt,
@@ -270,7 +253,7 @@ pub enum Function {
} }
impl Function { impl Function {
pub fn into_iter() -> IntoIter<Function, 215> { pub fn into_iter() -> IntoIter<Function, 198> {
[ [
Function::And, Function::And,
Function::False, Function::False,
@@ -379,26 +362,9 @@ impl Function {
Function::Month, Function::Month,
Function::Eomonth, Function::Eomonth,
Function::Date, Function::Date,
Function::Datedif,
Function::Datevalue,
Function::Edate, Function::Edate,
Function::Networkdays,
Function::NetworkdaysIntl,
Function::Time,
Function::Timevalue,
Function::Hour,
Function::Minute,
Function::Second,
Function::Today, Function::Today,
Function::Now, Function::Now,
Function::Days,
Function::Days360,
Function::Weekday,
Function::Weeknum,
Function::Workday,
Function::WorkdayIntl,
Function::Yearfrac,
Function::Isoweeknum,
Function::Pmt, Function::Pmt,
Function::Pv, Function::Pv,
Function::Rate, Function::Rate,
@@ -528,7 +494,6 @@ impl Function {
Function::Isformula => "_xlfn.ISFORMULA".to_string(), Function::Isformula => "_xlfn.ISFORMULA".to_string(),
Function::Sheet => "_xlfn.SHEET".to_string(), Function::Sheet => "_xlfn.SHEET".to_string(),
Function::Formulatext => "_xlfn.FORMULATEXT".to_string(), Function::Formulatext => "_xlfn.FORMULATEXT".to_string(),
Function::Isoweeknum => "_xlfn.ISOWEEKNUM".to_string(),
_ => self.to_string(), _ => self.to_string(),
} }
} }
@@ -666,26 +631,9 @@ impl Function {
"EOMONTH" => Some(Function::Eomonth), "EOMONTH" => Some(Function::Eomonth),
"MONTH" => Some(Function::Month), "MONTH" => Some(Function::Month),
"DATE" => Some(Function::Date), "DATE" => Some(Function::Date),
"DATEDIF" => Some(Function::Datedif),
"DATEVALUE" => Some(Function::Datevalue),
"EDATE" => Some(Function::Edate), "EDATE" => Some(Function::Edate),
"NETWORKDAYS" => Some(Function::Networkdays),
"NETWORKDAYS.INTL" => Some(Function::NetworkdaysIntl),
"TIME" => Some(Function::Time),
"TIMEVALUE" => Some(Function::Timevalue),
"HOUR" => Some(Function::Hour),
"MINUTE" => Some(Function::Minute),
"SECOND" => Some(Function::Second),
"TODAY" => Some(Function::Today), "TODAY" => Some(Function::Today),
"NOW" => Some(Function::Now), "NOW" => Some(Function::Now),
"DAYS" | "_XLFN.DAYS" => Some(Function::Days),
"DAYS360" => Some(Function::Days360),
"WEEKDAY" => Some(Function::Weekday),
"WEEKNUM" => Some(Function::Weeknum),
"WORKDAY" => Some(Function::Workday),
"WORKDAY.INTL" => Some(Function::WorkdayIntl),
"YEARFRAC" => Some(Function::Yearfrac),
"ISOWEEKNUM" | "_XLFN.ISOWEEKNUM" => Some(Function::Isoweeknum),
// Financial // Financial
"PMT" => Some(Function::Pmt), "PMT" => Some(Function::Pmt),
"PV" => Some(Function::Pv), "PV" => Some(Function::Pv),
@@ -893,26 +841,9 @@ impl fmt::Display for Function {
Function::Month => write!(f, "MONTH"), Function::Month => write!(f, "MONTH"),
Function::Eomonth => write!(f, "EOMONTH"), Function::Eomonth => write!(f, "EOMONTH"),
Function::Date => write!(f, "DATE"), Function::Date => write!(f, "DATE"),
Function::Datedif => write!(f, "DATEDIF"),
Function::Datevalue => write!(f, "DATEVALUE"),
Function::Edate => write!(f, "EDATE"), Function::Edate => write!(f, "EDATE"),
Function::Networkdays => write!(f, "NETWORKDAYS"),
Function::NetworkdaysIntl => write!(f, "NETWORKDAYS.INTL"),
Function::Time => write!(f, "TIME"),
Function::Timevalue => write!(f, "TIMEVALUE"),
Function::Hour => write!(f, "HOUR"),
Function::Minute => write!(f, "MINUTE"),
Function::Second => write!(f, "SECOND"),
Function::Today => write!(f, "TODAY"), Function::Today => write!(f, "TODAY"),
Function::Now => write!(f, "NOW"), Function::Now => write!(f, "NOW"),
Function::Days => write!(f, "DAYS"),
Function::Days360 => write!(f, "DAYS360"),
Function::Weekday => write!(f, "WEEKDAY"),
Function::Weeknum => write!(f, "WEEKNUM"),
Function::Workday => write!(f, "WORKDAY"),
Function::WorkdayIntl => write!(f, "WORKDAY.INTL"),
Function::Yearfrac => write!(f, "YEARFRAC"),
Function::Isoweeknum => write!(f, "ISOWEEKNUM"),
Function::Pmt => write!(f, "PMT"), Function::Pmt => write!(f, "PMT"),
Function::Pv => write!(f, "PV"), Function::Pv => write!(f, "PV"),
Function::Rate => write!(f, "RATE"), Function::Rate => write!(f, "RATE"),
@@ -1151,26 +1082,9 @@ impl Model {
Function::Eomonth => self.fn_eomonth(args, cell), Function::Eomonth => self.fn_eomonth(args, cell),
Function::Month => self.fn_month(args, cell), Function::Month => self.fn_month(args, cell),
Function::Date => self.fn_date(args, cell), Function::Date => self.fn_date(args, cell),
Function::Datedif => self.fn_datedif(args, cell),
Function::Datevalue => self.fn_datevalue(args, cell),
Function::Edate => self.fn_edate(args, cell), Function::Edate => self.fn_edate(args, cell),
Function::Networkdays => self.fn_networkdays(args, cell),
Function::NetworkdaysIntl => self.fn_networkdays_intl(args, cell),
Function::Time => self.fn_time(args, cell),
Function::Timevalue => self.fn_timevalue(args, cell),
Function::Hour => self.fn_hour(args, cell),
Function::Minute => self.fn_minute(args, cell),
Function::Second => self.fn_second(args, cell),
Function::Today => self.fn_today(args, cell), Function::Today => self.fn_today(args, cell),
Function::Now => self.fn_now(args, cell), Function::Now => self.fn_now(args, cell),
Function::Days => self.fn_days(args, cell),
Function::Days360 => self.fn_days360(args, cell),
Function::Weekday => self.fn_weekday(args, cell),
Function::Weeknum => self.fn_weeknum(args, cell),
Function::Workday => self.fn_workday(args, cell),
Function::WorkdayIntl => self.fn_workday_intl(args, cell),
Function::Yearfrac => self.fn_yearfrac(args, cell),
Function::Isoweeknum => self.fn_isoweeknum(args, cell),
// Financial // Financial
Function::Pmt => self.fn_pmt(args, cell), Function::Pmt => self.fn_pmt(args, cell),
Function::Pv => self.fn_pv(args, cell), Function::Pv => self.fn_pv(args, cell),

View File

@@ -96,7 +96,7 @@ impl Model {
match cell.get_formula() { match cell.get_formula() {
Some(f) => { Some(f) => {
let node = &self.parsed_formulas[sheet_index as usize][f as usize]; let node = &self.parsed_formulas[sheet_index as usize][f as usize].0;
matches!( matches!(
node, node,
Node::FunctionKind { Node::FunctionKind {

View File

@@ -11,8 +11,9 @@ use crate::{
lexer::LexerMode, lexer::LexerMode,
parser::{ parser::{
move_formula::{move_formula, MoveContext}, move_formula::{move_formula, MoveContext},
static_analysis::{run_static_analysis_on_node, StaticResult},
stringify::{rename_defined_name_in_node, to_rc_format, to_string}, stringify::{rename_defined_name_in_node, to_rc_format, to_string},
Node, Parser, ArrayNode, Node, Parser,
}, },
token::{get_error_by_name, Error, OpCompare, OpProduct, OpSum, OpUnary}, token::{get_error_by_name, Error, OpCompare, OpProduct, OpSum, OpUnary},
types::*, types::*,
@@ -83,6 +84,24 @@ pub(crate) enum ParsedDefinedName {
InvalidDefinedNameFormula, InvalidDefinedNameFormula,
} }
/// A support node is either a cell or a range of cells
pub(crate) enum SupportNode {
/// (sheet, row, column)
Cell((u32, i32, i32)),
/// (sheet, row, column, height, width)
Range((u32, i32, i32, u32, u32))
}
/// The state of the computation
pub(crate) enum EvaluationState {
/// the model is ready for a new evaluation
Ready,
/// the model is evaluating cells that might spill
EvaluatingSpills,
/// the model is evaluating cells normally
Evaluating
}
/// A dynamical IronCalc model. /// A dynamical IronCalc model.
/// ///
/// Its is composed of a `Workbook`. Everything else are dynamical quantities: /// Its is composed of a `Workbook`. Everything else are dynamical quantities:
@@ -99,15 +118,13 @@ pub struct Model {
/// A Rust internal representation of an Excel workbook /// A Rust internal representation of an Excel workbook
pub workbook: Workbook, pub workbook: Workbook,
/// A list of parsed formulas /// A list of parsed formulas
pub parsed_formulas: Vec<Vec<Node>>, pub parsed_formulas: Vec<Vec<(Node, StaticResult)>>,
/// A list of parsed defined names /// A list of parsed defined names
pub(crate) parsed_defined_names: HashMap<(Option<u32>, String), ParsedDefinedName>, pub(crate) parsed_defined_names: HashMap<(Option<u32>, String), ParsedDefinedName>,
/// An optimization to lookup strings faster /// An optimization to lookup strings faster
pub(crate) shared_strings: HashMap<String, usize>, pub(crate) shared_strings: HashMap<String, usize>,
/// An instance of the parser /// An instance of the parser
pub(crate) parser: Parser, pub(crate) parser: Parser,
/// 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 /// The locale of the model
pub(crate) locale: Locale, pub(crate) locale: Locale,
/// The language used /// The language used
@@ -116,6 +133,16 @@ pub struct Model {
pub(crate) tz: Tz, pub(crate) tz: Tz,
/// The view id. A view consists of a selected sheet and ranges. /// The view id. A view consists of a selected sheet and ranges.
pub(crate) view_id: u32, pub(crate) view_id: u32,
/// ** Runtime ***
/// The list of cells with formulas that are evaluated or being evaluated
pub(crate) cells: HashMap<(u32, i32, i32), CellState>,
/// The support graph. For a given cell (sheet, row, column) the list of cells and ranges that were requested
pub(crate) support_graph: HashMap<(u32, i32, i32), Vec<SupportNode>>,
/// If the model is in a switch state then spill cells in the indices should be switched and recalculation redone
pub(crate) switch_cells: Option<(i32, i32)>,
/// Stack of cells being evaluated
pub(crate) stack: Vec<(u32, i32, i32)>,
pub(crate) state: EvaluationState,
} }
// FIXME: Maybe this should be the same as CellReference // FIXME: Maybe this should be the same as CellReference
@@ -522,14 +549,203 @@ impl Model {
} }
Ok(format!("{}!{}{}", sheet.name, column, cell_reference.row)) Ok(format!("{}!{}{}", sheet.name, column, cell_reference.row))
} }
/// Sets sheet, target_row, target_column, (width, height), &v
#[allow(clippy::too_many_arguments)]
fn set_spill_cell_with_formula_value(
&mut self,
sheet: u32,
row: i32,
column: i32,
r: (i32, i32),
v: &CalcResult,
s: i32,
f: i32,
) -> Result<(), String> {
let new_cell = match v {
CalcResult::EmptyCell => Cell::DynamicCellFormulaNumber {
f,
v: 0.0,
s,
r,
a: false,
},
CalcResult::String(v) => Cell::DynamicCellFormulaString {
f,
v: v.clone(),
s,
r,
a: false,
},
CalcResult::Number(v) => Cell::DynamicCellFormulaNumber {
v: *v,
s,
r,
f,
a: false,
},
CalcResult::Boolean(b) => Cell::DynamicCellFormulaBoolean {
v: *b,
s,
r,
f,
a: false,
},
CalcResult::Error { error, .. } => Cell::DynamicCellFormulaError {
ei: error.clone(),
s,
r,
f,
a: false,
o: "".to_string(),
m: "".to_string(),
},
// These cannot happen
// FIXME: Maybe the type of get_cell_value should be different
CalcResult::Range { .. } | CalcResult::EmptyArg | CalcResult::Array(_) => {
Cell::DynamicCellFormulaError {
ei: Error::ERROR,
s,
r,
f,
a: false,
o: "".to_string(),
m: "".to_string(),
}
}
};
let sheet_data = &mut self.workbook.worksheet_mut(sheet)?.sheet_data;
match sheet_data.get_mut(&row) {
Some(column_data) => match column_data.get(&column) {
Some(_cell) => {
column_data.insert(column, new_cell);
}
None => {
column_data.insert(column, new_cell);
}
},
None => {
let mut column_data = HashMap::new();
column_data.insert(column, new_cell);
sheet_data.insert(row, column_data);
}
}
Ok(())
}
/// Sets a cell with a "spill" value
fn set_spill_cell_with_value(
&mut self,
sheet: u32,
row: i32,
column: i32,
m: (i32, i32),
v: &CalcResult,
) -> Result<(), String> {
let style_index = self.get_cell_style_index(sheet, row, column)?;
let new_style_index = if self.workbook.styles.style_is_quote_prefix(style_index) {
self.workbook
.styles
.get_style_without_quote_prefix(style_index)?
} else {
style_index
};
let new_cell = match v {
CalcResult::EmptyCell => Cell::SpillNumberCell {
v: 0.0,
s: style_index,
m,
},
CalcResult::String(s) => Cell::SpillStringCell {
v: s.clone(),
s: new_style_index,
m,
},
CalcResult::Number(f) => Cell::SpillNumberCell {
v: *f,
s: new_style_index,
m,
},
CalcResult::Boolean(b) => Cell::SpillBooleanCell {
v: *b,
s: new_style_index,
m,
},
CalcResult::Error { error, .. } => Cell::SpillErrorCell {
ei: error.clone(),
s: style_index,
m,
},
// These cannot happen
// FIXME: Maybe the type of get_cell_value should be different
CalcResult::Range { .. } | CalcResult::EmptyArg | CalcResult::Array(_) => {
Cell::SpillErrorCell {
ei: Error::ERROR,
s: style_index,
m,
}
}
};
let sheet_data = &mut self.workbook.worksheet_mut(sheet)?.sheet_data;
match sheet_data.get_mut(&row) {
Some(column_data) => match column_data.get(&column) {
Some(_cell) => {
column_data.insert(column, new_cell);
}
None => {
column_data.insert(column, new_cell);
}
},
None => {
let mut column_data = HashMap::new();
column_data.insert(column, new_cell);
sheet_data.insert(row, column_data);
}
}
Ok(())
}
/// Returns `None` if no cell has called this cell, otherwise returns the dependent cell
fn get_support_cell(&self, sheet: u32, row: i32, column: i32) -> Result<Option<&Cell>, String> {
self.workbook.supporting_cells.get(&(sheet, row, column)).map(|c| Some(c)).ok_or_else(|| "Cell not found".into())
}
/// Sets `result` in the cell given by `sheet` sheet index, row and column /// Sets `result` in the cell given by `sheet` sheet index, row and column
/// Note that will panic if the cell does not exist /// Note that will panic if the cell does not exist
/// It will do nothing if the cell does not have a formula /// It will do nothing if the cell does not have a formula
/// If the cell is an array or a range it will check if it is possible to spill to other cells
/// if it is not it will return an error.
/// Then it will check if any of the cells has been requested before.
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
fn set_cell_value(&mut self, cell_reference: CellReferenceIndex, result: &CalcResult) { fn set_cell_value(
&mut self,
cell_reference: CellReferenceIndex,
result: &CalcResult,
) -> Result<(), String> {
let CellReferenceIndex { sheet, column, row } = cell_reference; let CellReferenceIndex { sheet, column, row } = cell_reference;
let cell = &self.workbook.worksheets[sheet as usize].sheet_data[&row][&column]; let cell = self
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned()
.unwrap_or_default();
let s = cell.get_style(); let s = cell.get_style();
// If the cell is a dynamic cell we need to delete all the cells in the range
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
// skip the "mother" cell
if r == row && c == column {
continue;
}
self.cell_clear_contents(sheet, r, c)?;
}
}
}
if let Some(f) = cell.get_formula() { if let Some(f) = cell.get_formula() {
match result { match result {
CalcResult::Number(value) => { CalcResult::Number(value) => {
@@ -594,19 +810,145 @@ impl Model {
ei: error.clone(), ei: error.clone(),
}; };
} }
CalcResult::EmptyCell | CalcResult::EmptyArg => {
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::CellFormulaNumber { f, s, v: 0.0 };
}
CalcResult::Range { left, right } => { CalcResult::Range { left, right } => {
if left.sheet == right.sheet if left.sheet == right.sheet
&& left.row == right.row && left.row == right.row
&& left.column == right.column && left.column == right.column
{ {
let intersection_cell = CellReferenceIndex { // There is only one cell
let single_cell = CellReferenceIndex {
sheet: left.sheet, sheet: left.sheet,
column: left.column, column: left.column,
row: left.row, row: left.row,
}; };
let v = self.evaluate_cell(intersection_cell); let v = self.evaluate_cell(single_cell);
self.set_cell_value(cell_reference, &v); self.set_cell_value(cell_reference, &v)?;
} else { } else {
// We need to check if all the cells are empty, otherwise we mark the cell as #SPILL!
let mut all_empty = true;
for r in row..=row + right.row - left.row {
for c in column..=column + right.column - left.column {
if r == row && c == column {
// skip the "mother" cell
continue;
}
if !self.is_empty_cell(sheet, r, c).unwrap_or(false) {
all_empty = false;
break;
}
if let Some(support) = self.get_support_cell(sheet, r, c) {
all_empty = false;
}
}
if !all_empty {
break;
}
}
if !all_empty {
let o = match self.cell_reference_to_string(&cell_reference) {
Ok(s) => s,
Err(_) => "".to_string(),
};
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::DynamicCellFormulaError {
f,
s,
o,
m: "Result would spill to non empty cells".to_string(),
ei: Error::SPILL,
r: (1, 1),
a: false,
};
return Ok(());
}
// evaluate all the cells in that range
for r in left.row..=right.row {
for c in left.column..=right.column {
let cell_reference = CellReferenceIndex {
sheet: left.sheet,
row: r,
column: c,
};
// FIXME: We ned to return an error
self.evaluate_cell(cell_reference);
}
}
// now write the result in the target
for r in left.row..=right.row {
let row_delta = r - left.row;
for c in left.column..=right.column {
let column_delta = c - left.column;
// We need to put whatever is in (left.sheet, r, c) in
// (sheet, row + row_delta, column + column_delta)
// But we need to preserve the style
let target_row = row + row_delta;
let target_column = column + column_delta;
let cell = self
.workbook
.worksheet(left.sheet)?
.cell(r, c)
.cloned()
.unwrap_or_default();
let cell_reference = CellReferenceIndex {
sheet: left.sheet,
row: r,
column: c,
};
let v = self.get_cell_value(&cell, cell_reference);
if row == target_row && column == target_column {
// let cell_reference = CellReferenceIndex { sheet, row, column };
// self.set_cell_value(cell_reference, &v);
self.set_spill_cell_with_formula_value(
sheet,
target_row,
target_column,
(right.column - left.column + 1, right.row - left.row + 1),
&v,
s,
f,
)?;
continue;
}
self.set_spill_cell_with_value(
sheet,
target_row,
target_column,
(row, column),
&v,
)?;
}
}
}
}
CalcResult::Array(array) => {
let width = array[0].len() as i32;
let height = array.len() as i32;
// First we check that we don't spill:
let mut all_empty = true;
for r in row..row + height {
for c in column..column + width {
if r == row && c == column {
continue;
}
if !self.is_empty_cell(sheet, r, c).unwrap_or(false) {
all_empty = false;
break;
}
}
}
if !all_empty {
let o = match self.cell_reference_to_string(&cell_reference) { let o = match self.cell_reference_to_string(&cell_reference) {
Ok(s) => s, Ok(s) => s,
Err(_) => "".to_string(), Err(_) => "".to_string(),
@@ -620,57 +962,65 @@ impl Model {
f, f,
s, s,
o, o,
m: "Implicit Intersection not implemented".to_string(), m: "Result would spill to non empty cells".to_string(),
ei: Error::NIMPL, ei: Error::SPILL,
}; };
return Ok(());
}
let mut target_row = row;
for data_row in array {
let mut target_column = column;
for value in data_row {
if row == target_row && column == target_column {
// This is the root cell of the dynamic array
let cell_reference = CellReferenceIndex { sheet, row, column };
let v = match value {
ArrayNode::Boolean(b) => CalcResult::Boolean(*b),
ArrayNode::Number(f) => CalcResult::Number(*f),
ArrayNode::String(s) => CalcResult::String(s.clone()),
ArrayNode::Error(error) => CalcResult::new_error(
error.clone(),
cell_reference,
error.to_localized_error_string(&self.language),
),
};
self.set_spill_cell_with_formula_value(
sheet,
target_row,
target_column,
(width, height),
&v,
s,
f,
)?;
target_column += 1;
continue;
}
let v = match value {
ArrayNode::Boolean(b) => CalcResult::Boolean(*b),
ArrayNode::Number(f) => CalcResult::Number(*f),
ArrayNode::String(s) => CalcResult::String(s.clone()),
ArrayNode::Error(error) => CalcResult::new_error(
error.clone(),
cell_reference,
error.to_localized_error_string(&self.language),
),
};
self.set_spill_cell_with_value(
sheet,
target_row,
target_column,
(row, column),
&v,
)?;
target_column += 1;
}
target_row += 1;
} }
// if let Some(intersection_cell) = implicit_intersection(&cell_reference, &range)
// {
// let v = self.evaluate_cell(intersection_cell);
// self.set_cell_value(cell_reference, &v);
// } else {
// let o = match self.cell_reference_to_string(&cell_reference) {
// Ok(s) => s,
// Err(_) => "".to_string(),
// };
// *self.workbook.worksheets[sheet as usize]
// .sheet_data
// .get_mut(&row)
// .expect("expected a row")
// .get_mut(&column)
// .expect("expected a column") = Cell::CellFormulaError {
// f,
// s,
// o,
// m: "Invalid reference".to_string(),
// ei: Error::VALUE,
// };
// }
}
CalcResult::EmptyCell | CalcResult::EmptyArg => {
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::CellFormulaNumber { f, s, v: 0.0 };
}
CalcResult::Array(_) => {
*self.workbook.worksheets[sheet as usize]
.sheet_data
.get_mut(&row)
.expect("expected a row")
.get_mut(&column)
.expect("expected a column") = Cell::CellFormulaError {
f,
s,
o: "".to_string(),
m: "Arrays not supported yet".to_string(),
ei: Error::NIMPL,
};
} }
} }
} }
Ok(())
} }
/// Sets the color of the sheet tab. /// Sets the color of the sheet tab.
@@ -714,16 +1064,18 @@ impl Model {
Ok(()) Ok(())
} }
// EmptyCell, Boolean, Number, String, Error
fn get_cell_value(&self, cell: &Cell, cell_reference: CellReferenceIndex) -> CalcResult { fn get_cell_value(&self, cell: &Cell, cell_reference: CellReferenceIndex) -> CalcResult {
use Cell::*; use Cell::*;
match cell { match cell {
EmptyCell { .. } => CalcResult::EmptyCell, EmptyCell { .. } => CalcResult::EmptyCell,
BooleanCell { v, .. } => CalcResult::Boolean(*v), BooleanCell { v, .. } | SpillBooleanCell { v, .. } => CalcResult::Boolean(*v),
NumberCell { v, .. } => CalcResult::Number(*v), NumberCell { v, .. } | SpillNumberCell { v, .. } => CalcResult::Number(*v),
ErrorCell { ei, .. } => { ErrorCell { ei, .. } | SpillErrorCell { ei, .. } => {
let message = ei.to_localized_error_string(&self.language); let message = ei.to_localized_error_string(&self.language);
CalcResult::new_error(ei.clone(), cell_reference, message) CalcResult::new_error(ei.clone(), cell_reference, message)
} }
SpillStringCell { v, .. } => CalcResult::String(v.clone()),
SharedString { si, .. } => { SharedString { si, .. } => {
if let Some(s) = self.workbook.shared_strings.get(*si as usize) { if let Some(s) = self.workbook.shared_strings.get(*si as usize) {
CalcResult::String(s.clone()) CalcResult::String(s.clone())
@@ -732,15 +1084,21 @@ impl Model {
CalcResult::new_error(Error::ERROR, cell_reference, message) CalcResult::new_error(Error::ERROR, cell_reference, message)
} }
} }
CellFormula { .. } => CalcResult::Error { DynamicCellFormula { .. } | CellFormula { .. } => CalcResult::Error {
error: Error::ERROR, error: Error::ERROR,
origin: cell_reference, origin: cell_reference,
message: "Unevaluated formula".to_string(), message: "Unevaluated formula".to_string(),
}, },
CellFormulaBoolean { v, .. } => CalcResult::Boolean(*v), DynamicCellFormulaBoolean { v, .. } | CellFormulaBoolean { v, .. } => {
CellFormulaNumber { v, .. } => CalcResult::Number(*v), CalcResult::Boolean(*v)
CellFormulaString { v, .. } => CalcResult::String(v.clone()), }
CellFormulaError { ei, o, m, .. } => { DynamicCellFormulaNumber { v, .. } | CellFormulaNumber { v, .. } => {
CalcResult::Number(*v)
}
DynamicCellFormulaString { v, .. } | CellFormulaString { v, .. } => {
CalcResult::String(v.clone())
}
DynamicCellFormulaError { ei, o, m, .. } | CellFormulaError { ei, o, m, .. } => {
if let Some(cell_reference) = self.parse_reference(o) { if let Some(cell_reference) = self.parse_reference(o) {
CalcResult::new_error(ei.clone(), cell_reference, m.clone()) CalcResult::new_error(ei.clone(), cell_reference, m.clone())
} else { } else {
@@ -772,6 +1130,8 @@ impl Model {
self.workbook.worksheet(sheet)?.is_empty_cell(row, column) self.workbook.worksheet(sheet)?.is_empty_cell(row, column)
} }
/// Evaluates the cell. After the evaluation is done puts the value in the cell and other cells if it spills.
/// If when writing a spill cell encounter a cell whose value has been requested marks the model as "dirty"
pub(crate) fn evaluate_cell(&mut self, cell_reference: CellReferenceIndex) -> CalcResult { pub(crate) fn evaluate_cell(&mut self, cell_reference: CellReferenceIndex) -> CalcResult {
let row_data = match self.workbook.worksheets[cell_reference.sheet as usize] let row_data = match self.workbook.worksheets[cell_reference.sheet as usize]
.sheet_data .sheet_data
@@ -810,9 +1170,10 @@ impl Model {
self.cells.insert(key, CellState::Evaluating); self.cells.insert(key, CellState::Evaluating);
} }
} }
let node = &self.parsed_formulas[cell_reference.sheet as usize][f as usize].clone(); let (node, _static_result) =
let result = self.evaluate_node_in_context(node, cell_reference); &self.parsed_formulas[cell_reference.sheet as usize][f as usize];
self.set_cell_value(cell_reference, &result); let result = self.evaluate_node_in_context(&node.clone(), cell_reference);
let _ = self.set_cell_value(cell_reference, &result);
// mark cell as evaluated // mark cell as evaluated
self.cells.insert(key, CellState::Evaluated); self.cells.insert(key, CellState::Evaluated);
result result
@@ -922,6 +1283,10 @@ impl Model {
locale, locale,
tz, tz,
view_id: 0, view_id: 0,
support_graph: HashMap::new(),
switch_cells: None,
stack: Vec::new(),
state: EvaluationState::Ready,
}; };
model.parse_formulas(); model.parse_formulas();
@@ -1100,7 +1465,8 @@ impl Model {
Some(cell) => match cell.get_formula() { Some(cell) => match cell.get_formula() {
None => cell.get_text(&self.workbook.shared_strings, &self.language), None => cell.get_text(&self.workbook.shared_strings, &self.language),
Some(i) => { Some(i) => {
let formula = &self.parsed_formulas[sheet as usize][i as usize]; let (formula, static_result) =
&self.parsed_formulas[sheet as usize][i as usize];
let cell_ref = CellReferenceRC { let cell_ref = CellReferenceRC {
sheet: self.workbook.worksheets[sheet as usize].get_name(), sheet: self.workbook.worksheets[sheet as usize].get_name(),
row: target_row, row: target_row,
@@ -1203,7 +1569,8 @@ impl Model {
.get(sheet as usize) .get(sheet as usize)
.ok_or("missing sheet")? .ok_or("missing sheet")?
.get(formula_index as usize) .get(formula_index as usize)
.ok_or("missing formula")?; .ok_or("missing formula")?
.0;
let cell_ref = CellReferenceRC { let cell_ref = CellReferenceRC {
sheet: worksheet.get_name(), sheet: worksheet.get_name(),
row, row,
@@ -1437,6 +1804,25 @@ impl Model {
column: i32, column: i32,
value: String, value: String,
) -> Result<(), String> { ) -> Result<(), String> {
// We need to check if the cell is part of a dynamic array
let cell = self
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned()
.unwrap_or_default();
// If the cell is a dynamic cell we need to delete all the cells in the range
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
// skip the "mother" cell
if r == row && c == column {
continue;
}
self.cell_clear_contents(sheet, r, c)?;
}
}
}
// If value starts with "'" then we force the style to be quote_prefix // If value starts with "'" then we force the style to be quote_prefix
let style_index = self.get_cell_style_index(sheet, row, column)?; let style_index = self.get_cell_style_index(sheet, row, column)?;
if let Some(new_value) = value.strip_prefix('\'') { if let Some(new_value) = value.strip_prefix('\'') {
@@ -1462,8 +1848,9 @@ impl Model {
self.set_cell_with_formula(sheet, row, column, formula, new_style_index)?; self.set_cell_with_formula(sheet, row, column, formula, new_style_index)?;
// Update the style if needed // Update the style if needed
let cell = CellReferenceIndex { sheet, row, column }; let cell = CellReferenceIndex { sheet, row, column };
let parsed_formula = &self.parsed_formulas[sheet as usize][formula_index as usize]; let (parsed_formula, static_result) =
if let Some(units) = self.compute_node_units(parsed_formula, &cell) { self.parsed_formulas[sheet as usize][formula_index as usize].clone();
if let Some(units) = self.compute_node_units(&parsed_formula, &cell) {
let new_style_index = self let new_style_index = self
.workbook .workbook
.styles .styles
@@ -1471,6 +1858,14 @@ impl Model {
let style = self.workbook.styles.get_style(new_style_index)?; let style = self.workbook.styles.get_style(new_style_index)?;
self.set_cell_style(sheet, row, column, &style)? self.set_cell_style(sheet, row, column, &style)?
} }
match static_result {
StaticResult::Scalar => {}
StaticResult::Array(_, _)
| StaticResult::Range(_, _)
| StaticResult::Unknown => {
self.workbook.spill_cells.push((sheet, row, column));
}
}
} else { } else {
// The list of currencies is '$', '€' and the local currency // The list of currencies is '$', '€' and the local currency
let mut currencies = vec!["$", ""]; let mut currencies = vec!["$", ""];
@@ -1544,6 +1939,7 @@ impl Model {
_ => parsed_formula = new_parsed_formula, _ => parsed_formula = new_parsed_formula,
} }
} }
let static_result = run_static_analysis_on_node(&parsed_formula);
let s = to_rc_format(&parsed_formula); let s = to_rc_format(&parsed_formula);
let mut formula_index: i32 = -1; let mut formula_index: i32 = -1;
@@ -1552,7 +1948,7 @@ impl Model {
} }
if formula_index == -1 { if formula_index == -1 {
shared_formulas.push(s); shared_formulas.push(s);
self.parsed_formulas[sheet as usize].push(parsed_formula); self.parsed_formulas[sheet as usize].push((parsed_formula, static_result));
formula_index = (shared_formulas.len() as i32) - 1; formula_index = (shared_formulas.len() as i32) - 1;
} }
worksheet.set_cell_with_formula(row, column, formula_index, style)?; worksheet.set_cell_with_formula(row, column, formula_index, style)?;
@@ -1747,7 +2143,7 @@ impl Model {
}; };
match cell.get_formula() { match cell.get_formula() {
Some(formula_index) => { Some(formula_index) => {
let formula = &self.parsed_formulas[sheet as usize][formula_index as usize]; let formula = &self.parsed_formulas[sheet as usize][formula_index as usize].0;
let cell_ref = CellReferenceRC { let cell_ref = CellReferenceRC {
sheet: worksheet.get_name(), sheet: worksheet.get_name(),
row, row,
@@ -1783,9 +2179,34 @@ impl Model {
/// Evaluates the model with a top-down recursive algorithm /// Evaluates the model with a top-down recursive algorithm
pub fn evaluate(&mut self) { pub fn evaluate(&mut self) {
// clear all computation artifacts // We first evaluate all the cells that might spill to other cells
self.cells.clear(); let mut spills_computed = false;
self.state = EvaluationState::EvaluatingSpills;
while !spills_computed {
spills_computed = true;
// clear all computation artifacts
self.cells.clear();
// Evaluate all the cells that might spill
let spill_cells = self.workbook.spill_cells.clone();
for (sheet, row, column) in spill_cells {
self.evaluate_cell(CellReferenceIndex { sheet, row, column });
if self.switch_cells.is_some() {
spills_computed = false;
break;
}
}
if let Some((index1, index2)) = self.switch_cells {
spills_computed = false;
// switch the cells indices in the spill_cells
let cell1 = self.workbook.spill_cells[index1 as usize];
let cell2 = self.workbook.spill_cells[index2 as usize];
self.workbook.spill_cells[index1 as usize] = cell2;
self.workbook.spill_cells[index2 as usize] = cell1;
}
}
self.state = EvaluationState::Evaluating;
// Now we compute all the rest
let cells = self.get_all_cells(); let cells = self.get_all_cells();
for cell in cells { for cell in cells {
@@ -1795,6 +2216,7 @@ impl Model {
column: cell.column, column: cell.column,
}); });
} }
self.state = EvaluationState::Ready;
} }
/// Removes the content of the cell but leaves the style. /// Removes the content of the cell but leaves the style.
@@ -1818,9 +2240,22 @@ impl Model {
/// # } /// # }
/// ``` /// ```
pub fn cell_clear_contents(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> { pub fn cell_clear_contents(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> {
self.workbook // If it has a spill formula we need to delete the contents of all the spilled cells
.worksheet_mut(sheet)? let worksheet = self.workbook.worksheet_mut(sheet)?;
.cell_clear_contents(row, column)?; if let Some(cell) = worksheet.cell(row, column) {
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
if row == r && column == c {
// we skip the root cell
continue;
}
worksheet.cell_clear_contents(r, c)?;
}
}
}
}
worksheet.cell_clear_contents(row, column)?;
Ok(()) Ok(())
} }
@@ -1845,6 +2280,18 @@ impl Model {
/// # } /// # }
pub fn cell_clear_all(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> { pub fn cell_clear_all(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> {
let worksheet = self.workbook.worksheet_mut(sheet)?; let worksheet = self.workbook.worksheet_mut(sheet)?;
// Delete the contents of spilled cells if any
if let Some(cell) = worksheet.cell(row, column) {
if let Some((width, height)) = cell.get_dynamic_range() {
for r in row..row + height {
for c in column..column + width {
if row == r && c == column {
worksheet.cell_clear_contents(r, c)?;
}
}
}
}
}
let sheet_data = &mut worksheet.sheet_data; let sheet_data = &mut worksheet.sheet_data;
if let Some(row_data) = sheet_data.get_mut(&row) { if let Some(row_data) = sheet_data.get_mut(&row) {

View File

@@ -8,6 +8,7 @@ use crate::{
expressions::{ expressions::{
lexer::LexerMode, lexer::LexerMode,
parser::{ parser::{
static_analysis::run_static_analysis_on_node,
stringify::{rename_sheet_in_node, to_rc_format, to_string}, stringify::{rename_sheet_in_node, to_rc_format, to_string},
Parser, Parser,
}, },
@@ -15,7 +16,7 @@ use crate::{
}, },
language::get_language, language::get_language,
locale::get_locale, locale::get_locale,
model::{get_milliseconds_since_epoch, Model, ParsedDefinedName}, model::{get_milliseconds_since_epoch, EvaluationState, Model, ParsedDefinedName},
types::{ types::{
DefinedName, Metadata, SheetState, Workbook, WorkbookSettings, WorkbookView, Worksheet, DefinedName, Metadata, SheetState, Workbook, WorkbookSettings, WorkbookView, Worksheet,
WorksheetView, WorksheetView,
@@ -94,7 +95,8 @@ impl Model {
let mut parse_formula = Vec::new(); let mut parse_formula = Vec::new();
for formula in shared_formulas { for formula in shared_formulas {
let t = self.parser.parse(formula, &cell_reference); let t = self.parser.parse(formula, &cell_reference);
parse_formula.push(t); let static_result = run_static_analysis_on_node(&t);
parse_formula.push((t, static_result));
} }
self.parsed_formulas.push(parse_formula); self.parsed_formulas.push(parse_formula);
} }
@@ -405,6 +407,7 @@ impl Model {
}, },
tables: HashMap::new(), tables: HashMap::new(),
views, views,
spill_cells: Vec::new(),
}; };
let parsed_formulas = Vec::new(); let parsed_formulas = Vec::new();
let worksheets = &workbook.worksheets; let worksheets = &workbook.worksheets;
@@ -427,6 +430,10 @@ impl Model {
language, language,
tz, tz,
view_id: 0, view_id: 0,
support_graph: HashMap::new(),
switch_cells: None,
stack: Vec::new(),
state: EvaluationState::Ready,
}; };
model.parse_formulas(); model.parse_formulas();
Ok(model) Ok(model)

View File

@@ -7,8 +7,6 @@ mod test_column_width;
mod test_criteria; mod test_criteria;
mod test_currency; mod test_currency;
mod test_date_and_time; mod test_date_and_time;
mod test_datedif_leap_month_end;
mod test_days360_month_end;
mod test_error_propagation; mod test_error_propagation;
mod test_fn_average; mod test_fn_average;
mod test_fn_averageifs; mod test_fn_averageifs;
@@ -29,7 +27,6 @@ mod test_fn_sum;
mod test_fn_sumifs; mod test_fn_sumifs;
mod test_fn_textbefore; mod test_fn_textbefore;
mod test_fn_textjoin; mod test_fn_textjoin;
mod test_fn_time;
mod test_fn_unicode; mod test_fn_unicode;
mod test_frozen_rows_columns; mod test_frozen_rows_columns;
mod test_general; mod test_general;
@@ -46,11 +43,8 @@ mod test_sheets;
mod test_styles; mod test_styles;
mod test_trigonometric; mod test_trigonometric;
mod test_true_false; mod test_true_false;
mod test_weekday_return_types;
mod test_weeknum_return_types;
mod test_workbook; mod test_workbook;
mod test_worksheet; mod test_worksheet;
mod test_yearfrac_basis;
pub(crate) mod util; pub(crate) mod util;
mod engineering; mod engineering;
@@ -58,6 +52,7 @@ mod test_fn_offset;
mod test_number_format; mod test_number_format;
mod test_arrays; mod test_arrays;
mod test_dynamic_arrays;
mod test_escape_quotes; mod test_escape_quotes;
mod test_extend; mod test_extend;
mod test_fn_fv; mod test_fn_fv;
@@ -71,7 +66,6 @@ mod test_issue_155;
mod test_ln; mod test_ln;
mod test_log; mod test_log;
mod test_log10; mod test_log10;
mod test_networkdays;
mod test_percentage; mod test_percentage;
mod test_set_functions_error_handling; mod test_set_functions_error_handling;
mod test_today; mod test_today;

View File

@@ -6,11 +6,6 @@
/// We can also enter examples that illustrate/document a part of the function /// We can also enter examples that illustrate/document a part of the function
use crate::{cell::CellValue, test::util::new_empty_model}; use crate::{cell::CellValue, test::util::new_empty_model};
// Excel uses a serial date system where Jan 1, 1900 = 1 (though it treats 1900 as a leap year)
// Most test dates are documented inline, but we define boundary values here:
const EXCEL_MAX_DATE: f64 = 2958465.0; // Dec 31, 9999 - used in boundary tests
const EXCEL_INVALID_DATE: f64 = 2958466.0; // One day past max - used in error tests
#[test] #[test]
fn test_fn_date_arguments() { fn test_fn_date_arguments() {
let mut model = new_empty_model(); let mut model = new_empty_model();
@@ -221,382 +216,3 @@ fn test_date_early_dates() {
Ok(CellValue::Number(61.0)) Ok(CellValue::Number(61.0))
); );
} }
#[test]
fn test_days_function() {
let mut model = new_empty_model();
// Basic functionality
model._set("A1", "=DAYS(44570,44561)");
model._set("A2", "=DAYS(44561,44570)"); // Reversed order
model._set("A3", "=DAYS(44561,44561)");
// Edge cases
model._set("A4", "=DAYS(1,2)"); // Early dates
model._set(
"A5",
&format!("=DAYS({},{})", EXCEL_MAX_DATE, EXCEL_MAX_DATE - 1.0),
); // Near max date
// Error cases - wrong argument count
model._set("A6", "=DAYS()");
model._set("A7", "=DAYS(44561)");
model._set("A8", "=DAYS(44561,44570,1)");
// Error cases - invalid dates
model._set("A9", "=DAYS(-1,44561)");
model._set("A10", &format!("=DAYS(44561,{EXCEL_INVALID_DATE})"));
model.evaluate();
assert_eq!(model._get_text("A1"), *"9");
assert_eq!(model._get_text("A2"), *"-9");
assert_eq!(model._get_text("A3"), *"0");
assert_eq!(model._get_text("A4"), *"-1"); // DAYS(1,2) = 1-2 = -1
assert_eq!(model._get_text("A5"), *"1");
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#ERROR!");
assert_eq!(model._get_text("A9"), *"#NUM!");
assert_eq!(model._get_text("A10"), *"#NUM!");
}
#[test]
fn test_days360_function() {
let mut model = new_empty_model();
// Basic functionality with different basis values
model._set("A1", "=DAYS360(44196,44560)"); // Default basis (US 30/360)
model._set("A2", "=DAYS360(44196,44560,FALSE)"); // US 30/360 explicitly
model._set("A3", "=DAYS360(44196,44560,TRUE)"); // European 30/360
// Same date
model._set("A4", "=DAYS360(44561,44561)");
model._set("A5", "=DAYS360(44561,44561,TRUE)");
// Reverse order (negative result)
model._set("A6", "=DAYS360(44560,44196)");
model._set("A7", "=DAYS360(44560,44196,TRUE)");
// Edge cases
model._set("A8", "=DAYS360(1,2)");
model._set("A9", "=DAYS360(1,2,FALSE)");
// Error cases - wrong argument count
model._set("A10", "=DAYS360()");
model._set("A11", "=DAYS360(44561)");
model._set("A12", "=DAYS360(44561,44570,TRUE,1)");
// Error cases - invalid dates
model._set("A13", "=DAYS360(-1,44561)");
model._set("A14", &format!("=DAYS360(44561,{EXCEL_INVALID_DATE})"));
model.evaluate();
assert_eq!(model._get_text("A1"), *"360");
assert_eq!(model._get_text("A2"), *"360");
assert_eq!(model._get_text("A3"), *"360");
assert_eq!(model._get_text("A4"), *"0");
assert_eq!(model._get_text("A5"), *"0");
assert_eq!(model._get_text("A6"), *"-360");
assert_eq!(model._get_text("A7"), *"-360");
assert_eq!(model._get_text("A8"), *"1");
assert_eq!(model._get_text("A9"), *"1");
assert_eq!(model._get_text("A10"), *"#ERROR!");
assert_eq!(model._get_text("A11"), *"#ERROR!");
assert_eq!(model._get_text("A12"), *"#ERROR!");
assert_eq!(model._get_text("A13"), *"#NUM!");
assert_eq!(model._get_text("A14"), *"#NUM!");
}
#[test]
fn test_weekday_function() {
let mut model = new_empty_model();
// Test return_type parameter variations with one known date (Friday 44561)
model._set("A1", "=WEEKDAY(44561)"); // Default: Sun=1, Fri=6
model._set("A2", "=WEEKDAY(44561,2)"); // Mon=1, Fri=5
model._set("A3", "=WEEKDAY(44561,3)"); // Mon=0, Fri=4
// Test boundary days (Sun/Mon) to verify return_type logic
model._set("A4", "=WEEKDAY(44556,1)"); // Sunday: should be 1
model._set("A5", "=WEEKDAY(44556,2)"); // Sunday: should be 7
model._set("A6", "=WEEKDAY(44557,2)"); // Monday: should be 1
// Error cases
model._set("A7", "=WEEKDAY()"); // Wrong arg count
model._set("A8", "=WEEKDAY(44561,0)"); // Invalid return_type
model._set("A9", "=WEEKDAY(-1)"); // Invalid date
model.evaluate();
// Core functionality
assert_eq!(model._get_text("A1"), *"6"); // Friday default
assert_eq!(model._get_text("A2"), *"5"); // Friday Mon=1
assert_eq!(model._get_text("A3"), *"4"); // Friday Mon=0
// Boundary verification
assert_eq!(model._get_text("A4"), *"1"); // Sunday Sun=1
assert_eq!(model._get_text("A5"), *"7"); // Sunday Mon=1
assert_eq!(model._get_text("A6"), *"1"); // Monday Mon=1
// Error cases
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#VALUE!");
assert_eq!(model._get_text("A9"), *"#NUM!");
}
#[test]
fn test_weeknum_function() {
let mut model = new_empty_model();
// Test different return_type values (1=week starts Sunday, 2=week starts Monday)
model._set("A1", "=WEEKNUM(44561)"); // Default return_type=1
model._set("A2", "=WEEKNUM(44561,1)"); // Sunday start
model._set("A3", "=WEEKNUM(44561,2)"); // Monday start
// Test year boundaries
model._set("A4", "=WEEKNUM(43831,1)"); // Jan 1, 2020 (Wednesday)
model._set("A5", "=WEEKNUM(43831,2)"); // Jan 1, 2020 (Wednesday)
model._set("A6", "=WEEKNUM(44196,1)"); // Dec 31, 2020 (Thursday)
model._set("A7", "=WEEKNUM(44196,2)"); // Dec 31, 2020 (Thursday)
// Test first and last weeks of year
model._set("A8", "=WEEKNUM(44197,1)"); // Jan 1, 2021 (Friday)
model._set("A9", "=WEEKNUM(44197,2)"); // Jan 1, 2021 (Friday)
model._set("A10", "=WEEKNUM(44561,1)"); // Dec 31, 2021 (Friday)
model._set("A11", "=WEEKNUM(44561,2)"); // Dec 31, 2021 (Friday)
// Error cases - wrong argument count
model._set("A12", "=WEEKNUM()");
model._set("A13", "=WEEKNUM(44561,1,1)");
// Error cases - invalid return_type
model._set("A14", "=WEEKNUM(44561,0)");
model._set("A15", "=WEEKNUM(44561,3)");
model._set("A16", "=WEEKNUM(44561,-1)");
// Error cases - invalid dates
model._set("A17", "=WEEKNUM(-1)");
model._set("A18", &format!("=WEEKNUM({EXCEL_INVALID_DATE})"));
model.evaluate();
// Basic functionality
assert_eq!(model._get_text("A1"), *"53"); // Week 53
assert_eq!(model._get_text("A2"), *"53"); // Week 53 (Sunday start)
assert_eq!(model._get_text("A3"), *"53"); // Week 53 (Monday start)
// Year boundary tests
assert_eq!(model._get_text("A4"), *"1"); // Jan 1, 2020 (Sunday start)
assert_eq!(model._get_text("A5"), *"1"); // Jan 1, 2020 (Monday start)
assert_eq!(model._get_text("A6"), *"53"); // Dec 31, 2020 (Sunday start)
assert_eq!(model._get_text("A7"), *"53"); // Dec 31, 2020 (Monday start)
// 2021 tests
assert_eq!(model._get_text("A8"), *"1"); // Jan 1, 2021 (Sunday start)
assert_eq!(model._get_text("A9"), *"1"); // Jan 1, 2021 (Monday start)
assert_eq!(model._get_text("A10"), *"53"); // Dec 31, 2021 (Sunday start)
assert_eq!(model._get_text("A11"), *"53"); // Dec 31, 2021 (Monday start)
// Error cases
assert_eq!(model._get_text("A12"), *"#ERROR!");
assert_eq!(model._get_text("A13"), *"#ERROR!");
assert_eq!(model._get_text("A14"), *"#VALUE!");
assert_eq!(model._get_text("A15"), *"#VALUE!");
assert_eq!(model._get_text("A16"), *"#VALUE!");
assert_eq!(model._get_text("A17"), *"#NUM!");
assert_eq!(model._get_text("A18"), *"#NUM!");
}
#[test]
fn test_workday_function() {
let mut model = new_empty_model();
// Basic functionality
model._set("A1", "=WORKDAY(44560,1)");
model._set("A2", "=WORKDAY(44561,-1)");
model._set("A3", "=WORKDAY(44561,0)");
model._set("A4", "=WORKDAY(44560,5)");
// Test with holidays
model._set("B1", "44561");
model._set("A5", "=WORKDAY(44560,1,B1)"); // Should skip the holiday
model._set("B2", "44562");
model._set("B3", "44563");
model._set("A6", "=WORKDAY(44560,3,B1:B3)"); // Multiple holidays
// Test starting on weekend
model._set("A7", "=WORKDAY(44562,1)"); // Saturday start
model._set("A8", "=WORKDAY(44563,1)"); // Sunday start
// Test negative workdays
model._set("A9", "=WORKDAY(44565,-3)"); // Go backwards 3 days
model._set("A10", "=WORKDAY(44565,-5,B1:B3)"); // Backwards with holidays
// Edge cases
model._set("A11", "=WORKDAY(1,1)"); // Early date
model._set("A12", "=WORKDAY(100000,10)"); // Large numbers
// Error cases - wrong argument count
model._set("A13", "=WORKDAY()");
model._set("A14", "=WORKDAY(44560)");
model._set("A15", "=WORKDAY(44560,1,B1,B2)");
// Error cases - invalid dates
model._set("A16", "=WORKDAY(-1,1)");
model._set("A17", &format!("=WORKDAY({EXCEL_INVALID_DATE},1)"));
// Error cases - invalid holiday dates
model._set("B4", "-1");
model._set("A18", "=WORKDAY(44560,1,B4)");
model.evaluate();
// Basic functionality
assert_eq!(model._get_text("A1"), *"44561"); // 1 day forward
assert_eq!(model._get_text("A2"), *"44560"); // 1 day backward
assert_eq!(model._get_text("A3"), *"44561"); // 0 days
assert_eq!(model._get_text("A4"), *"44567"); // 5 days forward
// With holidays
assert_eq!(model._get_text("A5"), *"44564"); // Skip holiday, go to Monday
assert_eq!(model._get_text("A6"), *"44566"); // Skip multiple holidays
// Weekend starts
assert_eq!(model._get_text("A7"), *"44564"); // From Saturday
assert_eq!(model._get_text("A8"), *"44564"); // From Sunday
// Negative workdays
assert_eq!(model._get_text("A9"), *"44560"); // 3 days back
assert_eq!(model._get_text("A10"), *"44557"); // 5 days back with holidays
// Edge cases
assert_eq!(model._get_text("A11"), *"2"); // Early date
assert_eq!(model._get_text("A12"), *"100014"); // Large numbers
// Error cases
assert_eq!(model._get_text("A13"), *"#ERROR!");
assert_eq!(model._get_text("A14"), *"#ERROR!");
assert_eq!(model._get_text("A15"), *"#ERROR!");
assert_eq!(model._get_text("A16"), *"#NUM!");
assert_eq!(model._get_text("A17"), *"#NUM!");
assert_eq!(model._get_text("A18"), *"#NUM!"); // Invalid holiday
}
#[test]
fn test_workday_intl_function() {
let mut model = new_empty_model();
// Test key weekend mask types
model._set("A1", "=WORKDAY.INTL(44560,1,1)"); // Numeric: standard (Sat-Sun)
model._set("A2", "=WORKDAY.INTL(44560,1,2)"); // Numeric: Sun-Mon
model._set("A3", "=WORKDAY.INTL(44560,1,\"0000001\")"); // String: Sunday only
model._set("A4", "=WORKDAY.INTL(44560,1,\"1100000\")"); // String: Mon-Tue
// Test with holidays
model._set("B1", "44561");
model._set("A5", "=WORKDAY.INTL(44560,2,1,B1)"); // Standard + holiday
model._set("A6", "=WORKDAY.INTL(44560,2,7,B1)"); // Fri-Sat + holiday
// Basic edge cases
model._set("A7", "=WORKDAY.INTL(44561,0,1)"); // Zero days
model._set("A8", "=WORKDAY.INTL(44565,-1,1)"); // Negative days
// Error cases
model._set("A9", "=WORKDAY.INTL()"); // Wrong arg count
model._set("A10", "=WORKDAY.INTL(44560,1,0)"); // Invalid weekend mask
model._set("A11", "=WORKDAY.INTL(44560,1,\"123\")"); // Invalid string mask
model._set("A12", "=WORKDAY.INTL(-1,1,1)"); // Invalid date
model.evaluate();
// Weekend mask functionality
assert_eq!(model._get_text("A1"), *"44561"); // Standard weekend
assert_eq!(model._get_text("A2"), *"44561"); // Sun-Mon weekend
assert_eq!(model._get_text("A3"), *"44561"); // Sunday only
assert_eq!(model._get_text("A4"), *"44561"); // Mon-Tue weekend
// With holidays
assert_eq!(model._get_text("A5"), *"44565"); // Skip holiday + standard weekend
assert_eq!(model._get_text("A6"), *"44564"); // Skip holiday + Fri-Sat weekend
// Edge cases
assert_eq!(model._get_text("A7"), *"44561"); // Zero days
assert_eq!(model._get_text("A8"), *"44564"); // Negative days
// Error cases
assert_eq!(model._get_text("A9"), *"#ERROR!");
assert_eq!(model._get_text("A10"), *"#NUM!");
assert_eq!(model._get_text("A11"), *"#VALUE!");
assert_eq!(model._get_text("A12"), *"#NUM!");
}
#[test]
fn test_yearfrac_function() {
let mut model = new_empty_model();
// Test key basis values (not exhaustive - just verify parameter works)
model._set("A1", "=YEARFRAC(44561,44926)"); // Default (30/360)
model._set("A2", "=YEARFRAC(44561,44926,1)"); // Actual/actual
model._set("A3", "=YEARFRAC(44561,44926,4)"); // European 30/360
// Edge cases
model._set("A4", "=YEARFRAC(44561,44561,1)"); // Same date = 0
model._set("A5", "=YEARFRAC(44926,44561,1)"); // Reverse = negative
model._set("A6", "=YEARFRAC(44197,44562,1)"); // Exact year (2021)
// Error cases
model._set("A7", "=YEARFRAC()"); // Wrong arg count
model._set("A8", "=YEARFRAC(44561,44926,5)"); // Invalid basis
model._set("A9", "=YEARFRAC(-1,44926,1)"); // Invalid date
model.evaluate();
// Basic functionality (approximate values expected)
assert_eq!(model._get_text("A1"), *"1"); // About 1 year
assert_eq!(model._get_text("A2"), *"1"); // About 1 year
assert_eq!(model._get_text("A3"), *"1"); // About 1 year
// Edge cases
assert_eq!(model._get_text("A4"), *"0"); // Same date
assert_eq!(model._get_text("A5"), *"-1"); // Negative
assert_eq!(model._get_text("A6"), *"1"); // Exact year
// Error cases
assert_eq!(model._get_text("A7"), *"#ERROR!");
assert_eq!(model._get_text("A8"), *"#NUM!"); // Invalid basis should return #NUM!
assert_eq!(model._get_text("A9"), *"#NUM!");
}
#[test]
fn test_isoweeknum_function() {
let mut model = new_empty_model();
// Basic functionality
model._set("A1", "=ISOWEEKNUM(44563)"); // Mid-week date
model._set("A2", "=ISOWEEKNUM(44561)"); // Year-end date
// Key ISO week boundaries (just critical cases)
model._set("A3", "=ISOWEEKNUM(44197)"); // Jan 1, 2021 (Fri) -> Week 53 of 2020
model._set("A4", "=ISOWEEKNUM(44200)"); // Jan 4, 2021 (Mon) -> Week 1 of 2021
model._set("A5", "=ISOWEEKNUM(44564)"); // Jan 3, 2022 (Mon) -> Week 1 of 2022
// Error cases
model._set("A6", "=ISOWEEKNUM()"); // Wrong arg count
model._set("A7", "=ISOWEEKNUM(-1)"); // Invalid date
model.evaluate();
// Basic functionality
assert_eq!(model._get_text("A1"), *"52");
assert_eq!(model._get_text("A2"), *"52");
// ISO week boundaries
assert_eq!(model._get_text("A3"), *"53"); // Week 53 of previous year
assert_eq!(model._get_text("A4"), *"1"); // Week 1 of current year
assert_eq!(model._get_text("A5"), *"1"); // Week 1 of next year
// Error cases
assert_eq!(model._get_text("A6"), *"#ERROR!");
assert_eq!(model._get_text("A7"), *"#NUM!");
}

View File

@@ -1,33 +0,0 @@
use crate::test::util::new_empty_model;
#[test]
fn test_datedif_yd_leap_year_edge_cases() {
let mut model = new_empty_model();
// 29 Feb 2020 → 28 Feb 2021 (should be 0 days)
model._set("A1", "=DATEDIF(\"29/2/2020\", \"28/2/2021\", \"YD\")");
// 29 Feb 2020 → 1 Mar 2021 (should be 1 day)
model._set("A2", "=DATEDIF(\"29/2/2020\", \"2021-03-01\", \"YD\")");
model.evaluate();
assert_eq!(model._get_text("A1"), *"0");
assert_eq!(model._get_text("A2"), *"1");
}
#[test]
fn test_datedif_md_month_end_edge_cases() {
let mut model = new_empty_model();
// 31 Jan 2021 → 28 Feb 2021 (non-leap) => 28
model._set("B1", "=DATEDIF(\"31/1/2021\", \"28/2/2021\", \"MD\")");
// 31 Jan 2020 → 29 Feb 2020 (leap) => 29
model._set("B2", "=DATEDIF(\"31/1/2020\", \"29/2/2020\", \"MD\")");
model.evaluate();
assert_eq!(model._get_text("B1"), *"28");
assert_eq!(model._get_text("B2"), *"29");
}

View File

@@ -1,43 +0,0 @@
use crate::test::util::new_empty_model;
#[test]
fn test_days360_month_end_us() {
let mut model = new_empty_model();
// 31 Jan 2021 -> 28 Feb 2021 (non-leap)
model._set("A1", "=DAYS360(DATE(2021,1,31),DATE(2021,2,28))");
// 31 Jan 2020 -> 28 Feb 2020 (leap year not last day of Feb)
model._set("A2", "=DAYS360(DATE(2020,1,31),DATE(2020,2,28))");
// 28 Feb 2020 -> 31 Mar 2020 (leap year span crossing month ends)
model._set("A3", "=DAYS360(DATE(2020,2,28),DATE(2020,3,31))");
// 30 Apr 2021 -> 31 May 2021 (end-of-month adjustment rule)
model._set("A4", "=DAYS360(DATE(2021,4,30),DATE(2021,5,31))");
model.evaluate();
assert_eq!(model._get_text("A1"), *"30");
assert_eq!(model._get_text("A2"), *"28");
assert_eq!(model._get_text("A3"), *"33");
assert_eq!(model._get_text("A4"), *"30");
}
#[test]
fn test_days360_month_end_european() {
let mut model = new_empty_model();
// European basis = TRUE (or 1)
model._set("B1", "=DAYS360(DATE(2021,1,31),DATE(2021,2,28),TRUE)");
model._set("B2", "=DAYS360(DATE(2020,1,31),DATE(2020,2,29),TRUE)");
model._set("B3", "=DAYS360(DATE(2021,8,31),DATE(2021,9,30),TRUE)");
model.evaluate();
assert_eq!(model._get_text("B1"), *"28");
assert_eq!(model._get_text("B2"), *"29");
assert_eq!(model._get_text("B3"), *"30");
}

View File

@@ -0,0 +1,50 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
#[test]
fn they_spill() {
let mut model = new_empty_model();
model._set("A1", "42");
model._set("A2", "5");
model._set("A3", "7");
model._set("B1", "=A1:A3");
model.evaluate();
assert_eq!(model._get_text("B1"), *"42");
assert_eq!(model._get_text("B2"), *"5");
assert_eq!(model._get_text("B3"), *"7");
}
#[test]
fn spill_error() {
let mut model = new_empty_model();
model._set("A1", "42");
model._set("A2", "5");
model._set("A3", "7");
model._set("B1", "=A1:A3");
model._set("B2", "4");
model.evaluate();
assert_eq!(model._get_text("B1"), *"#SPILL!");
assert_eq!(model._get_text("B2"), *"4");
assert_eq!(model._get_text("B3"), *"");
}
#[test]
fn second_evaluation() {
let mut model = new_empty_model();
model._set("C3", "={1,2,3}");
model.evaluate();
assert_eq!(model._get_text("D3"), "2");
model._set("D8", "23");
model.evaluate();
assert_eq!(model._get_text("D3"), "2");
}

View File

@@ -1,182 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
use crate::types::Cell;
// Helper to evaluate a formula and return the formatted text
fn eval_formula(formula: &str) -> String {
let mut model = new_empty_model();
model._set("A1", formula);
model.evaluate();
model._get_text("A1")
}
// Helper that evaluates a formula and returns the raw value of A1 as a Result<f64, String>
fn eval_formula_raw_number(formula: &str) -> Result<f64, String> {
let mut model = new_empty_model();
model._set("A1", formula);
model.evaluate();
match model._get_cell("A1") {
Cell::NumberCell { v, .. } => Ok(*v),
Cell::BooleanCell { v, .. } => Ok(if *v { 1.0 } else { 0.0 }),
Cell::ErrorCell { ei, .. } => Err(format!("{}", ei)),
_ => Err(model._get_text("A1")),
}
}
#[test]
fn test_datevalue_basic_numeric() {
// DATEVALUE should return the serial number representing the date, **not** a formatted date
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"2/1/2023\")").unwrap(),
44958.0
);
}
#[test]
fn test_datevalue_mmdd_with_leading_zero() {
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"02/01/2023\")").unwrap(),
44958.0
); // 1-Feb-2023
}
#[test]
fn test_datevalue_iso() {
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"2023-01-02\")").unwrap(),
44928.0
);
}
#[test]
fn test_datevalue_month_name() {
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"2-Jan-23\")").unwrap(),
44928.0
);
}
#[test]
fn test_datevalue_ambiguous_ddmm() {
// 01/02/2023 interpreted as MM/DD -> 2-Jan-2023
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"01/02/2023\")").unwrap(),
44929.0
);
}
#[test]
fn test_datevalue_ddmm_unambiguous() {
// 15/01/2023 should be 15-Jan-2023 since 15 cannot be month
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"15/01/2023\")").unwrap(),
44941.0
);
}
#[test]
fn test_datevalue_leap_day() {
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"29/02/2020\")").unwrap(),
43890.0
);
}
#[test]
fn test_datevalue_year_first_text_month() {
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"2023/Jan/15\")").unwrap(),
44941.0
);
}
#[test]
fn test_datevalue_mmdd_with_day_gt_12() {
assert_eq!(
eval_formula_raw_number("=DATEVALUE(\"6/15/2021\")").unwrap(),
44373.0
);
}
#[test]
fn test_datevalue_error_conditions() {
let cases = [
"=DATEVALUE(\"31/04/2023\")", // invalid day (Apr has 30 days)
"=DATEVALUE(\"13/13/2023\")", // invalid month
"=DATEVALUE(\"not a date\")", // non-date text
];
for formula in cases {
let result = eval_formula(formula);
assert_eq!(result, *"#VALUE!", "Expected #VALUE! for {}", formula);
}
}
// Helper to set and evaluate a single DATEDIF call
fn eval_datedif(unit: &str) -> String {
let mut model = new_empty_model();
let formula = format!("=DATEDIF(\"2020-01-01\", \"2021-06-15\", \"{}\")", unit);
model._set("A1", &formula);
model.evaluate();
model._get_text("A1")
}
#[test]
fn test_datedif_y() {
assert_eq!(eval_datedif("Y"), *"1");
}
#[test]
fn test_datedif_m() {
assert_eq!(eval_datedif("M"), *"17");
}
#[test]
fn test_datedif_d() {
assert_eq!(eval_datedif("D"), *"531");
}
#[test]
fn test_datedif_ym() {
assert_eq!(eval_datedif("YM"), *"5");
}
#[test]
fn test_datedif_yd() {
assert_eq!(eval_datedif("YD"), *"165");
}
#[test]
fn test_datedif_md() {
assert_eq!(eval_datedif("MD"), *"14");
}
#[test]
fn test_datedif_edge_and_error_cases() {
let mut model = new_empty_model();
// Leap-year spanning
model._set("A1", "=DATEDIF(\"28/2/2020\", \"1/3/2020\", \"D\")");
// End date before start date => #NUM!
model._set("A2", "=DATEDIF(\"1/2/2021\", \"1/1/2021\", \"D\")");
// Invalid unit => #VALUE!
model._set("A3", "=DATEDIF(\"1/1/2020\", \"1/1/2021\", \"Z\")");
model.evaluate();
assert_eq!(model._get_text("A1"), *"2");
assert_eq!(model._get_text("A2"), *"#NUM!");
assert_eq!(model._get_text("A3"), *"#VALUE!");
}
#[test]
fn test_datedif_mixed_case_unit() {
assert_eq!(eval_datedif("yD"), *"165"); // mixed-case should work
}
#[test]
fn test_datedif_error_propagation() {
// Invalid date in arguments should propagate #VALUE!
let mut model = new_empty_model();
model._set("A1", "=DATEDIF(\"bad\", \"bad\", \"Y\")");
model.evaluate();
assert_eq!(model._get_text("A1"), *"#VALUE!");
}

View File

@@ -91,12 +91,12 @@ fn fn_or_xor() {
model._set("A10", &format!("={func}(X99:Z99")); model._set("A10", &format!("={func}(X99:Z99"));
// Reference to cell with reference to empty range // Reference to cell with reference to empty range
model._set("B11", "=X99:Z99"); model._set("B11", "=@X99:Z99");
model._set("A11", &format!("={func}(B11)")); model._set("A11", &format!("={func}(B11)"));
// Reference to cell with non-empty range // Reference to cell with non-empty range
model._set("X12", "1"); model._set("X12", "1");
model._set("B12", "=X12:Z12"); model._set("B12", "=@X12:Z12");
model._set("A12", &format!("={func}(B12)")); model._set("A12", &format!("={func}(B12)"));
// Reference to text cell // Reference to text cell

View File

@@ -1,520 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
// Helper constants for common time values with detailed documentation
const MIDNIGHT: &str = "0"; // 00:00:00 = 0/24 = 0
const NOON: &str = "0.5"; // 12:00:00 = 12/24 = 0.5
const TIME_14_30: &str = "0.604166667"; // 14:30:00 = 14.5/24 ≈ 0.604166667
const TIME_14_30_45: &str = "0.6046875"; // 14:30:45 = 14.5125/24 = 0.6046875
const TIME_14_30_59: &str = "0.604849537"; // 14:30:59 (from floored fractional inputs)
const TIME_23_59_59: &str = "0.999988426"; // 23:59:59 = 23.99972.../24 ≈ 0.999988426
// Excel documentation test values with explanations
const TIME_2_24_AM: &str = "0.1"; // 2:24 AM = 2.4/24 = 0.1
const TIME_2_PM: &str = "0.583333333"; // 2:00 PM = 14/24 ≈ 0.583333333
const TIME_6_45_PM: &str = "0.78125"; // 6:45 PM = 18.75/24 = 0.78125
const TIME_6_35_AM: &str = "0.274305556"; // 6:35 AM = 6.583333.../24 ≈ 0.274305556
const TIME_2_30_AM: &str = "0.104166667"; // 2:30 AM = 2.5/24 ≈ 0.104166667
const TIME_1_AM: &str = "0.041666667"; // 1:00 AM = 1/24 ≈ 0.041666667
const TIME_9_PM: &str = "0.875"; // 9:00 PM = 21/24 = 0.875
const TIME_2_AM: &str = "0.083333333"; // 2:00 AM = 2/24 ≈ 0.083333333
// Additional helper: 1-second past midnight (00:00:01)
const TIME_00_00_01: &str = "0.000011574"; // 1 second = 1/86400 ≈ 0.000011574
/// Helper function to set up and evaluate a model with time expressions
fn test_time_expressions(expressions: &[(&str, &str)]) -> crate::model::Model {
let mut model = new_empty_model();
for (cell, formula) in expressions {
model._set(cell, formula);
}
model.evaluate();
model
}
/// Helper function to test component extraction for a given time value
/// Returns (hour, minute, second) as strings
fn test_component_extraction(time_value: &str) -> (String, String, String) {
let model = test_time_expressions(&[
("A1", &format!("=HOUR({time_value})")),
("B1", &format!("=MINUTE({time_value})")),
("C1", &format!("=SECOND({time_value})")),
]);
(
model._get_text("A1").to_string(),
model._get_text("B1").to_string(),
model._get_text("C1").to_string(),
)
}
#[test]
fn test_excel_timevalue_compatibility() {
// Test cases based on Excel's official documentation and examples
let model = test_time_expressions(&[
// Excel documentation examples
("A1", "=TIMEVALUE(\"2:24 AM\")"), // Should be 0.1
("A2", "=TIMEVALUE(\"2 PM\")"), // Should be 0.583333... (14/24)
("A3", "=TIMEVALUE(\"6:45 PM\")"), // Should be 0.78125 (18.75/24)
("A4", "=TIMEVALUE(\"18:45\")"), // Same as above, 24-hour format
// Date-time format (date should be ignored)
("B1", "=TIMEVALUE(\"22-Aug-2011 6:35 AM\")"), // Should be ~0.2743
("B2", "=TIMEVALUE(\"2023-01-01 14:30:00\")"), // Should be 0.604166667
// Edge cases that Excel should support
("C1", "=TIMEVALUE(\"12:00 AM\")"), // Midnight: 0
("C2", "=TIMEVALUE(\"12:00 PM\")"), // Noon: 0.5
("C3", "=TIMEVALUE(\"11:59:59 PM\")"), // Almost midnight: 0.999988426
// Single digit variations
("D1", "=TIMEVALUE(\"1 AM\")"), // 1:00 AM
("D2", "=TIMEVALUE(\"9 PM\")"), // 9:00 PM
("D3", "=TIMEVALUE(\"12 AM\")"), // Midnight
("D4", "=TIMEVALUE(\"12 PM\")"), // Noon
]);
// Excel documentation examples - verify exact values
assert_eq!(model._get_text("A1"), *TIME_2_24_AM); // 2:24 AM
assert_eq!(model._get_text("A2"), *TIME_2_PM); // 2 PM = 14:00
assert_eq!(model._get_text("A3"), *TIME_6_45_PM); // 6:45 PM = 18:45
assert_eq!(model._get_text("A4"), *TIME_6_45_PM); // 18:45 (24-hour)
// Date-time formats (date ignored, extract time only)
assert_eq!(model._get_text("B1"), *TIME_6_35_AM); // 6:35 AM ≈ 0.2743
assert_eq!(model._get_text("B2"), *TIME_14_30); // 14:30:00
// Edge cases
assert_eq!(model._get_text("C1"), *MIDNIGHT); // 12:00 AM = 00:00
assert_eq!(model._get_text("C2"), *NOON); // 12:00 PM = 12:00
assert_eq!(model._get_text("C3"), *TIME_23_59_59); // 11:59:59 PM
// Single digit hours
assert_eq!(model._get_text("D1"), *TIME_1_AM); // 1:00 AM
assert_eq!(model._get_text("D2"), *TIME_9_PM); // 9:00 PM = 21:00
assert_eq!(model._get_text("D3"), *MIDNIGHT); // 12 AM = 00:00
assert_eq!(model._get_text("D4"), *NOON); // 12 PM = 12:00
}
#[test]
fn test_time_function_basic_cases() {
let model = test_time_expressions(&[
("A1", "=TIME(0,0,0)"), // Midnight
("A2", "=TIME(12,0,0)"), // Noon
("A3", "=TIME(14,30,0)"), // 2:30 PM
("A4", "=TIME(23,59,59)"), // Max time
]);
assert_eq!(model._get_text("A1"), *MIDNIGHT);
assert_eq!(model._get_text("A2"), *NOON);
assert_eq!(model._get_text("A3"), *TIME_14_30);
assert_eq!(model._get_text("A4"), *TIME_23_59_59);
}
#[test]
fn test_time_function_normalization() {
let model = test_time_expressions(&[
("A1", "=TIME(25,0,0)"), // Hours > 24 wrap around
("A2", "=TIME(48,0,0)"), // 48 hours = 0 (2 full days)
("A3", "=TIME(0,90,0)"), // 90 minutes = 1.5 hours
("A4", "=TIME(0,0,90)"), // 90 seconds = 1.5 minutes
("A5", "=TIME(14.9,30.9,59.9)"), // Fractional inputs floored to 14:30:59
]);
assert_eq!(model._get_text("A1"), *TIME_1_AM); // 1:00:00
assert_eq!(model._get_text("A2"), *MIDNIGHT); // 0:00:00
assert_eq!(model._get_text("A3"), *"0.0625"); // 1:30:00
assert_eq!(model._get_text("A4"), *"0.001041667"); // 0:01:30
assert_eq!(model._get_text("A5"), *TIME_14_30_59); // 14:30:59 (floored)
}
#[test]
fn test_time_function_precision_edge_cases() {
let model = test_time_expressions(&[
// High precision fractional seconds
("A1", "=TIME(14,30,45.999)"), // Fractional seconds should be floored
("A2", "=SECOND(TIME(14,30,45.999))"), // Should extract 45, not 46
// Very large normalization values
("B1", "=TIME(999,999,999)"), // Extreme normalization test
("B2", "=HOUR(999.5)"), // Multiple days, extract hour from fractional part
("B3", "=MINUTE(999.75)"), // Multiple days, extract minute
// Boundary conditions at rollover points
("C1", "=TIME(24,60,60)"), // Should normalize to next day (00:01:00)
("C2", "=HOUR(0.999999999)"), // Almost 24 hours should be 23
("C3", "=MINUTE(0.999999999)"), // Almost 24 hours, extract minutes
("C4", "=SECOND(0.999999999)"), // Almost 24 hours, extract seconds
// Precision at boundaries
("D1", "=TIME(23,59,59.999)"), // Very close to midnight
("D2", "=TIME(0,0,0.001)"), // Just after midnight
]);
// Fractional seconds are floored
assert_eq!(model._get_text("A2"), *"45"); // 45.999 floored to 45
// Multiple days should work with rem_euclid
assert_eq!(model._get_text("B2"), *"12"); // 999.5 days, hour = 12 (noon)
// Boundary normalization
assert_eq!(model._get_text("C1"), *"0.042361111"); // 24:60:60 = 01:01:00 (normalized)
assert_eq!(model._get_text("C2"), *"23"); // Almost 24 hours = 23:xx:xx
// High precision should be handled correctly
let result_d1 = model._get_text("D1").parse::<f64>().unwrap();
assert!(result_d1 < 1.0 && result_d1 > 0.999); // Very close to but less than 1.0
}
#[test]
fn test_time_function_errors() {
let model = test_time_expressions(&[
("A1", "=TIME()"), // Wrong arg count
("A2", "=TIME(12)"), // Wrong arg count
("A3", "=TIME(12,30,0,0)"), // Wrong arg count
("B1", "=TIME(-1,0,0)"), // Negative hour
("B2", "=TIME(0,-1,0)"), // Negative minute
("B3", "=TIME(0,0,-1)"), // Negative second
]);
// Wrong argument count
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
// Negative values should return #NUM! error
assert_eq!(model._get_text("B1"), *"#NUM!");
assert_eq!(model._get_text("B2"), *"#NUM!");
assert_eq!(model._get_text("B3"), *"#NUM!");
}
#[test]
fn test_timevalue_function_formats() {
let model = test_time_expressions(&[
// Basic formats
("A1", "=TIMEVALUE(\"14:30\")"),
("A2", "=TIMEVALUE(\"14:30:45\")"),
("A3", "=TIMEVALUE(\"00:00:00\")"),
// AM/PM formats
("B1", "=TIMEVALUE(\"2:30 PM\")"),
("B2", "=TIMEVALUE(\"2:30 AM\")"),
("B3", "=TIMEVALUE(\"12:00 PM\")"), // Noon
("B4", "=TIMEVALUE(\"12:00 AM\")"), // Midnight
// Single hour with AM/PM (now supported!)
("B5", "=TIMEVALUE(\"2 PM\")"),
("B6", "=TIMEVALUE(\"2 AM\")"),
// Date-time formats (extract time only)
("C1", "=TIMEVALUE(\"2023-01-01 14:30:00\")"),
("C2", "=TIMEVALUE(\"2023-01-01T14:30:00\")"),
// Whitespace handling
("D1", "=TIMEVALUE(\" 14:30 \")"),
]);
// Basic formats
assert_eq!(model._get_text("A1"), *TIME_14_30);
assert_eq!(model._get_text("A2"), *TIME_14_30_45);
assert_eq!(model._get_text("A3"), *MIDNIGHT);
// AM/PM formats
assert_eq!(model._get_text("B1"), *TIME_14_30); // 2:30 PM = 14:30
assert_eq!(model._get_text("B2"), *TIME_2_30_AM); // 2:30 AM
assert_eq!(model._get_text("B3"), *NOON); // 12:00 PM = noon
assert_eq!(model._get_text("B4"), *MIDNIGHT); // 12:00 AM = midnight
// Single hour AM/PM formats (now supported!)
assert_eq!(model._get_text("B5"), *TIME_2_PM); // 2 PM = 14:00
assert_eq!(model._get_text("B6"), *TIME_2_AM); // 2 AM = 02:00
// Date-time formats
assert_eq!(model._get_text("C1"), *TIME_14_30);
assert_eq!(model._get_text("C2"), *TIME_14_30);
// Whitespace
assert_eq!(model._get_text("D1"), *TIME_14_30);
}
#[test]
fn test_timevalue_function_errors() {
let model = test_time_expressions(&[
("A1", "=TIMEVALUE()"), // Wrong arg count
("A2", "=TIMEVALUE(\"14:30\", \"x\")"), // Wrong arg count
("B1", "=TIMEVALUE(\"invalid\")"), // Invalid format
("B2", "=TIMEVALUE(\"25:00\")"), // Invalid hour
("B3", "=TIMEVALUE(\"14:70\")"), // Invalid minute
("B4", "=TIMEVALUE(\"\")"), // Empty string
("B5", "=TIMEVALUE(\"2PM\")"), // Missing space (still unsupported)
]);
// Wrong argument count should return #ERROR!
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
// Invalid formats should return #VALUE!
assert_eq!(model._get_text("B1"), *"#VALUE!");
assert_eq!(model._get_text("B2"), *"#VALUE!");
assert_eq!(model._get_text("B3"), *"#VALUE!");
assert_eq!(model._get_text("B4"), *"#VALUE!");
assert_eq!(model._get_text("B5"), *"#VALUE!"); // "2PM" no space - not supported
}
#[test]
fn test_time_component_extraction_comprehensive() {
// Test component extraction using helper function for consistency
// Test basic time values
let test_cases = [
(MIDNIGHT, ("0", "0", "0")), // 00:00:00
(NOON, ("12", "0", "0")), // 12:00:00
(TIME_14_30, ("14", "30", "0")), // 14:30:00
(TIME_23_59_59, ("23", "59", "59")), // 23:59:59
];
for (time_value, expected) in test_cases {
let (hour, minute, second) = test_component_extraction(time_value);
assert_eq!(hour, expected.0, "Hour mismatch for {time_value}");
assert_eq!(minute, expected.1, "Minute mismatch for {time_value}");
assert_eq!(second, expected.2, "Second mismatch for {time_value}");
}
// Test multiple days (extract from fractional part)
let (hour, minute, second) = test_component_extraction("1.5"); // Day 2, 12:00
assert_eq!(
(hour, minute, second),
("12".to_string(), "0".to_string(), "0".to_string())
);
let (hour, minute, second) = test_component_extraction("100.604166667"); // Day 101, 14:30
assert_eq!(
(hour, minute, second),
("14".to_string(), "30".to_string(), "0".to_string())
);
// Test precision at boundaries
let (hour, _, _) = test_component_extraction("0.041666666"); // Just under 1:00 AM
assert_eq!(hour, "0");
let (hour, _, _) = test_component_extraction("0.041666667"); // Exactly 1:00 AM
assert_eq!(hour, "1");
let (hour, _, _) = test_component_extraction("0.041666668"); // Just over 1:00 AM
assert_eq!(hour, "1");
// Test very large day values
let (hour, minute, second) = test_component_extraction("1000000.25"); // Million days + 6 hours
assert_eq!(
(hour, minute, second),
("6".to_string(), "0".to_string(), "0".to_string())
);
}
#[test]
fn test_time_component_function_errors() {
let model = test_time_expressions(&[
// Wrong argument counts
("A1", "=HOUR()"), // No arguments
("A2", "=MINUTE()"), // No arguments
("A3", "=SECOND()"), // No arguments
("A4", "=HOUR(1, 2)"), // Too many arguments
("A5", "=MINUTE(1, 2)"), // Too many arguments
("A6", "=SECOND(1, 2)"), // Too many arguments
// Negative values should return #NUM!
("B1", "=HOUR(-0.5)"), // Negative value
("B2", "=MINUTE(-1)"), // Negative value
("B3", "=SECOND(-1)"), // Negative value
("B4", "=HOUR(-0.000001)"), // Slightly negative
("B5", "=MINUTE(-0.000001)"), // Slightly negative
("B6", "=SECOND(-0.000001)"), // Slightly negative
]);
// Wrong argument count should return #ERROR!
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("A4"), *"#ERROR!");
assert_eq!(model._get_text("A5"), *"#ERROR!");
assert_eq!(model._get_text("A6"), *"#ERROR!");
// Negative values should return #NUM!
assert_eq!(model._get_text("B1"), *"#NUM!");
assert_eq!(model._get_text("B2"), *"#NUM!");
assert_eq!(model._get_text("B3"), *"#NUM!");
assert_eq!(model._get_text("B4"), *"#NUM!");
assert_eq!(model._get_text("B5"), *"#NUM!");
assert_eq!(model._get_text("B6"), *"#NUM!");
}
#[test]
fn test_time_functions_integration() {
// Test how TIME, TIMEVALUE and component extraction functions work together
let model = test_time_expressions(&[
// Create times with both functions
("A1", "=TIME(14,30,45)"),
("A2", "=TIMEVALUE(\"14:30:45\")"),
// Extract components from TIME function results
("B1", "=HOUR(A1)"),
("B2", "=MINUTE(A1)"),
("B3", "=SECOND(A1)"),
// Extract components from TIMEVALUE function results
("C1", "=HOUR(A2)"),
("C2", "=MINUTE(A2)"),
("C3", "=SECOND(A2)"),
// Test additional TIME variations
("D1", "=TIME(14,0,0)"), // 14:00:00
("E1", "=HOUR(D1)"), // Extract hour from 14:00:00
("E2", "=MINUTE(D1)"), // Extract minute from 14:00:00
("E3", "=SECOND(D1)"), // Extract second from 14:00:00
]);
// TIME and TIMEVALUE should produce equivalent results
assert_eq!(model._get_text("A1"), model._get_text("A2"));
// Extracting components should work consistently
assert_eq!(model._get_text("B1"), *"14");
assert_eq!(model._get_text("B2"), *"30");
assert_eq!(model._get_text("B3"), *"45");
assert_eq!(model._get_text("C1"), *"14");
assert_eq!(model._get_text("C2"), *"30");
assert_eq!(model._get_text("C3"), *"45");
// Components from TIME(14,0,0)
assert_eq!(model._get_text("E1"), *"14");
assert_eq!(model._get_text("E2"), *"0");
assert_eq!(model._get_text("E3"), *"0");
}
#[test]
fn test_time_function_extreme_values() {
// Test missing edge cases: very large fractional inputs
let model = test_time_expressions(&[
// Extremely large fractional values to TIME function
("A1", "=TIME(999999.9, 999999.9, 999999.9)"), // Very large fractional inputs
("A2", "=TIME(1e6, 1e6, 1e6)"), // Scientific notation inputs
("A3", "=TIME(0.000001, 0.000001, 0.000001)"), // Very small fractional inputs
// Large day values for component extraction (stress test)
("B1", "=HOUR(999999.999)"), // Almost a million days
("B2", "=MINUTE(999999.999)"),
("B3", "=SECOND(999999.999)"),
// Edge case: exactly 1.0 (should be midnight of next day)
("C1", "=HOUR(1.0)"),
("C2", "=MINUTE(1.0)"),
("C3", "=SECOND(1.0)"),
// Very high precision values
("D1", "=HOUR(0.999999999999)"), // Almost exactly 24:00:00
("D2", "=MINUTE(0.999999999999)"),
("D3", "=SECOND(0.999999999999)"),
]);
// Large fractional inputs should be floored and normalized
let result_a1 = model._get_text("A1").parse::<f64>().unwrap();
assert!(
(0.0..1.0).contains(&result_a1),
"Result should be valid time fraction"
);
// Component extraction should work with very large values
let hour_b1 = model._get_text("B1").parse::<i32>().unwrap();
assert!((0..=23).contains(&hour_b1), "Hour should be 0-23");
// Exactly 1.0 should be midnight (start of next day)
assert_eq!(model._get_text("C1"), *"0");
assert_eq!(model._get_text("C2"), *"0");
assert_eq!(model._get_text("C3"), *"0");
// Very high precision should still extract valid components
let hour_d1 = model._get_text("D1").parse::<i32>().unwrap();
assert!((0..=23).contains(&hour_d1), "Hour should be 0-23");
}
#[test]
fn test_timevalue_malformed_but_parseable() {
// Test missing edge case: malformed but potentially parseable strings
let model = test_time_expressions(&[
// Test various malformed but potentially parseable time strings
("A1", "=TIMEVALUE(\"14:30:00.123\")"), // Milliseconds (might be truncated)
("A2", "=TIMEVALUE(\"14:30:00.999\")"), // High precision milliseconds
("A3", "=TIMEVALUE(\"02:30:00\")"), // Leading zero hours
("A4", "=TIMEVALUE(\"2:05:00\")"), // Single digit hour, zero-padded minute
// Boundary cases for AM/PM parsing
("B1", "=TIMEVALUE(\"11:59:59 PM\")"), // Just before midnight
("B2", "=TIMEVALUE(\"12:00:01 AM\")"), // Just after midnight
("B3", "=TIMEVALUE(\"12:00:01 PM\")"), // Just after noon
("B4", "=TIMEVALUE(\"11:59:59 AM\")"), // Just before noon
// Test various date-time combinations
("C1", "=TIMEVALUE(\"2023-12-31T23:59:59\")"), // ISO format at year end
("C2", "=TIMEVALUE(\"2023-01-01 00:00:01\")"), // New year, just after midnight
// Test potential edge cases that might still be parseable
("D1", "=TIMEVALUE(\"24:00:00\")"), // Should error (invalid hour)
("D2", "=TIMEVALUE(\"23:60:00\")"), // Should error (invalid minute)
("D3", "=TIMEVALUE(\"23:59:60\")"), // Should error (invalid second)
]);
// Milliseconds are not supported, should return a #VALUE! error like Excel
assert_eq!(model._get_text("A1"), *"#VALUE!");
assert_eq!(model._get_text("A2"), *"#VALUE!");
// Leading zeros should work fine
assert_eq!(model._get_text("A3"), *TIME_2_30_AM); // 02:30:00 should parse as 2:30:00
// AM/PM boundary cases should work
let result_b1 = model._get_text("B1").parse::<f64>().unwrap();
assert!(
result_b1 > 0.99 && result_b1 < 1.0,
"11:59:59 PM should be very close to 1.0"
);
let result_b2 = model._get_text("B2").parse::<f64>().unwrap();
assert!(
result_b2 > 0.0 && result_b2 < 0.01,
"12:00:01 AM should be very close to 0.0"
);
// ISO 8601 format with "T" separator should be parsed correctly
assert_eq!(model._get_text("C1"), *TIME_23_59_59); // 23:59:59 → almost midnight
assert_eq!(model._get_text("C2"), *TIME_00_00_01); // 00:00:01 → one second past midnight
// Time parser normalizes edge cases to midnight (Excel compatibility)
assert_eq!(model._get_text("D1"), *"0"); // 24:00:00 = midnight of next day
assert_eq!(model._get_text("D2"), *"0"); // 23:60:00 normalizes to 24:00:00 = midnight
assert_eq!(model._get_text("D3"), *"0"); // 23:59:60 normalizes to 24:00:00 = midnight
}
#[test]
fn test_performance_stress_with_extreme_values() {
// Test performance/stress cases with extreme values
let model = test_time_expressions(&[
// Very large numbers that should still work
("A1", "=TIME(2147483647, 0, 0)"), // Max i32 hours
("A2", "=TIME(0, 2147483647, 0)"), // Max i32 minutes
("A3", "=TIME(0, 0, 2147483647)"), // Max i32 seconds
// Component extraction with extreme day values
("B1", "=HOUR(1e15)"), // Very large day number
("B2", "=MINUTE(1e15)"),
("B3", "=SECOND(1e15)"),
// Edge of floating point precision
("C1", "=HOUR(1.7976931348623157e+308)"), // Near max f64
("C2", "=HOUR(2.2250738585072014e-308)"), // Near min positive f64
// Multiple TIME function calls with large values
("D1", "=TIME(1000000, 1000000, 1000000)"), // Large normalized values
("D2", "=HOUR(D1)"), // Extract from large TIME result
("D3", "=MINUTE(D1)"),
("D4", "=SECOND(D1)"),
]);
// All results should be valid (not errors) even with extreme inputs
for cell in ["A1", "A2", "A3", "B1", "B2", "B3", "D1", "D2", "D3", "D4"] {
let result = model._get_text(cell);
assert!(
result != *"#ERROR!" && result != *"#NUM!" && result != *"#VALUE!",
"Cell {cell} should not error with extreme values: {result}",
);
}
// Results should be mathematically valid
let hour_b1 = model._get_text("B1").parse::<i32>().unwrap();
let minute_b2 = model._get_text("B2").parse::<i32>().unwrap();
let second_b3 = model._get_text("B3").parse::<i32>().unwrap();
assert!((0..=23).contains(&hour_b1));
assert!((0..=59).contains(&minute_b2));
assert!((0..=59).contains(&second_b3));
// TIME function results should be valid time fractions
let time_d1 = model._get_text("D1").parse::<f64>().unwrap();
assert!(
(0.0..1.0).contains(&time_d1),
"TIME result should be valid fraction"
);
}

View File

@@ -3,7 +3,7 @@
use crate::test::util::new_empty_model; use crate::test::util::new_empty_model;
#[test] #[test]
fn simple_colum() { fn simple_column() {
let mut model = new_empty_model(); let mut model = new_empty_model();
// We populate cells A1 to A3 // We populate cells A1 to A3
model._set("A1", "1"); model._set("A1", "1");
@@ -30,7 +30,7 @@ fn return_of_array_is_n_impl() {
model.evaluate(); model.evaluate();
assert_eq!(model._get_text("C2"), "#N/IMPL!".to_string()); assert_eq!(model._get_text("C2"), "1".to_string());
assert_eq!(model._get_text("D2"), "1.89188842".to_string()); assert_eq!(model._get_text("D2"), "1.89188842".to_string());
} }

View File

@@ -1,347 +0,0 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
// Test data: Jan 1-10, 2023 week
const JAN_1_2023: i32 = 44927; // Sunday
const JAN_2_2023: i32 = 44928; // Monday
const JAN_6_2023: i32 = 44932; // Friday
const JAN_9_2023: i32 = 44935; // Monday
const JAN_10_2023: i32 = 44936; // Tuesday
#[test]
fn networkdays_calculates_weekdays_excluding_weekends() {
let mut model = new_empty_model();
model._set("A1", &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023})"));
model.evaluate();
assert_eq!(
model._get_text("A1"),
"7",
"Should count 7 weekdays in 10-day span"
);
}
#[test]
fn networkdays_handles_reverse_date_order() {
let mut model = new_empty_model();
model._set("A1", &format!("=NETWORKDAYS({JAN_10_2023},{JAN_1_2023})"));
model.evaluate();
assert_eq!(
model._get_text("A1"),
"-7",
"Reversed dates should return negative count"
);
}
#[test]
fn networkdays_excludes_holidays_from_weekdays() {
let mut model = new_empty_model();
model._set(
"A1",
&format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},{JAN_9_2023})"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"6",
"Should exclude Monday holiday from 7 weekdays"
);
}
#[test]
fn networkdays_handles_same_start_end_date() {
let mut model = new_empty_model();
model._set("A1", &format!("=NETWORKDAYS({JAN_9_2023},{JAN_9_2023})"));
model.evaluate();
assert_eq!(
model._get_text("A1"),
"1",
"Same weekday date should count as 1 workday"
);
}
#[test]
fn networkdays_accepts_holiday_ranges() {
let mut model = new_empty_model();
model._set("B1", &JAN_2_2023.to_string());
model._set("B2", &JAN_6_2023.to_string());
model._set(
"A1",
&format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1:B2)"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"5",
"Should exclude 2 holidays from 7 weekdays"
);
}
#[test]
fn networkdays_intl_uses_standard_weekend_by_default() {
let mut model = new_empty_model();
model._set(
"A1",
&format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023})"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"7",
"Default should be Saturday-Sunday weekend"
);
}
#[test]
fn networkdays_intl_supports_numeric_weekend_patterns() {
let mut model = new_empty_model();
// Pattern 2 = Sunday-Monday weekend
model._set(
"A1",
&format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},2)"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"6",
"Sunday-Monday weekend should give 6 workdays"
);
}
#[test]
fn networkdays_intl_supports_single_day_weekends() {
let mut model = new_empty_model();
// Pattern 11 = Sunday only weekend
model._set(
"A1",
&format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},11)"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"8",
"Sunday-only weekend should give 8 workdays"
);
}
#[test]
fn networkdays_intl_supports_string_weekend_patterns() {
let mut model = new_empty_model();
// "0000110" = Friday-Saturday weekend
model._set(
"A1",
&format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000110\")"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"8",
"Friday-Saturday weekend should give 8 workdays"
);
}
#[test]
fn networkdays_intl_no_weekends_counts_all_days() {
let mut model = new_empty_model();
model._set(
"A1",
&format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000000\")"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"10",
"No weekends should count all 10 days"
);
}
#[test]
fn networkdays_intl_combines_custom_weekends_with_holidays() {
let mut model = new_empty_model();
model._set(
"A1",
&format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000110\",{JAN_9_2023})"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"7",
"Should exclude both weekend and holiday"
);
}
#[test]
fn networkdays_validates_argument_count() {
let mut model = new_empty_model();
model._set("A1", "=NETWORKDAYS()");
model._set("A2", "=NETWORKDAYS(1,2,3,4)");
model._set("A3", "=NETWORKDAYS.INTL()");
model._set("A4", "=NETWORKDAYS.INTL(1,2,3,4,5)");
model.evaluate();
assert_eq!(model._get_text("A1"), "#ERROR!");
assert_eq!(model._get_text("A2"), "#ERROR!");
assert_eq!(model._get_text("A3"), "#ERROR!");
assert_eq!(model._get_text("A4"), "#ERROR!");
}
#[test]
fn networkdays_rejects_invalid_dates() {
let mut model = new_empty_model();
model._set("A1", "=NETWORKDAYS(-1,100)");
model._set("A2", "=NETWORKDAYS(1,3000000)");
model._set("A3", "=NETWORKDAYS(\"text\",100)");
model.evaluate();
assert_eq!(model._get_text("A1"), "#NUM!");
assert_eq!(model._get_text("A2"), "#NUM!");
assert_eq!(model._get_text("A3"), "#VALUE!");
}
#[test]
fn networkdays_intl_rejects_invalid_weekend_patterns() {
let mut model = new_empty_model();
model._set("A1", "=NETWORKDAYS.INTL(1,10,99)");
model._set("A2", "=NETWORKDAYS.INTL(1,10,\"111110\")");
model._set("A3", "=NETWORKDAYS.INTL(1,10,\"11111000\")");
model._set("A4", "=NETWORKDAYS.INTL(1,10,\"1111102\")");
model.evaluate();
assert_eq!(model._get_text("A1"), "#NUM!");
assert_eq!(model._get_text("A2"), "#VALUE!");
assert_eq!(model._get_text("A3"), "#VALUE!");
assert_eq!(model._get_text("A4"), "#VALUE!");
}
#[test]
fn networkdays_rejects_invalid_holidays() {
let mut model = new_empty_model();
model._set("B1", "invalid");
model._set(
"A1",
&format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1)"),
);
model._set(
"A2",
&format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},-1)"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"#VALUE!",
"Should reject non-numeric holidays"
);
assert_eq!(
model._get_text("A2"),
"#NUM!",
"Should reject out-of-range holidays"
);
}
#[test]
fn networkdays_handles_weekend_only_periods() {
let mut model = new_empty_model();
let saturday = JAN_1_2023 - 1;
model._set("A1", &format!("=NETWORKDAYS({saturday},{JAN_1_2023})"));
model.evaluate();
assert_eq!(
model._get_text("A1"),
"0",
"Weekend-only period should count 0 workdays"
);
}
#[test]
fn networkdays_ignores_holidays_outside_date_range() {
let mut model = new_empty_model();
let future_holiday = JAN_10_2023 + 100;
model._set(
"A1",
&format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},{future_holiday})"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"7",
"Out-of-range holidays should be ignored"
);
}
#[test]
fn networkdays_handles_empty_holiday_ranges() {
let mut model = new_empty_model();
model._set(
"A1",
&format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1:B3)"),
);
model.evaluate();
assert_eq!(
model._get_text("A1"),
"7",
"Empty holiday range should be treated as no holidays"
);
}
#[test]
fn networkdays_handles_minimum_valid_dates() {
let mut model = new_empty_model();
model._set("A1", "=NETWORKDAYS(1,7)");
model.evaluate();
assert_eq!(
model._get_text("A1"),
"5",
"Should handle earliest Excel dates correctly"
);
}
#[test]
fn networkdays_handles_large_date_ranges_efficiently() {
let mut model = new_empty_model();
model._set("A1", "=NETWORKDAYS(1,365)");
model.evaluate();
assert!(
!model._get_text("A1").starts_with('#'),
"Large ranges should not error"
);
}

View File

@@ -1,26 +0,0 @@
use crate::test::util::new_empty_model;
#[test]
fn test_weekday_return_types_11_to_17() {
let mut model = new_empty_model();
// Test date: 44561 corresponds to a Friday (2021-12-31). We verify the
// numeric result for each custom week start defined by return_type 11-17.
model._set("A1", "=WEEKDAY(44561,11)"); // Monday start
model._set("A2", "=WEEKDAY(44561,12)"); // Tuesday start
model._set("A3", "=WEEKDAY(44561,13)"); // Wednesday start
model._set("A4", "=WEEKDAY(44561,14)"); // Thursday start
model._set("A5", "=WEEKDAY(44561,15)"); // Friday start
model._set("A6", "=WEEKDAY(44561,16)"); // Saturday start
model._set("A7", "=WEEKDAY(44561,17)"); // Sunday start
model.evaluate();
assert_eq!(model._get_text("A1"), *"5"); // Mon=1 .. Sun=7 ⇒ Fri=5
assert_eq!(model._get_text("A2"), *"4"); // Tue start ⇒ Fri=4
assert_eq!(model._get_text("A3"), *"3"); // Wed start ⇒ Fri=3
assert_eq!(model._get_text("A4"), *"2"); // Thu start ⇒ Fri=2
assert_eq!(model._get_text("A5"), *"1"); // Fri start ⇒ Fri=1
assert_eq!(model._get_text("A6"), *"7"); // Sat start ⇒ Fri=7
assert_eq!(model._get_text("A7"), *"6"); // Sun start ⇒ Fri=6
}

View File

@@ -1,31 +0,0 @@
use crate::test::util::new_empty_model;
#[test]
fn test_weeknum_return_types_11_to_17_and_21() {
let mut model = new_empty_model();
// Date 44561 -> 2021-12-31 (Friday). Previously verified as week 53 (Sunday/Monday start).
// We verify that custom week-start codes 11-17 all map to week 53 and ISO variant (21) maps to 52.
let formulas = [
("A1", "=WEEKNUM(44561,11)"),
("A2", "=WEEKNUM(44561,12)"),
("A3", "=WEEKNUM(44561,13)"),
("A4", "=WEEKNUM(44561,14)"),
("A5", "=WEEKNUM(44561,15)"),
("A6", "=WEEKNUM(44561,16)"),
("A7", "=WEEKNUM(44561,17)"),
("A8", "=WEEKNUM(44561,21)"), // ISO week numbering
];
for (cell, formula) in formulas {
model._set(cell, formula);
}
model.evaluate();
// All 11-17 variations should yield 53
for cell in ["A1", "A2", "A3", "A4", "A5", "A6", "A7"] {
assert_eq!(model._get_text(cell), *"53", "{cell} should be 53");
}
// ISO week (return_type 21)
assert_eq!(model._get_text("A8"), *"52");
}

View File

@@ -1,60 +0,0 @@
#![allow(clippy::panic)]
use crate::{cell::CellValue, test::util::new_empty_model};
#[test]
fn test_yearfrac_basis_2_actual_360() {
let mut model = new_empty_model();
// Non-leap span of exactly 360 days should result in 1.0
model._set("A1", "=YEARFRAC(44561,44921,2)");
// Leap-year span of 366 days: Jan 1 2020 → Jan 1 2021
model._set("A2", "=YEARFRAC(43831,44197,2)");
// Reverse order should yield negative value
model._set("A3", "=YEARFRAC(44921,44561,2)");
model.evaluate();
// 360/360
assert_eq!(model._get_text("A1"), *"1");
// 366/360 ≈ 1.0166666667 (tolerance 1e-10)
if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!A2") {
assert!((v - 1.016_666_666_7).abs() < 1e-10);
} else {
panic!("Expected numeric value in A2");
}
// Negative symmetric of A1
assert_eq!(model._get_text("A3"), *"-1");
}
#[test]
fn test_yearfrac_basis_3_actual_365() {
let mut model = new_empty_model();
// Non-leap span of exactly 365 days should result in 1.0
model._set("B1", "=YEARFRAC(44561,44926,3)");
// Leap-year span of 366 days
model._set("B2", "=YEARFRAC(43831,44197,3)");
// Same date should be 0
model._set("B3", "=YEARFRAC(44561,44561,3)");
model.evaluate();
// 365/365
assert_eq!(model._get_text("B1"), *"1");
// 366/365 ≈ 1.002739726 (tolerance 1e-10)
if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B2") {
assert!((v - 1.002_739_726).abs() < 1e-10);
} else {
panic!("Expected numeric value in B2");
}
// Same date
assert_eq!(model._get_text("B3"), *"0");
}

View File

@@ -6,8 +6,10 @@ mod test_border;
mod test_clear_cells; mod test_clear_cells;
mod test_column_style; mod test_column_style;
mod test_defined_names; mod test_defined_names;
mod test_delete_evaluates;
mod test_delete_row_column_formatting; mod test_delete_row_column_formatting;
mod test_diff_queue; mod test_diff_queue;
mod test_dynamic_array;
mod test_evaluation; mod test_evaluation;
mod test_general; mod test_general;
mod test_grid_lines; mod test_grid_lines;

View File

@@ -89,67 +89,3 @@ fn clear_all_empty_cell() {
model.undo().unwrap(); model.undo().unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 1), Ok("".to_string())); assert_eq!(model.get_formatted_cell_value(0, 1, 1), Ok("".to_string()));
} }
#[test]
fn issue_454() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model
.set_user_input(
0,
1,
1,
"Le presbytère n'a rien perdu de son charme, ni le jardin de son éclat.",
)
.unwrap();
model.set_user_input(0, 1, 2, "=ISTEXT(A1)").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("TRUE".to_string())
);
model
.range_clear_contents(&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
})
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("FALSE".to_string())
);
model.undo().unwrap();
}
#[test]
fn issue_454b() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model
.set_user_input(
0,
1,
1,
"Le presbytère n'a rien perdu de son charme, ni le jardin de son éclat.",
)
.unwrap();
model.set_user_input(0, 1, 2, "=ISTEXT(A1)").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("TRUE".to_string())
);
model
.range_clear_all(&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
})
.unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("FALSE".to_string())
);
model.undo().unwrap();
}

View File

@@ -0,0 +1,47 @@
#![allow(clippy::unwrap_used)]
use crate::{expressions::types::Area, UserModel};
#[test]
fn clear_cell_contents_evaluates() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 1, 2, "=A1").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("42".to_string())
);
model
.range_clear_contents(&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
})
.unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("0".to_string()));
}
#[test]
fn clear_cell_all_evaluates() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 1, 2, "=A1").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("42".to_string())
);
model
.range_clear_all(&Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
})
.unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("0".to_string()));
}

View File

@@ -0,0 +1,130 @@
#![allow(clippy::unwrap_used)]
use crate::{expressions::types::Area, UserModel};
// Tests basic behavour.
#[test]
fn basic() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// We put a value by the dynamic array to check the border conditions
model.set_user_input(0, 2, 1, "22").unwrap();
model.set_user_input(0, 1, 1, "={34,35,3}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 1),
Ok("34".to_string())
);
}
// Test that overwriting a dynamic array with a single value dissolves the array
#[test]
fn sett_user_input_mother() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "={34,35,3}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("35".to_string())
);
model.set_user_input(0, 1, 1, "123").unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("".to_string()));
}
#[test]
fn set_user_input_sibling() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "={43,55,34}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("55".to_string())
);
// This does nothing
model.set_user_input(0, 1, 2, "123").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("55".to_string())
);
}
#[test]
fn basic_undo_redo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "={34,35,3}").unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("35".to_string())
);
model.undo().unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2), Ok("".to_string()));
model.redo().unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 1, 2),
Ok("35".to_string())
);
}
#[test]
fn mixed_spills() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// D9 => ={1,2,3}
model.set_user_input(0, 9, 4, "={34,35,3}").unwrap();
// F6 => ={1;2;3;4}
model.set_user_input(0, 6, 6, "={1;2;3;4}").unwrap();
// F6 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 6, 6),
Ok("#SPILL!".to_string())
);
// We delete D9
model
.range_clear_contents(&Area {
sheet: 0,
row: 9,
column: 4,
width: 1,
height: 1,
})
.unwrap();
// F6 should be 1
assert_eq!(model.get_formatted_cell_value(0, 6, 6), Ok("1".to_string()));
// Now we undo that
model.undo().unwrap();
// F6 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 6, 6),
Ok("#SPILL!".to_string())
);
}
#[test]
fn spill_order_d9_f6() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// D9 => ={1,2,3}
model.set_user_input(0, 9, 4, "={34,35,3}").unwrap();
// F6 => ={1;2;3;4}
model.set_user_input(0, 6, 6, "={1;2;3;4}").unwrap();
// F6 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 6, 6),
Ok("#SPILL!".to_string())
);
}
#[test]
fn spill_order_f6_d9() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
// F6 => ={1;2;3;4}
model.set_user_input(0, 6, 6, "={1;2;3;4}").unwrap();
// D9 => ={1,2,3}
model.set_user_input(0, 9, 4, "={34,35,3}").unwrap();
// D9 should be #SPILL!
assert_eq!(
model.get_formatted_cell_value(0, 9, 4),
Ok("#SPILL!".to_string())
);
}

View File

@@ -51,6 +51,8 @@ pub struct Workbook {
pub metadata: Metadata, pub metadata: Metadata,
pub tables: HashMap<String, Table>, pub tables: HashMap<String, Table>,
pub views: HashMap<u32, WorkbookView>, pub views: HashMap<u32, WorkbookView>,
/// The list of cells that spill in the order of evaluation
pub spill_cells: Vec<(u32, i32, i32)>,
} }
/// A defined name. The `sheet_id` is the sheet index in case the name is local /// A defined name. The `sheet_id` is the sheet index in case the name is local
@@ -159,17 +161,17 @@ pub enum CellType {
CompoundData = 128, CompoundData = 128,
} }
/// Cell types
/// s is always the style index of the cell
#[derive(Encode, Decode, Debug, Clone, PartialEq)] #[derive(Encode, Decode, Debug, Clone, PartialEq)]
pub enum Cell { pub enum Cell {
EmptyCell { EmptyCell {
s: i32, s: i32,
}, },
BooleanCell { BooleanCell {
v: bool, v: bool,
s: i32, s: i32,
}, },
NumberCell { NumberCell {
v: f64, v: f64,
s: i32, s: i32,
@@ -181,6 +183,7 @@ pub enum Cell {
}, },
// Always a shared string // Always a shared string
SharedString { SharedString {
// string index
si: i32, si: i32,
s: i32, s: i32,
}, },
@@ -189,13 +192,11 @@ pub enum Cell {
f: i32, f: i32,
s: i32, s: i32,
}, },
CellFormulaBoolean { CellFormulaBoolean {
f: i32, f: i32,
v: bool, v: bool,
s: i32, s: i32,
}, },
CellFormulaNumber { CellFormulaNumber {
f: i32, f: i32,
v: f64, v: f64,
@@ -207,9 +208,9 @@ pub enum Cell {
v: String, v: String,
s: i32, s: i32,
}, },
CellFormulaError { CellFormulaError {
f: i32, f: i32,
// error index
ei: Error, ei: Error,
s: i32, s: i32,
// Origin: Sheet3!C4 // Origin: Sheet3!C4
@@ -217,7 +218,81 @@ pub enum Cell {
// Error Message: "Not implemented function" // Error Message: "Not implemented function"
m: String, m: String,
}, },
// TODO: Array formulas // All Spill/dynamic cells have a boolean, a for array, if true it is an array formula
// Spill cells point to a mother cell (row, column)
SpillNumberCell {
v: f64,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
SpillBooleanCell {
v: bool,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
SpillErrorCell {
ei: Error,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
SpillStringCell {
v: String,
s: i32,
// mother cell (row, column)
m: (i32, i32),
},
// Dynamic cell formulas have a range (width, height)
DynamicCellFormula {
f: i32,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaBoolean {
f: i32,
v: bool,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaNumber {
f: i32,
v: f64,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaString {
f: i32,
v: String,
s: i32,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
DynamicCellFormulaError {
f: i32,
ei: Error,
s: i32,
// Cell origin of the error
o: String,
// Error message in text
m: String,
// range of the formula (width, height)
r: (i32, i32),
// true if the formula is a CSE formula
a: bool,
},
} }
impl Default for Cell { impl Default for Cell {

View File

@@ -24,6 +24,18 @@ use crate::user_model::history::{
}; };
use super::border_utils::is_max_border; use super::border_utils::is_max_border;
#[derive(Serialize, Deserialize)]
pub enum CellArrayStructure {
// It s just a single cell
SingleCell,
// It is part o a dynamic array
// (mother_row, mother_column, width, height)
DynamicChild(i32, i32, i32, i32),
// Mother of a dynamic array (width, height)
DynamicMother(i32, i32),
}
/// Data for the clipboard /// Data for the clipboard
pub type ClipboardData = HashMap<i32, HashMap<i32, ClipboardCell>>; pub type ClipboardData = HashMap<i32, HashMap<i32, ClipboardCell>>;
@@ -1743,6 +1755,65 @@ impl UserModel {
Ok(None) Ok(None)
} }
/// Returns the geometric structure of a cell
pub fn get_cell_array_structure(
&self,
sheet: u32,
row: i32,
column: i32,
) -> Result<CellArrayStructure, String> {
let cell = self
.model
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned()
.unwrap_or_default();
match cell {
Cell::EmptyCell { .. }
| Cell::BooleanCell { .. }
| Cell::NumberCell { .. }
| Cell::ErrorCell { .. }
| Cell::SharedString { .. }
| Cell::CellFormula { .. }
| Cell::CellFormulaBoolean { .. }
| Cell::CellFormulaNumber { .. }
| Cell::CellFormulaString { .. }
| Cell::CellFormulaError { .. } => Ok(CellArrayStructure::SingleCell),
Cell::SpillNumberCell { m, .. }
| Cell::SpillBooleanCell { m, .. }
| Cell::SpillErrorCell { m, .. }
| Cell::SpillStringCell { m, .. } => {
let (m_row, m_column) = m;
let m_cell = self
.model
.workbook
.worksheet(sheet)?
.cell(m_row, m_column)
.cloned()
.unwrap_or_default();
let (width, height) = match m_cell {
Cell::DynamicCellFormula { r, .. }
| Cell::DynamicCellFormulaBoolean { r, .. }
| Cell::DynamicCellFormulaNumber { r, .. }
| Cell::DynamicCellFormulaString { r, .. }
| Cell::DynamicCellFormulaError { r, .. } => (r.0, r.1),
_ => return Err("Invalid structure".to_string()),
};
Ok(CellArrayStructure::DynamicChild(
m_row, m_column, width, height,
))
}
Cell::DynamicCellFormula { r, .. }
| Cell::DynamicCellFormulaBoolean { r, .. }
| Cell::DynamicCellFormulaNumber { r, .. }
| Cell::DynamicCellFormulaString { r, .. }
| Cell::DynamicCellFormulaError { r, .. } => {
Ok(CellArrayStructure::DynamicMother(r.0, r.1))
}
}
}
/// Returns a copy of the selected area /// Returns a copy of the selected area
pub fn copy_to_clipboard(&self) -> Result<Clipboard, String> { pub fn copy_to_clipboard(&self) -> Result<Clipboard, String> {
let selected_area = self.get_selected_view(); let selected_area = self.get_selected_view();
@@ -2045,6 +2116,24 @@ impl UserModel {
old_value, old_value,
} => { } => {
needs_evaluation = true; needs_evaluation = true;
let cell = self
.model
.workbook
.worksheet(*sheet)?
.cell(*row, *column)
.cloned()
.unwrap_or_default();
if let Some((width, height)) = cell.get_dynamic_range() {
for r in *row..*row + height {
for c in *column..*column + width {
// skip the "mother" cell
if r == *row && c == *column {
continue;
}
self.model.cell_clear_contents(*sheet, r, c)?;
}
}
}
match *old_value.clone() { match *old_value.clone() {
Some(value) => { Some(value) => {
self.model self.model

View File

@@ -5,18 +5,21 @@ use bitcode::{Decode, Encode};
use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet}; use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet};
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]
#[cfg_attr(debug_assertions, derive(Debug))]
pub(crate) struct RowData { pub(crate) struct RowData {
pub(crate) row: Option<Row>, pub(crate) row: Option<Row>,
pub(crate) data: HashMap<i32, Cell>, pub(crate) data: HashMap<i32, Cell>,
} }
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]
#[cfg_attr(debug_assertions, derive(Debug))]
pub(crate) struct ColumnData { pub(crate) struct ColumnData {
pub(crate) column: Option<Col>, pub(crate) column: Option<Col>,
pub(crate) data: HashMap<i32, Cell>, pub(crate) data: HashMap<i32, Cell>,
} }
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]
#[cfg_attr(debug_assertions, derive(Debug))]
pub(crate) enum Diff { pub(crate) enum Diff {
// Cell diffs // Cell diffs
SetCellValue { SetCellValue {

View File

@@ -1,7 +1,7 @@
[package] [package]
edition = "2021" edition = "2021"
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.6.0" version = "0.5.0"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib"]
@@ -10,7 +10,7 @@ crate-type = ["cdylib"]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.12.2", default-features = false, features = ["napi4", "serde-json"] } napi = { version = "2.12.2", default-features = false, features = ["napi4", "serde-json"] }
napi-derive = "2.12.2" napi-derive = "2.12.2"
ironcalc = { path = "../../xlsx", version = "0.6.0" } ironcalc = { path = "../../xlsx", version = "0.5.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
[build-dependencies] [build-dependencies]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "pyroncalc" name = "pyroncalc"
version = "0.6.0" version = "0.5.7"
edition = "2021" edition = "2021"
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.6.0" } xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.5.0" }
pyo3 = { version = "0.25", features = ["extension-module"] } pyo3 = { version = "0.25", features = ["extension-module"] }
bitcode = "0.6.3" bitcode = "0.6.3"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "ironcalc" name = "ironcalc"
version = "0.6.0" version = "0.5.7"
description = "Create, edit and evaluate Excel spreadsheets" description = "Create, edit and evaluate Excel spreadsheets"
requires-python = ">=3.10" requires-python = ">=3.10"
keywords = [ keywords = [

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "wasm" name = "wasm"
version = "0.6.0" version = "0.5.0"
authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"]
description = "IronCalc Web bindings" description = "IronCalc Web bindings"
license = "MIT/Apache-2.0" license = "MIT/Apache-2.0"
@@ -14,7 +14,7 @@ crate-type = ["cdylib"]
# Uses `../ironcalc/base` when used locally, and uses # Uses `../ironcalc/base` when used locally, and uses
# the inicated version from crates.io when published. # the inicated version from crates.io when published.
# https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations
ironcalc_base = { path = "../../base", version = "0.6", features = ["use_regex_lite"] } ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = "0.2.100" wasm-bindgen = "0.2.100"
serde-wasm-bindgen = "0.4" serde-wasm-bindgen = "0.4"

View File

@@ -766,4 +766,21 @@ impl Model {
.get_first_non_empty_in_row_after_column(sheet, row, column) .get_first_non_empty_in_row_after_column(sheet, row, column)
.map_err(to_js_error) .map_err(to_js_error)
} }
#[wasm_bindgen(
js_name = "getCellArrayStructure",
unchecked_return_type = "CellArrayStructure"
)]
pub fn get_cell_array_structure(
&self,
sheet: u32,
row: i32,
column: i32,
) -> Result<JsValue, JsError> {
let cell_structure = self
.model
.get_cell_array_structure(sheet, row, column)
.map_err(|e| to_js_error(e.to_string()))?;
serde_wasm_bindgen::to_value(&cell_structure).map_err(JsError::from)
}
} }

View File

@@ -109,6 +109,11 @@ export interface MarkedToken {
end: number; end: number;
} }
export type CellArrayStructure =
| "SingleCell"
| { DynamicChild: [number, number, number, number] }
| { DynamicMother: [number, number] };
export interface WorksheetProperties { export interface WorksheetProperties {
name: string; name: string;
color: string; color: string;
@@ -216,7 +221,7 @@ export interface SelectedView {
// }; // };
// type ClipboardData = Record<string, Record <string, ClipboardCell>>; // type ClipboardData = Record<string, Record <string, ClipboardCell>>;
type ClipboardData = Map<number, Map <number, ClipboardCell>>; type ClipboardData = Map<number, Map<number, ClipboardCell>>;
export interface ClipboardCell { export interface ClipboardCell {
text: string; text: string;

View File

@@ -1,12 +0,0 @@
services:
server:
build:
context: .
target: server-runtime
caddy:
build:
context: .
target: caddy-runtime
ports:
- "2080:2080"

View File

@@ -12,27 +12,27 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
| Function | Status | Documentation | | Function | Status | Documentation |
| ---------------- | ---------------------------------------------- | ------------- | | ---------------- | ---------------------------------------------- | ------------- |
| DATE | <Badge type="tip" text="Available" /> | | | DATE | <Badge type="tip" text="Available" /> | |
| DATEDIF | <Badge type="tip" text="Available" /> | [DATEDIF](date_and_time/datedif) | | DATEDIF | <Badge type="info" text="Not implemented yet" /> | |
| DATEVALUE | <Badge type="tip" text="Available" /> | [DATEVALUE](date_and_time/datevalue) | | DATEVALUE | <Badge type="info" text="Not implemented yet" /> | |
| DAY | <Badge type="tip" text="Available" /> | [DAY](date_and_time/day) | | DAY | <Badge type="tip" text="Available" /> | [DAY](date_and_time/day) |
| DAYS | <Badge type="tip" text="Available" /> | | | DAYS | <Badge type="info" text="Not implemented yet" /> | |
| DAYS360 | <Badge type="tip" text="Available" /> | | | DAYS360 | <Badge type="info" text="Not implemented yet" /> | |
| EDATE | <Badge type="tip" text="Available" /> | | | EDATE | <Badge type="tip" text="Available" /> | |
| EOMONTH | <Badge type="tip" text="Available" /> | | | EOMONTH | <Badge type="tip" text="Available" /> | |
| HOUR | <Badge type="tip" text="Available" /> | [HOUR](date_and_time/hour) | | HOUR | <Badge type="info" text="Not implemented yet" /> | |
| ISOWEEKNUM | <Badge type="tip" text="Available" /> | | | ISOWEEKNUM | <Badge type="info" text="Not implemented yet" /> | |
| MINUTE | <Badge type="tip" text="Available" /> | [MINUTE](date_and_time/minute) | | MINUTE | <Badge type="info" text="Not implemented yet" /> | |
| MONTH | <Badge type="tip" text="Available" /> | [MONTH](date_and_time/month) | | MONTH | <Badge type="tip" text="Available" /> | [MONTH](date_and_time/month) |
| NETWORKDAYS | <Badge type="tip" text="Available" /> | [NETWORKDAYS](date_and_time/networkdays) | | NETWORKDAYS | <Badge type="info" text="Not implemented yet" /> | |
| NETWORKDAYS.INTL | <Badge type="tip" text="Available" /> | [NETWORKDAYS.INTL](date_and_time/networkdays.intl) | | NETWORKDAYS.INTL | <Badge type="info" text="Not implemented yet" /> | |
| NOW | <Badge type="tip" text="Available" /> | | | NOW | <Badge type="tip" text="Available" /> | |
| SECOND | <Badge type="tip" text="Available" /> | [SECOND](date_and_time/second) | | SECOND | <Badge type="info" text="Not implemented yet" /> | |
| TIME | <Badge type="tip" text="Available" /> | [TIME](date_and_time/time) | | TIME | <Badge type="info" text="Not implemented yet" /> | |
| TIMEVALUE | <Badge type="tip" text="Available" /> | [TIMEVALUE](date_and_time/timevalue) | | TIMEVALUE | <Badge type="info" text="Not implemented yet" /> | |
| TODAY | <Badge type="tip" text="Available" /> | | | TODAY | <Badge type="tip" text="Available" /> | |
| WEEKDAY | <Badge type="tip" text="Available" /> | | | WEEKDAY | <Badge type="info" text="Not implemented yet" /> | |
| WEEKNUM | <Badge type="tip" text="Available" /> | | | WEEKNUM | <Badge type="info" text="Not implemented yet" /> | |
| WORKDAY | <Badge type="tip" text="Available" /> | | | WORKDAY | <Badge type="info" text="Not implemented yet" /> | |
| WORKDAY.INTL | <Badge type="tip" text="Available" /> | | | WORKDAY.INTL | <Badge type="info" text="Not implemented yet" /> | |
| YEAR | <Badge type="tip" text="Available" /> | [YEAR](date_and_time/year) | | YEAR | <Badge type="tip" text="Available" /> | [YEAR](date_and_time/year) |
| YEARFRAC | <Badge type="tip" text="Available" /> | | | YEARFRAC | <Badge type="info" text="Not implemented yet" /> | |

View File

@@ -7,5 +7,6 @@ lang: en-US
# DATEDIF # DATEDIF
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# DATEVALUE # DATEVALUE
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# DAYS # DAYS
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# DAYS360 # DAYS360
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,6 @@ lang: en-US
# HOUR # HOUR
::: warning ::: warning
**Note:** This draft page is under construction 🚧 🚧 This function is not yet available in IronCalc.
The HOUR function is implemented and available in IronCalc. [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# ISOWEEKNUM # ISOWEEKNUM
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,6 @@ lang: en-US
# MINUTE # MINUTE
::: warning ::: warning
**Note:** This draft page is under construction 🚧 🚧 This function is not yet available in IronCalc.
The MINUTE function is implemented and available in IronCalc. [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -4,73 +4,9 @@ outline: deep
lang: en-US lang: en-US
--- ---
# NETWORKDAYS.INTL function # NETWORKDAYS.INTL
::: warning ::: warning
**Note:** This draft page is under construction 🚧 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::
## Overview
NETWORKDAYS.INTL is a function of the Date and Time category that calculates the number of working days between two dates, with customizable weekend definitions and optionally specified holidays.
## Usage
### Syntax
**NETWORKDAYS.INTL(<span title="Number" style="color:#1E88E5">start_date</span>, <span title="Number" style="color:#1E88E5">end_date</span>, [<span title="Number or String" style="color:#4CAF50">weekend</span>], [<span title="Array" style="color:#E91E63">holidays</span>]) => <span title="Number" style="color:#1E88E5">workdays</span>**
### Argument descriptions
* *start_date* ([number](/features/value-types#numbers), required). The start date expressed as a [serial number](/features/serial-numbers.md).
* *end_date* ([number](/features/value-types#numbers), required). The end date expressed as a [serial number](/features/serial-numbers.md).
* *weekend* ([number](/features/value-types#numbers) or [string](/features/value-types#strings), optional). Defines which days are considered weekends. Default is 1 (Saturday-Sunday).
* *holidays* ([array](/features/value-types#arrays) or [range](/features/ranges), optional). A list of dates to exclude from the calculation, expressed as serial numbers.
### Weekend parameter options
The _weekend_ parameter can be specified in two ways:
**Numeric codes:**
- 1 (default): Saturday and Sunday
- 2: Sunday and Monday
- 3: Monday and Tuesday
- 4: Tuesday and Wednesday
- 5: Wednesday and Thursday
- 6: Thursday and Friday
- 7: Friday and Saturday
- 11: Sunday only
- 12: Monday only
- 13: Tuesday only
- 14: Wednesday only
- 15: Thursday only
- 16: Friday only
- 17: Saturday only
**String pattern:** A 7-character string of "0" and "1" where "1" indicates a weekend day. The string represents Monday through Sunday. For example, "0000011" means Saturday and Sunday are weekends.
### Additional guidance
- If the supplied _start_date_ and _end_date_ arguments have fractional parts, NETWORKDAYS.INTL uses their [floor values](https://en.wikipedia.org/wiki/Floor_and_ceiling_functions).
- If _start_date_ is later than _end_date_, the function returns a negative number.
- Empty cells in the _holidays_ array are ignored.
- The calculation includes both the start and end dates if they are workdays.
### Returned value
NETWORKDAYS.INTL returns a [number](/features/value-types#numbers) representing the count of working days between the two dates.
### Error conditions
* In common with many other IronCalc functions, NETWORKDAYS.INTL propagates errors that are found in its arguments.
* If fewer than 2 or more than 4 arguments are supplied, then NETWORKDAYS.INTL returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the *start_date* or *end_date* arguments are not (or cannot be converted to) [numbers](/features/value-types#numbers), then NETWORKDAYS.INTL returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the *start_date* or *end_date* values are outside the valid date range, then NETWORKDAYS.INTL returns the [`#NUM!`](/features/error-types.md#num) error.
* If the *weekend* parameter is an invalid numeric code or an improperly formatted string, then NETWORKDAYS.INTL returns the [`#NUM!`](/features/error-types.md#num) or [`#VALUE!`](/features/error-types.md#value) error.
* If the *holidays* array contains non-numeric values, then NETWORKDAYS.INTL returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
## Details
IronCalc utilizes Rust's [chrono](https://docs.rs/chrono/latest/chrono/) crate to implement the NETWORKDAYS.INTL function. This function provides more flexibility than NETWORKDAYS by allowing custom weekend definitions.
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=networkdays-intl).
## Links
* See also IronCalc's [NETWORKDAYS](/functions/date_and_time/networkdays.md) function for the basic version with fixed weekends.
* Visit Microsoft Excel's [NETWORKDAYS.INTL function](https://support.microsoft.com/en-us/office/networkdays-intl-function-a9b26239-4f20-46a1-9ab8-4e925bfd5e28) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093019) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/NETWORKDAYS.INTL) provide versions of the NETWORKDAYS.INTL function.

View File

@@ -4,51 +4,9 @@ outline: deep
lang: en-US lang: en-US
--- ---
# NETWORKDAYS function # NETWORKDAYS
::: warning ::: warning
**Note:** This draft page is under construction 🚧 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::
## Overview
NETWORKDAYS is a function of the Date and Time category that calculates the number of working days between two dates, excluding weekends (Saturday and Sunday by default) and optionally specified holidays.
## Usage
### Syntax
**NETWORKDAYS(<span title="Number" style="color:#1E88E5">start_date</span>, <span title="Number" style="color:#1E88E5">end_date</span>, [<span title="Array" style="color:#E91E63">holidays</span>]) => <span title="Number" style="color:#1E88E5">workdays</span>**
### Argument descriptions
* *start_date* ([number](/features/value-types#numbers), required). The start date expressed as a [serial number](/features/serial-numbers.md).
* *end_date* ([number](/features/value-types#numbers), required). The end date expressed as a [serial number](/features/serial-numbers.md).
* *holidays* ([array](/features/value-types#arrays) or [range](/features/ranges), optional). A list of dates to exclude from the calculation, expressed as serial numbers.
### Additional guidance
- If the supplied _start_date_ and _end_date_ arguments have fractional parts, NETWORKDAYS uses their [floor values](https://en.wikipedia.org/wiki/Floor_and_ceiling_functions).
- If _start_date_ is later than _end_date_, the function returns a negative number.
- Weekend days are Saturday and Sunday by default. Use [NETWORKDAYS.INTL](networkdays.intl) for custom weekend definitions.
- Empty cells in the _holidays_ array are ignored.
- The calculation includes both the start and end dates if they are workdays.
### Returned value
NETWORKDAYS returns a [number](/features/value-types#numbers) representing the count of working days between the two dates.
### Error conditions
* In common with many other IronCalc functions, NETWORKDAYS propagates errors that are found in its arguments.
* If fewer than 2 or more than 3 arguments are supplied, then NETWORKDAYS returns the [`#ERROR!`](/features/error-types.md#error) error.
* If the *start_date* or *end_date* arguments are not (or cannot be converted to) [numbers](/features/value-types#numbers), then NETWORKDAYS returns the [`#VALUE!`](/features/error-types.md#value) error.
* If the *start_date* or *end_date* values are outside the valid date range, then NETWORKDAYS returns the [`#NUM!`](/features/error-types.md#num) error.
* If the *holidays* array contains non-numeric values, then NETWORKDAYS returns the [`#VALUE!`](/features/error-types.md#value) error.
<!--@include: ../markdown-snippets/error-type-details.txt-->
## Details
IronCalc utilizes Rust's [chrono](https://docs.rs/chrono/latest/chrono/) crate to implement the NETWORKDAYS function. The function treats Saturday and Sunday as weekend days.
## Examples
[See some examples in IronCalc](https://app.ironcalc.com/?example=networkdays).
## Links
* See also IronCalc's [NETWORKDAYS.INTL](/functions/date_and_time/networkdays.intl.md) function for custom weekend definitions.
* Visit Microsoft Excel's [NETWORKDAYS function](https://support.microsoft.com/en-us/office/networkdays-function-48e717bf-a7a3-495f-969e-5005e3eb18e7) page.
* Both [Google Sheets](https://support.google.com/docs/answer/3093018) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/NETWORKDAYS) provide versions of the NETWORKDAYS function.

View File

@@ -7,6 +7,6 @@ lang: en-US
# SECOND # SECOND
::: warning ::: warning
**Note:** This draft page is under construction 🚧 🚧 This function is not yet available in IronCalc.
The SECOND function is implemented and available in IronCalc. [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,6 @@ lang: en-US
# TIME # TIME
::: warning ::: warning
**Note:** This draft page is under construction 🚧 🚧 This function is not yet available in IronCalc.
The TIME function is implemented and available in IronCalc. [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,6 @@ lang: en-US
# TIMEVALUE # TIMEVALUE
::: warning ::: warning
**Note:** This draft page is under construction 🚧 🚧 This function is not yet available in IronCalc.
The TIMEVALUE function is implemented and available in IronCalc. [Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# WEEKDAY # WEEKDAY
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# WEEKNUM # WEEKNUM
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# WORKDAY.INTL # WORKDAY.INTL
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# WORKDAY # WORKDAY
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,5 +7,6 @@ lang: en-US
# YEARFRAC # YEARFRAC
::: warning ::: warning
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). 🚧 This function is not yet available in IronCalc.
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -1,18 +0,0 @@
---
layout: doc
outline: deep
lang: en-US
---
# Managing Workbooks
All your created sheets can be accessed from the **Workbook Sidebar**. To display it, click the button in the top-left corner of the window. From there, you can:
- View all existing workbooks, ordered by date (today, last 30 days, and older)
- Create new blank workbooks
- Download the existing workbook as .xlsx
- Pin workbooks to the top of the list
- Duplicate workbooks
- Delete workbooks
This helps you keep your workbooks organized and makes sharing simple and straightforward.

View File

@@ -26,3 +26,12 @@ IronCalc makes it easy to share your files with others. Follow these steps to sh
- **Changes in the Shared Version**: - **Changes in the Shared Version**:
Any modifications made in the shared sheet (even if its an existing sheet) will **not** overwrite or affect the original sheet. Any modifications made in the shared sheet (even if its an existing sheet) will **not** overwrite or affect the original sheet.
## Managing Sheets
All your created sheets can be accessed from the **"File"** section in the top-left corner of the window. From here, you can:
- View all existing sheets.
- Delete sheets as needed.
This helps you keep your files organized and makes sharing simple and straightforward.

View File

@@ -1,9 +1,8 @@
import { Menu, MenuItem, styled } from "@mui/material"; import { Menu, MenuItem, styled } from "@mui/material";
import { Check } from "lucide-react";
import { type ComponentProps, useCallback, useRef, useState } from "react"; import { type ComponentProps, useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormatPicker from "./FormatPicker"; import FormatPicker from "./FormatPicker";
import { KNOWN_FORMATS, NumberFormats } from "./formatUtil"; import { NumberFormats } from "./formatUtil";
type FormatMenuProps = { type FormatMenuProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -27,8 +26,6 @@ const FormatMenu = (properties: FormatMenuProps) => {
[properties.onChange], [properties.onChange],
); );
const isCustomFormat = !KNOWN_FORMATS.has(properties.numFmt);
return ( return (
<> <>
<ChildrenWrapper <ChildrenWrapper
@@ -51,17 +48,11 @@ const FormatMenu = (properties: FormatMenuProps) => {
}} }}
> >
<MenuItemWrapper onClick={(): void => onSelect(NumberFormats.AUTO)}> <MenuItemWrapper onClick={(): void => onSelect(NumberFormats.AUTO)}>
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.auto")}</MenuItemText>
<CheckIcon $active={properties.numFmt === NumberFormats.AUTO} />
{t("toolbar.format_menu.auto")}
</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuDivider /> <MenuDivider />
<MenuItemWrapper onClick={(): void => onSelect(NumberFormats.NUMBER)}> <MenuItemWrapper onClick={(): void => onSelect(NumberFormats.NUMBER)}>
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.number")}</MenuItemText>
<CheckIcon $active={properties.numFmt === NumberFormats.NUMBER} />
{t("toolbar.format_menu.number")}
</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.number_example")} {t("toolbar.format_menu.number_example")}
</MenuItemExample> </MenuItemExample>
@@ -69,12 +60,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.PERCENTAGE)} onClick={(): void => onSelect(NumberFormats.PERCENTAGE)}
> >
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.percentage")}</MenuItemText>
<CheckIcon
$active={properties.numFmt === NumberFormats.PERCENTAGE}
/>
{t("toolbar.format_menu.percentage")}
</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.percentage_example")} {t("toolbar.format_menu.percentage_example")}
</MenuItemExample> </MenuItemExample>
@@ -84,12 +70,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.CURRENCY_EUR)} onClick={(): void => onSelect(NumberFormats.CURRENCY_EUR)}
> >
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.currency_eur")}</MenuItemText>
<CheckIcon
$active={properties.numFmt === NumberFormats.CURRENCY_EUR}
/>
{t("toolbar.format_menu.currency_eur")}
</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.currency_eur_example")} {t("toolbar.format_menu.currency_eur_example")}
</MenuItemExample> </MenuItemExample>
@@ -97,12 +78,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.CURRENCY_USD)} onClick={(): void => onSelect(NumberFormats.CURRENCY_USD)}
> >
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.currency_usd")}</MenuItemText>
<CheckIcon
$active={properties.numFmt === NumberFormats.CURRENCY_USD}
/>
{t("toolbar.format_menu.currency_usd")}
</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.currency_usd_example")} {t("toolbar.format_menu.currency_usd_example")}
</MenuItemExample> </MenuItemExample>
@@ -110,12 +86,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.CURRENCY_GBP)} onClick={(): void => onSelect(NumberFormats.CURRENCY_GBP)}
> >
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.currency_gbp")}</MenuItemText>
<CheckIcon
$active={properties.numFmt === NumberFormats.CURRENCY_GBP}
/>
{t("toolbar.format_menu.currency_gbp")}
</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.currency_gbp_example")} {t("toolbar.format_menu.currency_gbp_example")}
</MenuItemExample> </MenuItemExample>
@@ -125,12 +96,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.DATE_SHORT)} onClick={(): void => onSelect(NumberFormats.DATE_SHORT)}
> >
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.date_short")}</MenuItemText>
<CheckIcon
$active={properties.numFmt === NumberFormats.DATE_SHORT}
/>
{t("toolbar.format_menu.date_short")}
</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.date_short_example")} {t("toolbar.format_menu.date_short_example")}
</MenuItemExample> </MenuItemExample>
@@ -138,12 +104,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.DATE_LONG)} onClick={(): void => onSelect(NumberFormats.DATE_LONG)}
> >
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.date_long")}</MenuItemText>
<CheckIcon
$active={properties.numFmt === NumberFormats.DATE_LONG}
/>
{t("toolbar.format_menu.date_long")}
</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.date_long_example")} {t("toolbar.format_menu.date_long_example")}
</MenuItemExample> </MenuItemExample>
@@ -151,10 +112,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuDivider /> <MenuDivider />
<MenuItemWrapper onClick={(): void => setPickerOpen(true)}> <MenuItemWrapper onClick={(): void => setPickerOpen(true)}>
<MenuItemText> <MenuItemText>{t("toolbar.format_menu.custom")}</MenuItemText>
<CheckIcon $active={isCustomFormat} />
{t("toolbar.format_menu.custom")}
</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
</StyledMenu> </StyledMenu>
<FormatPicker <FormatPicker
@@ -181,7 +139,6 @@ const StyledMenu = styled(Menu)`
const MenuItemWrapper = styled(MenuItem)` const MenuItemWrapper = styled(MenuItem)`
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: 12px; font-size: 12px;
width: calc(100% - 8px); width: calc(100% - 8px);
@@ -204,18 +161,8 @@ const MenuDivider = styled("div")`
border-top: 1px solid #eeeeee; border-top: 1px solid #eeeeee;
`; `;
const CheckIcon = styled(Check)<{ $active: boolean }>`
width: 16px;
height: 16px;
color: ${(props) => (props.$active ? "currentColor" : "transparent")};
margin-right: 8px;
flex-shrink: 0;
`;
const MenuItemText = styled("div")` const MenuItemText = styled("div")`
color: #000; color: #000;
display: flex;
align-items: center;
`; `;
const MenuItemExample = styled("div")` const MenuItemExample = styled("div")`

View File

@@ -42,5 +42,3 @@ export enum NumberFormats {
PERCENTAGE = "0.00%", PERCENTAGE = "0.00%",
NUMBER = "#,##0.00", NUMBER = "#,##0.00",
} }
export const KNOWN_FORMATS = new Set<string>(Object.values(NumberFormats));

View File

@@ -13,6 +13,7 @@ import type { WorkbookState } from "../workbookState";
type FormulaBarProps = { type FormulaBarProps = {
cellAddress: string; cellAddress: string;
formulaValue: string; formulaValue: string;
isPartOfArray: boolean;
model: Model; model: Model;
workbookState: WorkbookState; workbookState: WorkbookState;
onChange: () => void; onChange: () => void;
@@ -23,6 +24,7 @@ function FormulaBar(properties: FormulaBarProps) {
const { const {
cellAddress, cellAddress,
formulaValue, formulaValue,
isPartOfArray,
model, model,
onChange, onChange,
onTextUpdated, onTextUpdated,
@@ -62,6 +64,9 @@ function FormulaBar(properties: FormulaBarProps) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
}} }}
sx={{
color: isPartOfArray ? "grey" : "black",
}}
> >
<Editor <Editor
originalText={formulaValue} originalText={formulaValue}
@@ -99,9 +104,10 @@ const FormulaSymbolButton = styled(StyledButton)`
const Divider = styled("div")` const Divider = styled("div")`
background-color: ${theme.palette.grey["300"]}; background-color: ${theme.palette.grey["300"]};
min-width: 1px; width: 1px;
height: 16px; height: 20px;
margin: 0px 16px; margin-left: 16px;
margin-right: 16px;
`; `;
const FormulaContainer = styled("div")` const FormulaContainer = styled("div")`

File diff suppressed because it is too large Load Diff

View File

@@ -362,6 +362,19 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
return model.getCellContent(sheet, row, column); return model.getCellContent(sheet, row, column);
}; };
// returns true if it is either single cell or the root cell of an array
const isRootCellOfArray = () => {
const { sheet, row, column } = model.getSelectedView();
const r = model.getCellArrayStructure(sheet, row, column);
if (r === "SingleCell") {
return false;
}
if ("DynamicMother" in r) {
return false;
}
return true;
};
const getCellStyle = useCallback(() => { const getCellStyle = useCallback(() => {
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
return model.getCellStyle(sheet, row, column); return model.getCellStyle(sheet, row, column);
@@ -705,6 +718,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}} }}
model={model} model={model}
workbookState={workbookState} workbookState={workbookState}
isPartOfArray={isRootCellOfArray()}
/> />
<Worksheet <Worksheet
model={model} model={model}

View File

@@ -14,6 +14,7 @@ import {
LAST_COLUMN, LAST_COLUMN,
LAST_ROW, LAST_ROW,
ROW_HEIGH_SCALE, ROW_HEIGH_SCALE,
cellArrayStructureColor,
outlineBackgroundColor, outlineBackgroundColor,
outlineColor, outlineColor,
} from "../WorksheetCanvas/constants"; } from "../WorksheetCanvas/constants";
@@ -59,6 +60,7 @@ const Worksheet = forwardRef(
const spacerElement = useRef<HTMLDivElement>(null); const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null); const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null); const areaOutline = useRef<HTMLDivElement>(null);
const cellArrayStructure = useRef<HTMLDivElement>(null);
const extendToOutline = useRef<HTMLDivElement>(null); const extendToOutline = useRef<HTMLDivElement>(null);
const columnResizeGuide = useRef<HTMLDivElement>(null); const columnResizeGuide = useRef<HTMLDivElement>(null);
const rowResizeGuide = useRef<HTMLDivElement>(null); const rowResizeGuide = useRef<HTMLDivElement>(null);
@@ -85,6 +87,7 @@ const Worksheet = forwardRef(
const outline = cellOutline.current; const outline = cellOutline.current;
const area = areaOutline.current; const area = areaOutline.current;
const arrayStructure = cellArrayStructure.current;
const extendTo = extendToOutline.current; const extendTo = extendToOutline.current;
const editor = editorElement.current; const editor = editorElement.current;
@@ -98,7 +101,8 @@ const Worksheet = forwardRef(
!area || !area ||
!extendTo || !extendTo ||
!scrollElement.current || !scrollElement.current ||
!editor !editor ||
!arrayStructure
) )
return; return;
// FIXME: This two need to be computed. // FIXME: This two need to be computed.
@@ -115,6 +119,7 @@ const Worksheet = forwardRef(
rowGuide: rowGuideRef, rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef, columnHeaders: columnHeadersRef,
cellOutline: outline, cellOutline: outline,
cellArrayStructure: arrayStructure,
areaOutline: area, areaOutline: area,
extendToOutline: extendTo, extendToOutline: extendTo,
editor: editor, editor: editor,
@@ -329,6 +334,7 @@ const Worksheet = forwardRef(
/> />
</EditorWrapper> </EditorWrapper>
<AreaOutline ref={areaOutline} /> <AreaOutline ref={areaOutline} />
<CellArrayStructure ref={cellArrayStructure} />
<ExtendToOutline ref={extendToOutline} /> <ExtendToOutline ref={extendToOutline} />
<ColumnResizeGuide ref={columnResizeGuide} /> <ColumnResizeGuide ref={columnResizeGuide} />
<RowResizeGuide ref={rowResizeGuide} /> <RowResizeGuide ref={rowResizeGuide} />
@@ -514,6 +520,12 @@ const AreaOutline = styled("div")`
background-color: ${outlineBackgroundColor}; background-color: ${outlineBackgroundColor};
`; `;
const CellArrayStructure = styled("div")`
position: absolute;
border: 1px solid ${cellArrayStructureColor};
border-radius: 3px;
`;
const CellOutline = styled("div")` const CellOutline = styled("div")`
position: absolute; position: absolute;
border: 2px solid ${outlineColor}; border: 2px solid ${outlineColor};

View File

@@ -13,6 +13,7 @@ export const defaultTextColor = "#2E414D";
export const outlineColor = "#F2994A"; export const outlineColor = "#F2994A";
export const outlineBackgroundColor = "#F2994A1A"; export const outlineBackgroundColor = "#F2994A1A";
export const cellArrayStructureColor = "#64BDFDA1";
export const LAST_COLUMN = 16_384; export const LAST_COLUMN = 16_384;
export const LAST_ROW = 1_048_576; export const LAST_ROW = 1_048_576;

View File

@@ -31,6 +31,7 @@ export interface CanvasSettings {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
cellOutline: HTMLDivElement; cellOutline: HTMLDivElement;
areaOutline: HTMLDivElement; areaOutline: HTMLDivElement;
cellArrayStructure: HTMLDivElement;
extendToOutline: HTMLDivElement; extendToOutline: HTMLDivElement;
columnGuide: HTMLDivElement; columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement; rowGuide: HTMLDivElement;
@@ -90,6 +91,8 @@ export default class WorksheetCanvas {
cellOutlineHandle: HTMLDivElement; cellOutlineHandle: HTMLDivElement;
cellArrayStructure: HTMLDivElement;
extendToOutline: HTMLDivElement; extendToOutline: HTMLDivElement;
workbookState: WorkbookState; workbookState: WorkbookState;
@@ -124,6 +127,7 @@ export default class WorksheetCanvas {
this.refresh = options.refresh; this.refresh = options.refresh;
this.cellOutline = options.elements.cellOutline; this.cellOutline = options.elements.cellOutline;
this.cellArrayStructure = options.elements.cellArrayStructure;
this.areaOutline = options.elements.areaOutline; this.areaOutline = options.elements.areaOutline;
this.extendToOutline = options.elements.extendToOutline; this.extendToOutline = options.elements.extendToOutline;
this.rowGuide = options.elements.rowGuide; this.rowGuide = options.elements.rowGuide;
@@ -1515,16 +1519,20 @@ export default class WorksheetCanvas {
} }
private drawCellOutline(): void { private drawCellOutline(): void {
const { cellOutline, areaOutline, cellOutlineHandle } = this; const { cellArrayStructure, cellOutline, areaOutline, cellOutlineHandle } =
this;
if (this.workbookState.getEditingCell()) { if (this.workbookState.getEditingCell()) {
cellOutline.style.visibility = "hidden"; cellOutline.style.visibility = "hidden";
cellOutlineHandle.style.visibility = "hidden"; cellOutlineHandle.style.visibility = "hidden";
areaOutline.style.visibility = "hidden"; areaOutline.style.visibility = "hidden";
cellArrayStructure.style.visibility = "hidden";
return; return;
} }
cellOutline.style.visibility = "visible"; cellOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible"; cellOutlineHandle.style.visibility = "visible";
areaOutline.style.visibility = "visible"; areaOutline.style.visibility = "visible";
// This one is hidden by default
cellArrayStructure.style.visibility = "hidden";
const [selectedSheet, selectedRow, selectedColumn] = const [selectedSheet, selectedRow, selectedColumn] =
this.model.getSelectedCell(); this.model.getSelectedCell();
@@ -1580,6 +1588,34 @@ export default class WorksheetCanvas {
[handleX, handleY] = this.getCoordinatesByCell(rowStart, columnStart); [handleX, handleY] = this.getCoordinatesByCell(rowStart, columnStart);
handleX += this.getColumnWidth(selectedSheet, columnStart); handleX += this.getColumnWidth(selectedSheet, columnStart);
handleY += this.getRowHeight(selectedSheet, rowStart); handleY += this.getRowHeight(selectedSheet, rowStart);
// we draw the array structure if needed only in this case
const arrayStructure = this.model.getCellArrayStructure(
selectedSheet,
selectedRow,
selectedColumn,
);
let array = null;
if (arrayStructure === "SingleCell") {
// nothing to see here
} else if ("DynamicMother" in arrayStructure) {
cellArrayStructure.style.visibility = "visible";
const [arrayWidth, arrayHeight] = arrayStructure.DynamicMother;
array = [selectedRow, selectedColumn, arrayWidth, arrayHeight];
} else {
cellArrayStructure.style.visibility = "visible";
array = arrayStructure.DynamicChild;
}
if (array !== null) {
const [arrayX, arrayY] = this.getCoordinatesByCell(array[0], array[1]);
const [arrayX1, arrayY1] = this.getCoordinatesByCell(
array[0] + array[3],
array[1] + array[2],
);
cellArrayStructure.style.left = `${arrayX}px`;
cellArrayStructure.style.top = `${arrayY}px`;
cellArrayStructure.style.width = `${arrayX1 - arrayX}px`;
cellArrayStructure.style.height = `${arrayY1 - arrayY}px`;
}
} else { } else {
areaOutline.style.visibility = "visible"; areaOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible"; cellOutlineHandle.style.visibility = "visible";

View File

@@ -1,3 +1,3 @@
export const TOOLBAR_HEIGHT = 40; export const TOOLBAR_HEIGHT = 48;
export const FORMULA_BAR_HEIGHT = 40; export const FORMULA_BAR_HEIGHT = 40;
export const NAVIGATION_HEIGHT = 40; export const NAVIGATION_HEIGHT = 40;

View File

@@ -18,7 +18,6 @@ import InsertRowAboveIcon from "./insert-row-above.svg?react";
import InsertRowBelow from "./insert-row-below.svg?react"; import InsertRowBelow from "./insert-row-below.svg?react";
import IronCalcIcon from "./ironcalc_icon.svg?react"; import IronCalcIcon from "./ironcalc_icon.svg?react";
import IronCalcIconWhite from "./ironcalc_icon_white.svg?react";
import IronCalcLogo from "./orange+black.svg?react"; import IronCalcLogo from "./orange+black.svg?react";
import Fx from "./fx.svg?react"; import Fx from "./fx.svg?react";
@@ -42,7 +41,6 @@ export {
InsertRowAboveIcon, InsertRowAboveIcon,
InsertRowBelow, InsertRowBelow,
IronCalcIcon, IronCalcIcon,
IronCalcIconWhite,
IronCalcLogo, IronCalcLogo,
Fx, Fx,
}; };

View File

@@ -1,7 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.8" d="M9.95898 8.08594C9.60893 8.35318 9.27389 8.64313 8.95898 8.95801C7.09126 10.8257 6.042 13.3586 6.04199 16H6.04102V7.91406C6.39142 7.64662 6.72781 7.35715 7.04297 7.04199C8.90157 5.18307 9.9492 2.6648 9.95898 0.0371094V8.08594Z" fill="white"/>
<path opacity="0.8" d="M6.04102 7.91406C4.31493 9.23162 2.19571 9.95898 0 9.95898V6.04102C1.60208 6.04102 3.13861 5.40429 4.27148 4.27148C5.40436 3.13861 6.04101 1.60213 6.04102 0L6.04102 7.91406Z" fill="white"/>
<path opacity="0.8" d="M9.95947 8.08594C11.6856 6.76838 13.8048 6.04102 16.0005 6.04102V9.95898C14.3984 9.95898 12.8619 10.5957 11.729 11.7285C10.5961 12.8614 9.95948 14.3979 9.95947 16L9.95947 8.08594Z" fill="white"/>
<path d="M9.95898 0C9.95898 2.64126 8.90957 5.17429 7.04199 7.04199C6.727 7.35698 6.39119 7.64674 6.04102 7.91406L6.04102 0H9.95898Z" fill="white"/>
<path d="M6.04102 16C6.04102 13.3587 7.09042 10.8257 8.95801 8.95801C9.273 8.64302 9.60881 8.35326 9.95898 8.08594V16H6.04102Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,5 @@
import init, { Model } from "@ironcalc/wasm"; import init, { Model } from "@ironcalc/wasm";
import IronCalc from "./IronCalc"; import IronCalc from "./IronCalc";
import { IronCalcIcon, IronCalcIconWhite, IronCalcLogo } from "./icons"; import { IronCalcIcon, IronCalcLogo } from "./icons";
export { init, Model, IronCalc, IronCalcIcon, IronCalcIconWhite, IronCalcLogo }; export { init, Model, IronCalc, IronCalcIcon, IronCalcLogo };

View File

@@ -27,15 +27,13 @@
"vertical_align_top": "Align top", "vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG", "selected_png": "Export Selected area as PNG",
"wrap_text": "Wrap text", "wrap_text": "Wrap text",
"scroll_left": "Scroll left",
"scroll_right": "Scroll right",
"format_menu": { "format_menu": {
"auto": "Auto", "auto": "Auto",
"number": "Number", "number": "Number",
"percentage": "Percentage", "percentage": "Percentage",
"currency_eur": "Euro (EUR)", "currency_eur": "Euro (EUR)",
"currency_usd": "Dollar (USD)", "currency_usd": "Dollar (USD)",
"currency_gbp": "British Pound (GBP)", "currency_gbp": "British Pound (GBD)",
"date_short": "Short date", "date_short": "Short date",
"date_long": "Long date", "date_long": "Long date",
"custom": "Custom", "custom": "Custom",

View File

@@ -1,17 +0,0 @@
:2080 {
log {
output stdout
level INFO
}
@api path /api/*
handle @api {
reverse_proxy server:8000
}
handle {
root * /srv
try_files {path} /index.html
file_server
}
}

View File

@@ -2,8 +2,6 @@ import "./App.css";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FileBar } from "./components/FileBar"; import { FileBar } from "./components/FileBar";
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
import WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog";
import { import {
get_documentation_model, get_documentation_model,
get_model, get_model,
@@ -11,10 +9,8 @@ import {
} from "./components/rpc"; } from "./components/rpc";
import { import {
createNewModel, createNewModel,
deleteModelByUuid,
deleteSelectedModel, deleteSelectedModel,
isStorageEmpty, loadModelFromStorageOrCreate,
loadSelectedModelFromStorage,
saveModelToStorage, saveModelToStorage,
saveSelectedModelInStorage, saveSelectedModelInStorage,
selectModelFromStorage, selectModelFromStorage,
@@ -22,15 +18,9 @@ import {
// From IronCalc // From IronCalc
import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook"; import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook";
import { Modal } from "@mui/material";
import TemplatesDialog from "./components/WelcomeDialog/TemplatesDialog";
function App() { function App() {
const [model, setModel] = useState<Model | null>(null); const [model, setModel] = useState<Model | null>(null);
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [localStorageId, setLocalStorageId] = useState<number>(1);
useEffect(() => { useEffect(() => {
async function start() { async function start() {
@@ -62,14 +52,8 @@ function App() {
} }
} else { } else {
// try to load from local storage // try to load from local storage
const newModel = loadSelectedModelFromStorage(); const newModel = loadModelFromStorageOrCreate();
if (!newModel) { setModel(newModel);
setShowWelcomeDialog(true);
const createdModel = new Model("template", "en", "UTC");
setModel(createdModel);
} else {
setModel(newModel);
}
} }
} }
start(); start();
@@ -92,157 +76,51 @@ function App() {
} }
}, 1000); }, 1000);
// Handlers for model changes that also update our models state
const handleNewModel = () => {
const newModel = createNewModel();
setModel(newModel);
};
const handleSetModel = (uuid: string) => {
const newModel = selectModelFromStorage(uuid);
if (newModel) {
setModel(newModel);
}
};
const handleDeleteModel = () => {
const newModel = deleteSelectedModel();
if (newModel) {
setModel(newModel);
}
};
const handleDeleteModelByUuid = (uuid: string) => {
const newModel = deleteModelByUuid(uuid);
if (newModel) {
setModel(newModel);
}
};
// We could use context for model, but the problem is that it should initialized to null. // We could use context for model, but the problem is that it should initialized to null.
// Passing the property down makes sure it is always defined. // Passing the property down makes sure it is always defined.
return ( return (
<Wrapper> <Wrapper>
<LeftDrawer <FileBar
open={isDrawerOpen} model={model}
onClose={() => setIsDrawerOpen(false)} onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
newModel={handleNewModel} const blob = await uploadFile(arrayBuffer, fileName);
setModel={handleSetModel}
onDelete={handleDeleteModelByUuid}
localStorageId={localStorageId}
/>
<MainContent isDrawerOpen={isDrawerOpen}>
{isDrawerOpen && (
<MobileOverlay onClick={() => setIsDrawerOpen(false)} />
)}
<FileBar
model={model}
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
const blob = await uploadFile(arrayBuffer, fileName);
const bytes = new Uint8Array(await blob.arrayBuffer()); const bytes = new Uint8Array(await blob.arrayBuffer());
const newModel = Model.from_bytes(bytes); const newModel = Model.from_bytes(bytes);
saveModelToStorage(newModel); saveModelToStorage(newModel);
setModel(newModel);
}}
newModel={() => {
setModel(createNewModel());
}}
setModel={(uuid: string) => {
const newModel = selectModelFromStorage(uuid);
if (newModel) {
setModel(newModel); setModel(newModel);
}} }
newModel={handleNewModel} }}
newModelFromTemplate={() => { onDelete={() => {
setTemplatesDialogOpen(true); const newModel = deleteSelectedModel();
}} if (newModel) {
setModel={handleSetModel} setModel(newModel);
onDelete={handleDeleteModel} }
isDrawerOpen={isDrawerOpen} }}
setIsDrawerOpen={setIsDrawerOpen} />
setLocalStorageId={setLocalStorageId} <IronCalc model={model} />
/>
<IronCalc model={model} />
</MainContent>
{showWelcomeDialog && (
<WelcomeDialog
onClose={() => {
if (isStorageEmpty()) {
const createdModel = createNewModel();
setModel(createdModel);
}
setShowWelcomeDialog(false);
}}
onSelectTemplate={async (templateId) => {
switch (templateId) {
case "blank": {
const createdModel = createNewModel();
setModel(createdModel);
break;
}
default: {
const model_bytes = await get_documentation_model(templateId);
const importedModel = Model.from_bytes(model_bytes);
saveModelToStorage(importedModel);
setModel(importedModel);
break;
}
}
setShowWelcomeDialog(false);
}}
/>
)}
<Modal
open={isTemplatesDialogOpen}
onClose={() => setTemplatesDialogOpen(false)}
aria-labelledby="templates-dialog-title"
aria-describedby="templates-dialog-description"
>
<TemplatesDialog
onClose={() => setTemplatesDialogOpen(false)}
onSelectTemplate={async (fileName) => {
const model_bytes = await get_documentation_model(fileName);
const importedModel = Model.from_bytes(model_bytes);
saveModelToStorage(importedModel);
setModel(importedModel);
setTemplatesDialogOpen(false);
}}
/>
</Modal>
</Wrapper> </Wrapper>
); );
} }
const Wrapper = styled("div")` const Wrapper = styled("div")`
display: flex; margin: 0px;
padding: 0px;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
overflow: hidden;
`;
const DRAWER_WIDTH = 264;
const MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE = 440;
const MainContent = styled("div")<{ isDrawerOpen: boolean }>`
margin-left: ${({ isDrawerOpen }) => (isDrawerOpen ? "0px" : `-${DRAWER_WIDTH}px`)};
width: ${({ isDrawerOpen }) => (isDrawerOpen ? `calc(100% - ${DRAWER_WIDTH}px)` : "100%")};
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
@media (max-width: ${MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE}px) {
${({ isDrawerOpen }) => isDrawerOpen && `min-width: ${MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE}px;`}
}
`;
const MobileOverlay = styled("div")`
position: absolute; position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
z-index: 1;
cursor: pointer;
@media (min-width: ${MIN_MAIN_CONTENT_WIDTH_FOR_MOBILE + 1}px) {
display: none;
}
`; `;
const Loading = styled("div")` const Loading = styled("div")`

View File

@@ -12,9 +12,19 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
const deleteButtonRef = useRef<HTMLButtonElement>(null); const deleteButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "blur(2px)";
}
if (deleteButtonRef.current) { if (deleteButtonRef.current) {
deleteButtonRef.current.focus(); deleteButtonRef.current.focus();
} }
return () => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "none";
}
};
}, []); }, []);
return ( return (

View File

@@ -1,7 +1,6 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import type { Model } from "@ironcalc/workbook"; import type { Model } from "@ironcalc/workbook";
import { IconButton, Tooltip } from "@mui/material"; import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { FileMenu } from "./FileMenu"; import { FileMenu } from "./FileMenu";
import { HelpMenu } from "./HelpMenu"; import { HelpMenu } from "./HelpMenu";
@@ -28,13 +27,9 @@ function useWindowWidth() {
export function FileBar(properties: { export function FileBar(properties: {
model: Model; model: Model;
newModel: () => void; newModel: () => void;
newModelFromTemplate: () => void;
setModel: (key: string) => void; setModel: (key: string) => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>; onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void; onDelete: () => void;
isDrawerOpen: boolean;
setIsDrawerOpen: (open: boolean) => void;
setLocalStorageId: (updater: (id: number) => number) => void;
}) { }) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const spacerRef = useRef<HTMLDivElement>(null); const spacerRef = useRef<HTMLDivElement>(null);
@@ -52,51 +47,28 @@ export function FileBar(properties: {
return ( return (
<FileBarWrapper> <FileBarWrapper>
<Tooltip <StyledDesktopLogo />
title="Toggle sidebar" <StyledIronCalcIcon />
slotProps={{ <Divider />
popper: { <FileMenu
modifiers: [ newModel={properties.newModel}
{ setModel={properties.setModel}
name: "offset", onModelUpload={properties.onModelUpload}
options: { onDownload={async () => {
offset: [0, -8], const model = properties.model;
}, const bytes = model.toBytes();
}, const fileName = model.getName();
], await downloadModel(bytes, fileName);
},
}} }}
> onDelete={properties.onDelete}
<DrawerButton />
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)} <HelpMenu />
disableRipple
>
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
</DrawerButton>
</Tooltip>
{width > 440 && (
<FileMenu
newModel={properties.newModel}
newModelFromTemplate={properties.newModelFromTemplate}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={async () => {
const model = properties.model;
const bytes = model.toBytes();
const fileName = model.getName();
await downloadModel(bytes, fileName);
}}
onDelete={properties.onDelete}
/>
)}
{width > 440 && <HelpMenu />}
<WorkbookTitleWrapper> <WorkbookTitleWrapper>
<WorkbookTitle <WorkbookTitle
name={properties.model.getName()} name={properties.model.getName()}
onNameChange={(name) => { onNameChange={(name) => {
properties.model.setName(name); properties.model.setName(name);
updateNameSelectedWorkbook(properties.model, name); updateNameSelectedWorkbook(properties.model, name);
properties.setLocalStorageId((id) => id + 1);
}} }}
maxWidth={maxTitleWidth} maxWidth={maxTitleWidth}
/> />
@@ -129,38 +101,35 @@ const Spacer = styled("div")`
flex-grow: 1; flex-grow: 1;
`; `;
// const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>` const StyledDesktopLogo = styled(IronCalcLogo)`
// cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")}; width: 120px;
const DrawerButton = styled(IconButton)` margin-left: 12px;
margin-left: 8px; @media (max-width: 769px) {
height: 32px; display: none;
width: 32px; }
padding: 8px; `;
border-radius: 4px;
svg { const StyledIronCalcIcon = styled(IronCalcIcon)`
stroke-width: 2px; width: 36px;
stroke: #757575; margin-left: 10px;
width: 16px; @media (min-width: 769px) {
height: 16px; display: none;
}
&:hover {
background-color: #f2f2f2;
}
&:active {
background-color: #e0e0e0;
} }
`; `;
const Divider = styled("div")`
margin: 0px 8px 0px 16px;
height: 12px;
border-left: 1px solid #e0e0e0;
`;
// The container must be relative positioned so we can position the title absolutely // The container must be relative positioned so we can position the title absolutely
const FileBarWrapper = styled("div")` const FileBarWrapper = styled("div")`
position: relative; position: relative;
height: 60px; height: 60px;
min-height: 60px;
width: 100%; width: 100%;
background: #fff; background: #fff;
display: flex; display: flex;
gap: 2px;
align-items: center; align-items: center;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
justify-content: space-between; justify-content: space-between;

View File

@@ -1,15 +1,13 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Menu, MenuItem, Modal } from "@mui/material"; import { Menu, MenuItem, Modal } from "@mui/material";
import { FileDown, FileUp, Plus, Table2, Trash2 } from "lucide-react"; import { Check, FileDown, FileUp, Plus, Trash2 } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import DeleteWorkbookDialog from "./DeleteWorkbookDialog"; import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
import UploadFileDialog from "./UploadFileDialog"; import UploadFileDialog from "./UploadFileDialog";
// import TemplatesDialog from "./WelcomeDialog/TemplatesDialog";
import { getModelsMetadata, getSelectedUuid } from "./storage"; import { getModelsMetadata, getSelectedUuid } from "./storage";
export function FileMenu(props: { export function FileMenu(props: {
newModel: () => void; newModel: () => void;
newModelFromTemplate: () => void;
setModel: (key: string) => void; setModel: (key: string) => void;
onDownload: () => void; onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>; onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
@@ -19,9 +17,42 @@ export function FileMenu(props: {
const [isImportMenuOpen, setImportMenuOpen] = useState(false); const [isImportMenuOpen, setImportMenuOpen] = useState(false);
const anchorElement = useRef<HTMLButtonElement>(null); const anchorElement = useRef<HTMLButtonElement>(null);
const models = getModelsMetadata(); const models = getModelsMetadata();
const uuids = Object.keys(models);
const selectedUuid = getSelectedUuid(); const selectedUuid = getSelectedUuid();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const elements = [];
for (const uuid of uuids) {
elements.push(
<MenuItemWrapper
key={uuid}
onClick={() => {
props.setModel(uuid);
setMenuOpen(false);
}}
>
<CheckIndicator>
{uuid === selectedUuid ? (
<StyledIcon>
<Check />
</StyledIcon>
) : (
""
)}
</CheckIndicator>
<MenuItemText
style={{
maxWidth: "240px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{models[uuid]}
</MenuItemText>
</MenuItemWrapper>,
);
}
return ( return (
<> <>
<FileMenuWrapper <FileMenuWrapper
@@ -58,17 +89,10 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<Plus /> <StyledIcon>
New blank workbook <Plus />
</MenuItemWrapper> </StyledIcon>
<MenuItemWrapper <MenuItemText>New</MenuItemText>
onClick={() => {
props.newModelFromTemplate();
setMenuOpen(false);
}}
>
<Table2 />
New from template
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -76,23 +100,30 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<FileUp /> <StyledIcon>
Import <FileUp />
</StyledIcon>
<MenuItemText>Import</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper onClick={props.onDownload}> <MenuItemWrapper onClick={props.onDownload}>
<FileDown /> <StyledIcon>
Download (.xlsx) <FileDown />
</StyledIcon>
<MenuItemText>Download (.xlsx)</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<DeleteButton <MenuItemWrapper
onClick={() => { onClick={() => {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<Trash2 /> <StyledIcon>
Delete workbook <Trash2 />
</DeleteButton> </StyledIcon>
<MenuItemText>Delete workbook</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
{elements}
</Menu> </Menu>
<Modal <Modal
open={isImportMenuOpen} open={isImportMenuOpen}
@@ -118,14 +149,25 @@ export function FileMenu(props: {
<DeleteWorkbookDialog <DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)} onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete} onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid].name : ""} workbookName={selectedUuid ? models[selectedUuid] : ""}
/> />
</Modal> </Modal>
</> </>
); );
} }
export const MenuDivider = styled.div` const StyledIcon = styled.div`
display: flex;
align-items: center;
svg {
width: 16px;
height: 100%;
color: #757575;
padding-right: 10px;
}
`;
const MenuDivider = styled.div`
width: 100%; width: 100%;
margin: auto; margin: auto;
margin-top: 4px; margin-top: 4px;
@@ -133,7 +175,12 @@ export const MenuDivider = styled.div`
border-top: 1px solid #eeeeee; border-top: 1px solid #eeeeee;
`; `;
export const MenuItemWrapper = styled(MenuItem)` const MenuItemText = styled.div`
color: #000;
font-size: 12px;
`;
const MenuItemWrapper = styled(MenuItem)`
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
font-size: 14px; font-size: 14px;
@@ -143,14 +190,6 @@ export const MenuItemWrapper = styled(MenuItem)`
border-radius: 4px; border-radius: 4px;
padding: 8px; padding: 8px;
height: 32px; height: 32px;
color: #000;
font-size: 12px;
gap: 8px;
svg {
width: 16px;
height: 100%;
color: #757575;
}
`; `;
const FileMenuWrapper = styled.button<{ $isActive: boolean }>` const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
@@ -163,20 +202,14 @@ const FileMenuWrapper = styled.button<{ $isActive: boolean }>`
cursor: pointer; cursor: pointer;
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")}; background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
border: none; border: none;
background: none;
&:hover { &:hover {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
`; `;
export const DeleteButton = styled(MenuItemWrapper)` const CheckIndicator = styled.span`
color: #EB5757; display: flex;
svg { justify-content: center;
color: #EB5757; min-width: 26px;
}
&:hover {
background-color: #EB57571A;
}
&:active {
background-color: #EB57571A;
}
`; `;

View File

@@ -1,8 +1,7 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Menu } from "@mui/material"; import { Menu, MenuItem } from "@mui/material";
import { BookOpen, Keyboard } from "lucide-react"; import { BookOpen, Keyboard } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { MenuItemWrapper } from "./FileMenu";
export function HelpMenu() { export function HelpMenu() {
const [isMenuOpen, setMenuOpen] = useState(false); const [isMenuOpen, setMenuOpen] = useState(false);
@@ -62,8 +61,10 @@ export function HelpMenu() {
); );
}} }}
> >
<BookOpen /> <StyledIcon>
Documentation <BookOpen />
</StyledIcon>
<MenuItemText>Documentation</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -75,8 +76,10 @@ export function HelpMenu() {
); );
}} }}
> >
<Keyboard /> <StyledIcon>
Keyboard Shortcuts <Keyboard />
</StyledIcon>
<MenuItemText>Keyboard Shortcuts</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
</Menu> </Menu>
</div> </div>
@@ -93,7 +96,37 @@ const HelpButton = styled.button<{ $isActive?: boolean }>`
cursor: pointer; cursor: pointer;
background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")}; background-color: ${(props) => (props.$isActive ? "#e6e6e6" : "transparent")};
border: none; border: none;
background: none;
&:hover { &:hover {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
`; `;
const MenuItemWrapper = styled(MenuItem)`
display: flex;
align-items: center;
justify-content: flex-start;
font-size: 14px;
width: calc(100% - 8px);
min-width: 172px;
margin: 0px 4px;
border-radius: 4px;
padding: 8px;
height: 32px;
`;
const StyledIcon = styled.div`
display: flex;
align-items: center;
svg {
width: 16px;
height: 100%;
color: #757575;
padding-right: 10px;
}
`;
const MenuItemText = styled.div`
color: #000;
font-size: 12px;
`;

View File

@@ -1,29 +0,0 @@
import styled from "@emotion/styled";
import WorkbookList from "./WorkbookList";
interface DrawerContentProps {
setModel: (key: string) => void;
onDelete: (uuid: string) => void;
}
function DrawerContent(props: DrawerContentProps) {
const { setModel, onDelete } = props;
return (
<ContentContainer>
<WorkbookList setModel={setModel} onDelete={onDelete} />
</ContentContainer>
);
}
const ContentContainer = styled("div")`
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 12px;
height: 100%;
overflow: scroll;
font-size: 12px;
`;
export default DrawerContent;

View File

@@ -1,71 +0,0 @@
import styled from "@emotion/styled";
import { BookOpen } from "lucide-react";
function DrawerFooter() {
return (
<StyledDrawerFooter>
<FooterLink
href="https://docs.ironcalc.com/"
target="_blank"
rel="noopener noreferrer"
>
<OpenBookIcon>
<BookOpen />
</OpenBookIcon>
<FooterLinkText>Documentation</FooterLinkText>
</FooterLink>
</StyledDrawerFooter>
);
}
const StyledDrawerFooter = styled("div")`
display: flex;
align-items: center;
padding: 12px;
justify-content: space-between;
max-height: 60px;
height: 60px;
border-top: 1px solid #e0e0e0;
box-sizing: border-box;
`;
const FooterLink = styled("a")`
display: flex;
gap: 8px;
justify-content: flex-start;
font-size: 14px;
width: 100%;
min-width: 172px;
border-radius: 8px;
padding: 8px 4px 8px 8px;
transition: gap 0.5s;
background-color: transparent;
color: #000;
text-decoration: none;
align-items: center;
&:hover {
background-color: #e0e0e0 !important;
}
`;
const OpenBookIcon = styled("div")`
height: 16px;
width: 16px;
svg {
height: 16px;
width: 16px;
stroke: #9e9e9e;
}
`;
const FooterLinkText = styled("div")`
color: #000;
font-size: 12px;
width: 100%;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
`;
export default DrawerFooter;

View File

@@ -1,98 +0,0 @@
import styled from "@emotion/styled";
import { IronCalcIconWhite as IronCalcIcon } from "@ironcalc/workbook";
import { IconButton, Tooltip } from "@mui/material";
import { Plus } from "lucide-react";
import { DialogHeaderLogoWrapper } from "../WelcomeDialog/WelcomeDialog";
interface DrawerHeaderProps {
onNewModel: () => void;
}
function DrawerHeader({ onNewModel }: DrawerHeaderProps) {
return (
<HeaderContainer>
<LogoWrapper>
<Logo>
<IronCalcIcon />
</Logo>
<Title>IronCalc</Title>
</LogoWrapper>
<Tooltip
title="New workbook"
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<AddButton onClick={onNewModel}>
<PlusIcon />
</AddButton>
</Tooltip>
</HeaderContainer>
);
}
const HeaderContainer = styled("div")`
display: flex;
align-items: center;
padding: 12px 8px 12px 16px;
justify-content: space-between;
max-height: 60px;
min-height: 60px;
box-sizing: border-box;
box-shadow: 0 1px 0 0 #e0e0e0;
`;
const LogoWrapper = styled("div")`
display: flex;
align-items: center;
gap: 8px;
`;
const Title = styled("h1")`
font-size: 14px;
font-weight: 600;
`;
const Logo = styled(DialogHeaderLogoWrapper)`
transform: none;
margin-bottom: 0px;
padding: 6px;
`;
const AddButton = styled(IconButton)`
margin-left: 8px;
height: 32px;
width: 32px;
padding: 8px;
border-radius: 4px;
svg {
stroke-width: 2px;
stroke: #757575;
width: 16px;
height: 16px;
}
&:hover {
background-color: #E0E0E0;
}
&:active {
background-color: #BDBDBD;
}
`;
const PlusIcon = styled(Plus)`
width: 16px;
height: 16px;
`;
export default DrawerHeader;

View File

@@ -1,53 +0,0 @@
import styled from "@emotion/styled";
import { Drawer } from "@mui/material";
import DrawerContent from "./DrawerContent";
import DrawerFooter from "./DrawerFooter";
import DrawerHeader from "./DrawerHeader";
interface LeftDrawerProps {
open: boolean;
onClose: () => void;
newModel: () => void;
setModel: (key: string) => void;
onDelete: (uuid: string) => void;
localStorageId: number;
}
function LeftDrawer({
open,
onClose,
newModel,
setModel,
onDelete,
}: LeftDrawerProps) {
return (
<DrawerWrapper
variant="persistent"
anchor="left"
open={open}
onClose={onClose}
transitionDuration={0}
>
<DrawerHeader onNewModel={newModel} />
<DrawerContent setModel={setModel} onDelete={onDelete} />
<DrawerFooter />
</DrawerWrapper>
);
}
const DrawerWrapper = styled(Drawer)`
width: 264px;
height: 100%;
flex-shrink: 0;
font-family: "Inter", sans-serif;
.MuiDrawer-paper {
width: 264px;
background-color: #f5f5f5;
overflow: hidden;
border-right: 1px solid #e0e0e0;
}
`;
export default LeftDrawer;

View File

@@ -1,426 +0,0 @@
import styled from "@emotion/styled";
import { Menu, MenuItem, Modal } from "@mui/material";
import {
Copy,
EllipsisVertical,
FileDown,
Pin,
PinOff,
Table2,
Trash2,
} from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import DeleteWorkbookDialog from "../DeleteWorkbookDialog";
import { DeleteButton, MenuDivider, MenuItemWrapper } from "../FileMenu";
import { downloadModel } from "../rpc";
import {
duplicateModel,
getModelsMetadata,
getSelectedUuid,
isWorkbookPinned,
selectModelFromStorage,
togglePinWorkbook,
} from "../storage";
interface WorkbookListProps {
setModel: (key: string) => void;
onDelete: (uuid: string) => void;
}
function WorkbookList({ setModel, onDelete }: WorkbookListProps) {
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [selectedWorkbookUuid, setSelectedWorkbookUuid] = useState<
string | null
>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [workbookToDelete, setWorkbookToDelete] = useState<string | null>(null);
const [intendedSelection, setIntendedSelection] = useState<string | null>(
null,
);
const selectedUuid = getSelectedUuid();
// Clear intended selection when selectedUuid changes from outside
useEffect(() => {
if (intendedSelection && selectedUuid === intendedSelection) {
setIntendedSelection(null);
}
}, [selectedUuid, intendedSelection]);
const handleMenuOpen = (
event: React.MouseEvent<HTMLButtonElement>,
uuid: string,
) => {
console.log("Menu open", uuid);
event.stopPropagation();
setSelectedWorkbookUuid(uuid);
setMenuAnchorEl(event.currentTarget);
setIntendedSelection(uuid);
setModel(uuid);
};
const handleMenuClose = () => {
console.log(
"Menu closing, selectedWorkbookUuid:",
selectedWorkbookUuid,
"intendedSelection:",
intendedSelection,
);
setMenuAnchorEl(null);
// If we have an intended selection, make sure it's still selected
if (intendedSelection && intendedSelection !== selectedUuid) {
console.log("Re-selecting intended workbook:", intendedSelection);
setModel(intendedSelection);
}
// Don't reset selectedWorkbookUuid here - we want to keep track of which workbook was selected
// The selectedWorkbookUuid will be used for download/delete operations
};
const handleDeleteClick = (uuid: string) => {
console.log("Delete workbook:", uuid);
setWorkbookToDelete(uuid);
setIsDeleteDialogOpen(true);
setIntendedSelection(null);
handleMenuClose();
};
const handleDeleteConfirm = () => {
if (workbookToDelete) {
onDelete(workbookToDelete);
setWorkbookToDelete(null);
}
setIsDeleteDialogOpen(false);
};
const handleDeleteCancel = () => {
setWorkbookToDelete(null);
setIsDeleteDialogOpen(false);
};
const handleDownload = async (uuid: string) => {
try {
const model = selectModelFromStorage(uuid);
if (model) {
const bytes = model.toBytes();
const fileName = model.getName();
await downloadModel(bytes, fileName);
}
} catch (error) {
console.error("Failed to download workbook:", error);
}
};
const handlePinToggle = (uuid: string) => {
togglePinWorkbook(uuid);
setIntendedSelection(null);
handleMenuClose();
};
const handleDuplicate = (uuid: string) => {
const duplicatedModel = duplicateModel(uuid);
if (duplicatedModel) {
setIntendedSelection(null);
handleMenuClose();
}
};
// Group workbooks by pinned status and creation date
const groupWorkbooks = () => {
const now = Date.now();
const millisecondsInDay = 24 * 60 * 60 * 1000;
const millisecondsIn30Days = 30 * millisecondsInDay;
const pinnedModels = [];
const modelsCreatedToday = [];
const modelsCreatedThisMonth = [];
const olderModels = [];
const modelsMetadata = getModelsMetadata();
for (const uuid in modelsMetadata) {
const createdAt = modelsMetadata[uuid].createdAt;
const age = now - createdAt;
if (modelsMetadata[uuid].pinned) {
pinnedModels.push(uuid);
} else if (age < millisecondsInDay) {
modelsCreatedToday.push(uuid);
} else if (age < millisecondsIn30Days) {
modelsCreatedThisMonth.push(uuid);
} else {
olderModels.push(uuid);
}
}
// Sort each group by creation timestamp (newest first)
const sortByNewest = (uuids: string[]) =>
uuids.sort(
(a, b) => modelsMetadata[b].createdAt - modelsMetadata[a].createdAt,
);
return {
pinnedModels: sortByNewest(pinnedModels),
modelsCreatedToday: sortByNewest(modelsCreatedToday),
modelsCreatedThisMonth: sortByNewest(modelsCreatedThisMonth),
olderModels: sortByNewest(olderModels),
};
};
const {
pinnedModels,
modelsCreatedToday,
modelsCreatedThisMonth,
olderModels,
} = groupWorkbooks();
const renderWorkbookItem = (uuid: string) => {
const isMenuOpen = menuAnchorEl !== null && selectedWorkbookUuid === uuid;
const isAnyMenuOpen = menuAnchorEl !== null;
const models = getModelsMetadata();
return (
<WorkbookListItem
key={uuid}
onClick={() => {
// Prevent clicking on list items when any menu is open
if (isAnyMenuOpen) {
return;
}
setModel(uuid);
}}
selected={uuid === selectedUuid}
disableRipple
style={{ pointerEvents: isAnyMenuOpen ? "none" : "auto" }}
>
<StorageIndicator>
<Table2 />
</StorageIndicator>
<WorkbookListText>{models[uuid].name}</WorkbookListText>
<EllipsisButton
onClick={(e) => handleMenuOpen(e, uuid)}
isOpen={isMenuOpen}
onMouseDown={(e) => e.stopPropagation()}
style={{ pointerEvents: "auto" }}
>
<EllipsisVertical />
</EllipsisButton>
</WorkbookListItem>
);
};
const renderSection = (title: string, uuids: string[]) => {
if (uuids.length === 0) return null;
return (
<SectionContainer key={title}>
<SectionTitle>
{title === "Pinned" && <Pin />}
{title}
</SectionTitle>
{uuids.map(renderWorkbookItem)}
</SectionContainer>
);
};
const models = getModelsMetadata();
return (
<>
{renderSection("Pinned", pinnedModels)}
{renderSection("Today", modelsCreatedToday)}
{renderSection("Last 30 Days", modelsCreatedThisMonth)}
{renderSection("Older", olderModels)}
<StyledMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
MenuListProps={{
dense: true,
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItemWrapper
onClick={() => {
console.log(
"Download clicked, selectedWorkbookUuid:",
selectedWorkbookUuid,
);
if (selectedWorkbookUuid) {
handleDownload(selectedWorkbookUuid);
}
setIntendedSelection(null);
handleMenuClose();
}}
disableRipple
>
<FileDown />
Download (.xlsx)
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
if (selectedWorkbookUuid) {
handlePinToggle(selectedWorkbookUuid);
}
}}
disableRipple
>
{selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid) ? (
<PinOff />
) : (
<Pin />
)}
{selectedWorkbookUuid && isWorkbookPinned(selectedWorkbookUuid)
? "Unpin"
: "Pin"}
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
if (selectedWorkbookUuid) {
handleDuplicate(selectedWorkbookUuid);
}
}}
disableRipple
>
<Copy />
Duplicate
</MenuItemWrapper>
<MenuDivider />
<DeleteButton
selected={false}
onClick={() => {
if (selectedWorkbookUuid) {
handleDeleteClick(selectedWorkbookUuid);
}
}}
disableRipple
>
<Trash2 size={16} />
Delete workbook
</DeleteButton>
</StyledMenu>
<Modal
open={isDeleteDialogOpen}
onClose={handleDeleteCancel}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DeleteWorkbookDialog
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
workbookName={workbookToDelete ? models[workbookToDelete].name : ""}
/>
</Modal>
</>
);
}
const StorageIndicator = styled("div")`
height: 16px;
width: 16px;
svg {
height: 16px;
width: 16px;
stroke: #9e9e9e;
}
`;
const EllipsisButton = styled("button")<{ isOpen: boolean }>`
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
height: 24px;
width: ${({ isOpen }) => (isOpen ? "24px" : "0px")};
border-radius: 4px;
color: #333333;
stroke-width: 2px;
background-color: ${({ isOpen }) => (isOpen ? "#E0E0E0" : "none")};
opacity: ${({ isOpen }) => (isOpen ? "1" : "0")};
&:hover {
background: #BDBDBD;
opacity: 1;
}
&:active {
background: #bdbdbd;
opacity: 1;
}
`;
const WorkbookListItem = styled(MenuItem)<{ selected: boolean }>`
display: flex;
gap: 8px;
justify-content: flex-start;
font-size: 14px;
width: 100%;
min-width: 172px;
border-radius: 8px;
padding: 8px 4px 8px 8px;
height: 32px;
min-height: 32px;
transition: gap 0.5s;
background-color: ${({ selected }) =>
selected ? "#e0e0e0 !important" : "transparent"};
&:hover {
background-color: #e0e0e0;
button {
opacity: 1;
min-width: 24px;
}
}
`;
const WorkbookListText = styled("div")`
color: #000;
font-size: 12px;
width: 100%;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledMenu = styled(Menu)`
.MuiPaper-root {
border-radius: 8px;
padding: 4px 0px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.01);
},
.MuiList-root {
padding: 0;
},
`;
const SectionContainer = styled("div")`
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 2px;
`;
const SectionTitle = styled("div")`
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
color: #9e9e9e;
margin-bottom: 8px;
padding: 0px 8px;
font-size: 12px;
svg {
width: 12px;
height: 12px;
}
`;
export default WorkbookList;

View File

@@ -5,8 +5,8 @@ export function ShareButton(properties: { onClick: () => void }) {
const { onClick } = properties; const { onClick } = properties;
return ( return (
<Wrapper onClick={onClick} onKeyDown={() => {}}> <Wrapper onClick={onClick} onKeyDown={() => {}}>
<ShareIcon /> <Share2 style={{ width: "16px", height: "16px", marginRight: "10px" }} />
<ShareText>Share</ShareText> <span>Share</span>
</Wrapper> </Wrapper>
); );
} }
@@ -23,24 +23,8 @@ const Wrapper = styled("div")`
display: flex; display: flex;
align-items: center; align-items: center;
font-family: "Inter"; font-family: "Inter";
font-size: 12px; font-size: 14px;
&:hover { &:hover {
background: #d68742; background: #d68742;
} }
`; `;
const ShareIcon = styled(Share2)`
width: 16px;
height: 16px;
margin-right: 10px;
@media (max-width: 440px) {
margin-right: 0px;
}
`;
const ShareText = styled.span`
@media (max-width: 440px) {
display: none;
}
`;

View File

@@ -1,79 +0,0 @@
import { Dialog, styled } from "@mui/material";
import { X } from "lucide-react";
import { useState } from "react";
import TemplatesList, {
Cross,
DialogContent,
DialogFooter,
DialogFooterButton,
} from "./TemplatesList";
function TemplatesDialog(properties: {
onClose: () => void;
onSelectTemplate: (templateId: string) => void;
}) {
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const handleClose = () => {
properties.onClose();
};
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
};
return (
<DialogWrapper open={true} onClose={() => {}}>
<DialogTemplateHeader>
<span style={{ flexGrow: 2, marginLeft: 12 }}>Choose a template</span>
<Cross
style={{ marginRight: 12 }}
onClick={handleClose}
title="Close Dialog"
tabIndex={0}
onKeyDown={(event) => event.key === "Enter" && properties.onClose()}
>
<X />
</Cross>
</DialogTemplateHeader>
<DialogContent>
<TemplatesList
selectedTemplate={selectedTemplate}
handleTemplateSelect={handleTemplateSelect}
/>
</DialogContent>
<DialogFooter>
<DialogFooterButton
onClick={() => properties.onSelectTemplate(selectedTemplate)}
>
Create workbook
</DialogFooterButton>
</DialogFooter>
</DialogWrapper>
);
}
export const DialogWrapper = styled(Dialog)`
font-family: Inter;
.MuiDialog-paper {
width: 440px;
border-radius: 12px;
margin: 16px;
border: 1px solid #e0e0e0;
}
.MuiBackdrop-root {
background-color: rgba(0, 0, 0, 0.4);
}
`;
const DialogTemplateHeader = styled("div")`
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
height: 44px;
font-size: 14px;
font-weight: 500;
font-family: Inter;
`;
export default TemplatesDialog;

View File

@@ -1,103 +0,0 @@
import { Dialog, styled } from "@mui/material";
import { House, TicketsPlane } from "lucide-react";
import TemplatesListItem from "./TemplatesListItem";
function TemplatesList(props: {
selectedTemplate: string;
handleTemplateSelect: (templateId: string) => void;
}) {
const { selectedTemplate, handleTemplateSelect } = props;
return (
<TemplatesListWrapper>
<TemplatesListItem
title="Mortgage calculator"
description="Estimate payments, interest, and overall cost."
icon={<House />}
iconColor="#2F80ED"
active={selectedTemplate === "mortgage_calculator"}
onClick={() => handleTemplateSelect("mortgage_calculator")}
/>
<TemplatesListItem
title="Travel expenses tracker"
description="Track trip costs and stay on budget."
icon={<TicketsPlane />}
iconColor="#EB5757"
active={selectedTemplate === "travel_expenses_tracker"}
onClick={() => handleTemplateSelect("travel_expenses_tracker")}
/>
</TemplatesListWrapper>
);
}
export const DialogWrapper = styled(Dialog)`
font-family: Inter;
.MuiDialog-paper {
width: 440px;
border-radius: 12px;
margin: 16px;
border: 1px solid #e0e0e0;
}
.MuiBackdrop-root {
background-color: rgba(0, 0, 0, 0.4);
}
`;
export const Cross = styled("div")`
&:hover {
background-color: #f5f5f5;
}
display: flex;
border-radius: 4px;
min-height: 24px;
min-width: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
`;
export const DialogContent = styled("div")`
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
max-height: 300px;
overflow: hidden;
overflow-y: auto;
`;
export const TemplatesListWrapper = styled("div")`
display: flex;
flex-direction: column;
gap: 10px;
`;
export const DialogFooter = styled("div")`
border-top: 1px solid #e0e0e0;
padding: 16px;
`;
export const DialogFooterButton = styled("button")`
background-color: #f2994a;
border: none;
color: #fff;
padding: 12px;
border-radius: 4px;
cursor: pointer;
width: 100%;
font-size: 12px;
font-family: Inter;
&:hover {
background-color: #d68742;
}
&:active {
background-color: #d68742;
}
`;
// export default TemplatesDialog;
export default TemplatesList;

View File

@@ -1,107 +0,0 @@
import { styled } from "@mui/material";
import type { ReactNode } from "react";
interface TemplatesListItemProps {
title: string;
description: string;
icon: ReactNode;
iconColor: string;
active: boolean;
onClick: () => void;
}
function TemplatesListItem({
title,
description,
icon,
iconColor,
active,
onClick,
}: TemplatesListItemProps) {
return (
<ListItemWrapper active={active} iconColor={iconColor} onClick={onClick}>
<StyledIcon iconColor={iconColor}>{icon}</StyledIcon>
<TemplatesListItemTitle>
<Title>{title}</Title>
<Subtitle>{description}</Subtitle>
</TemplatesListItemTitle>
<RadioButton active={active}>
<RadioButtonDot />
</RadioButton>
</ListItemWrapper>
);
}
const ListItemWrapper = styled("div")<{ active?: boolean; iconColor?: string }>`
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: #424242;
border: 1px solid ${(props) => (props.active ? props.iconColor || "#424242" : "rgba(224, 224, 224, 0.60)")};
background-color: #FFFFFF;
padding: 16px;
border-radius: 8px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
cursor: pointer;
outline: ${(props) => (props.active ? `4px solid ${props.iconColor || "#424242"}24` : "none")};
transition: border 0.1s ease-in-out;
user-select: none;
&:hover {
border: 1px solid ${(props) => props.iconColor};
transition: border 0.1s ease-in-out;
}
`;
const TemplatesListItemTitle = styled("div")`
display: flex;
flex-direction: column;
color: #424242;
width: 100%;
gap: 2px;
`;
const Title = styled("div")`
font-weight: 600;
color: #424242;
line-height: 16px;
`;
const Subtitle = styled("div")`
color: #757575;
`;
const StyledIcon = styled("div")<{ iconColor?: string }>`
display: flex;
align-items: center;
margin-top: -1px;
svg {
width: 18px;
height: 100%;
color: ${(props) => props.iconColor || "#424242"};
}
`;
const RadioButton = styled("div")<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 16px;
min-width: 16px;
height: 16px;
border-radius: 16px;
margin-top: -4px;
margin-right: -4px;
background-color: ${(props) => (props.active ? "#F2994A" : "#FFFFFF")};
border: ${(props) => (props.active ? "none" : "1px solid #E0E0E0")};
`;
const RadioButtonDot = styled("div")`
width: 6px;
height: 6px;
border-radius: 6px;
background-color: #FFF;
`;
export default TemplatesListItem;

View File

@@ -1,136 +0,0 @@
import { IronCalcIconWhite as IronCalcIcon } from "@ironcalc/workbook";
import { styled } from "@mui/material";
import { Table, X } from "lucide-react";
import { useState } from "react";
import TemplatesListItem from "./TemplatesListItem";
import TemplatesList, {
Cross,
DialogContent,
DialogFooter,
DialogFooterButton,
DialogWrapper,
TemplatesListWrapper,
} from "./TemplatesList";
function WelcomeDialog(properties: {
onClose: () => void;
onSelectTemplate: (templateId: string) => void;
}) {
const [selectedTemplate, setSelectedTemplate] = useState<string>("blank");
const handleClose = () => {
properties.onClose();
};
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
};
return (
<DialogWrapper open={true} onClose={() => {}}>
<DialogWelcomeHeader>
<DialogHeaderTitleWrapper>
<DialogHeaderLogoWrapper>
<IronCalcIcon />
</DialogHeaderLogoWrapper>
<DialogHeaderTitle>Welcome to IronCalc</DialogHeaderTitle>
<DialogHeaderTitleSubtitle>
Start with a blank workbook or a ready-made template.
</DialogHeaderTitleSubtitle>
</DialogHeaderTitleWrapper>
<Cross
onClick={handleClose}
title="Close Dialog"
tabIndex={0}
onKeyDown={(event) => event.key === "Enter" && properties.onClose()}
>
<X />
</Cross>
</DialogWelcomeHeader>
<DialogContent>
<ListTitle>New</ListTitle>
<TemplatesListWrapper>
<TemplatesListItem
title="Blank workbook"
description="Create from scratch or upload your own file."
icon={<Table />}
iconColor="#F2994A"
active={selectedTemplate === "blank"}
onClick={() => handleTemplateSelect("blank")}
/>
</TemplatesListWrapper>
<ListTitle>Templates</ListTitle>
<TemplatesList
selectedTemplate={selectedTemplate}
handleTemplateSelect={handleTemplateSelect}
/>
</DialogContent>
<DialogFooter>
<DialogFooterButton
onClick={() => properties.onSelectTemplate(selectedTemplate)}
>
Create workbook
</DialogFooterButton>
</DialogFooter>
</DialogWrapper>
);
}
const DialogWelcomeHeader = styled("div")`
display: flex;
flex-direction: row;
align-items: flex-start;
border-bottom: 1px solid #e0e0e0;
padding: 16px;
font-family: Inter;
`;
const DialogHeaderTitleWrapper = styled("span")`
display: flex;
flex-direction: column;
align-items: flex-start;
font-size: 14px;
font-weight: 500;
padding: 4px 0px;
gap: 4px;
width: 100%;
`;
const DialogHeaderTitle = styled("span")`
font-weight: 700;
`;
const DialogHeaderTitleSubtitle = styled("span")`
font-size: 12px;
color: #757575;
`;
export const DialogHeaderLogoWrapper = styled("div")`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
max-width: 20px;
max-height: 20px;
background-color: #f2994a;
padding: 10px;
margin-bottom: 12px;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transform: rotate(-8deg);
user-select: none;
svg {
width: 18px;
height: 18px;
}
`;
const ListTitle = styled("div")`
font-size: 12px;
font-weight: 600;
color: #424242;
`;
export default WelcomeDialog;

View File

@@ -72,10 +72,10 @@ export function WorkbookTitle(properties: {
} }
const Container = styled("div")` const Container = styled("div")`
text-align: left; text-align: center;
padding: 6px 4px; padding: 8px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 700;
font-family: Inter; font-family: Inter;
`; `;
@@ -108,7 +108,7 @@ const TitleInput = styled("input")`
background-color: #f2f2f2; background-color: #f2f2f2;
} }
&:focus { &:focus {
outline: 1px solid grey; border: 1px solid grey;
} }
font-weight: inherit; font-weight: inherit;
font-family: inherit; font-family: inherit;

View File

@@ -3,10 +3,7 @@ import { base64ToBytes, bytesToBase64 } from "./util";
const MAX_WORKBOOKS = 50; const MAX_WORKBOOKS = 50;
type ModelsMetadata = Record< type ModelsMetadata = Record<string, string>;
string,
{ name: string; createdAt: number; pinned?: boolean }
>;
export function updateNameSelectedWorkbook(model: Model, newName: string) { export function updateNameSelectedWorkbook(model: Model, newName: string) {
const uuid = localStorage.getItem("selected"); const uuid = localStorage.getItem("selected");
@@ -15,11 +12,7 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) {
if (modelsJson) { if (modelsJson) {
try { try {
const models = JSON.parse(modelsJson); const models = JSON.parse(modelsJson);
if (models[uuid]) { models[uuid] = newName;
models[uuid].name = newName;
} else {
models[uuid] = { name: newName, createdAt: Date.now() };
}
localStorage.setItem("models", JSON.stringify(models)); localStorage.setItem("models", JSON.stringify(models));
} catch (e) { } catch (e) {
console.warn("Failed saving new name"); console.warn("Failed saving new name");
@@ -35,26 +28,7 @@ export function getModelsMetadata(): ModelsMetadata {
if (!modelsJson) { if (!modelsJson) {
modelsJson = "{}"; modelsJson = "{}";
} }
const models = JSON.parse(modelsJson); return JSON.parse(modelsJson);
// Migrate old format to new format
const migratedModels: ModelsMetadata = {};
for (const [uuid, value] of Object.entries(models)) {
if (typeof value === "string") {
// Old format: just the name string
migratedModels[uuid] = { name: value, createdAt: Date.now() };
} else if (typeof value === "object" && value !== null && "name" in value) {
// New format: object with name and createdAt
migratedModels[uuid] = value as { name: string; createdAt: number };
}
}
// Save migrated data back to localStorage
if (JSON.stringify(models) !== JSON.stringify(migratedModels)) {
localStorage.setItem("models", JSON.stringify(migratedModels));
}
return migratedModels;
} }
// Pick a different name Workbook{N} where N = 1, 2, 3 // Pick a different name Workbook{N} where N = 1, 2, 3
@@ -74,19 +48,19 @@ function getNewName(existingNames: string[]): string {
export function createNewModel(): Model { export function createNewModel(): Model {
const models = getModelsMetadata(); const models = getModelsMetadata();
const name = getNewName(Object.values(models).map((m) => m.name)); const name = getNewName(Object.values(models));
const model = new Model(name, "en", "UTC"); const model = new Model(name, "en", "UTC");
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
localStorage.setItem("selected", uuid); localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(model.toBytes())); localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
models[uuid] = { name, createdAt: Date.now() }; models[uuid] = name;
localStorage.setItem("models", JSON.stringify(models)); localStorage.setItem("models", JSON.stringify(models));
return model; return model;
} }
export function loadSelectedModelFromStorage(): Model | null { export function loadModelFromStorageOrCreate(): Model {
const uuid = localStorage.getItem("selected"); const uuid = localStorage.getItem("selected");
if (uuid) { if (uuid) {
// We try to load the selected model // We try to load the selected model
@@ -94,22 +68,14 @@ export function loadSelectedModelFromStorage(): Model | null {
if (modelBytesString) { if (modelBytesString) {
return Model.from_bytes(base64ToBytes(modelBytesString)); return Model.from_bytes(base64ToBytes(modelBytesString));
} }
// If it doesn't exist we create one at that uuid
const newModel = new Model("Workbook1", "en", "UTC");
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(newModel.toBytes()));
return newModel;
} }
return null; // If there was no selected model we create a new one
} return createNewModel();
// check if storage is empty
export function isStorageEmpty(): boolean {
const modelsJson = localStorage.getItem("models");
if (!modelsJson) {
return true;
}
try {
const models = JSON.parse(modelsJson);
return Object.keys(models).length === 0;
} catch (e) {
return true;
}
} }
export function saveSelectedModelInStorage(model: Model) { export function saveSelectedModelInStorage(model: Model) {
@@ -129,7 +95,7 @@ export function saveModelToStorage(model: Model) {
modelsJson = "{}"; modelsJson = "{}";
} }
const models = JSON.parse(modelsJson); const models = JSON.parse(modelsJson);
models[uuid] = { name: model.getName(), createdAt: Date.now() }; models[uuid] = model.getName();
localStorage.setItem("models", JSON.stringify(models)); localStorage.setItem("models", JSON.stringify(models));
} }
@@ -161,79 +127,3 @@ export function deleteSelectedModel(): Model | null {
} }
return selectModelFromStorage(uuids[0]); return selectModelFromStorage(uuids[0]);
} }
export function deleteModelByUuid(uuid: string): Model | null {
localStorage.removeItem(uuid);
const metadata = getModelsMetadata();
delete metadata[uuid];
localStorage.setItem("models", JSON.stringify(metadata));
// If this was the selected model, we need to select a different one
const selectedUuid = localStorage.getItem("selected");
if (selectedUuid === uuid) {
const uuids = Object.keys(metadata);
if (uuids.length === 0) {
return createNewModel();
}
// Find the newest workbook by creation timestamp
const newestUuid = uuids.reduce((newest, current) => {
const newestTime = metadata[newest]?.createdAt || 0;
const currentTime = metadata[current]?.createdAt || 0;
return currentTime > newestTime ? current : newest;
});
return selectModelFromStorage(newestUuid);
}
// If it wasn't the selected model, return the currently selected model
if (selectedUuid) {
const modelBytesString = localStorage.getItem(selectedUuid);
if (modelBytesString) {
return Model.from_bytes(base64ToBytes(modelBytesString));
}
}
// Fallback to creating a new model if no valid selected model
return createNewModel();
}
export function togglePinWorkbook(uuid: string): void {
const metadata = getModelsMetadata();
if (metadata[uuid]) {
metadata[uuid].pinned = !metadata[uuid].pinned;
localStorage.setItem("models", JSON.stringify(metadata));
}
}
export function isWorkbookPinned(uuid: string): boolean {
const metadata = getModelsMetadata();
return metadata[uuid]?.pinned || false;
}
export function duplicateModel(uuid: string): Model | null {
const originalModel = selectModelFromStorage(uuid);
if (!originalModel) return null;
const duplicatedModel = Model.from_bytes(originalModel.toBytes());
const models = getModelsMetadata();
const originalName = models[uuid]?.name || "Workbook";
const existingNames = Object.values(models).map((m) => m.name);
// Find next available number
let counter = 1;
let newName = `${originalName} (${counter})`;
while (existingNames.includes(newName)) {
counter++;
newName = `${originalName} (${counter})`;
}
duplicatedModel.setName(newName);
const newUuid = crypto.randomUUID();
localStorage.setItem("selected", newUuid);
localStorage.setItem(newUuid, bytesToBase64(duplicatedModel.toBytes()));
models[newUuid] = { name: newName, createdAt: Date.now() };
localStorage.setItem("models", JSON.stringify(models));
return duplicatedModel;
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironcalc" name = "ironcalc"
version = "0.6.0" version = "0.5.0"
authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"]
edition = "2021" edition = "2021"
homepage = "https://www.ironcalc.com" homepage = "https://www.ironcalc.com"
@@ -20,7 +20,7 @@ thiserror = "1.0"
# Uses `../base` when used locally, and uses # Uses `../base` when used locally, and uses
# the inicated version from crates.io when published. # the inicated version from crates.io when published.
# https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations
ironcalc_base = { path = "../base", version = "0.6" } ironcalc_base = { path = "../base", version = "0.5" }
itertools = "0.12" itertools = "0.12"
chrono = "0.4" chrono = "0.4"
bitcode = "0.6.0" bitcode = "0.6.0"

View File

@@ -22,7 +22,7 @@ use itertools::Itertools;
use ironcalc_base::{ use ironcalc_base::{
expressions::{ expressions::{
parser::{stringify::to_excel_string, Node}, parser::{static_analysis::StaticResult, stringify::to_excel_string, Node},
types::CellReferenceRC, types::CellReferenceRC,
utils::number_to_column, utils::number_to_column,
}, },
@@ -56,7 +56,7 @@ fn get_formula_attribute(
pub(crate) fn get_worksheet_xml( pub(crate) fn get_worksheet_xml(
worksheet: &Worksheet, worksheet: &Worksheet,
parsed_formulas: &[Node], parsed_formulas: &[(Node, StaticResult)],
dimension: &str, dimension: &str,
is_sheet_selected: bool, is_sheet_selected: bool,
) -> String { ) -> String {
@@ -104,7 +104,7 @@ pub(crate) fn get_worksheet_xml(
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!("<c r=\"{cell_name}\"{style}/>")); row_data_str.push(format!("<c r=\"{cell_name}\"{style}/>"));
} }
Cell::BooleanCell { v, s } => { Cell::SpillBooleanCell { v, s, .. } | Cell::BooleanCell { v, s } => {
// <c r="A8" t="b" s="1"> // <c r="A8" t="b" s="1">
// <v>1</v> // <v>1</v>
// </c> // </c>
@@ -114,7 +114,7 @@ pub(crate) fn get_worksheet_xml(
"<c r=\"{cell_name}\" t=\"b\"{style}><v>{b}</v></c>" "<c r=\"{cell_name}\" t=\"b\"{style}><v>{b}</v></c>"
)); ));
} }
Cell::NumberCell { v, s } => { Cell::SpillNumberCell { v, s, .. } | Cell::NumberCell { v, s } => {
// Normally the type number is left out. Example: // Normally the type number is left out. Example:
// <c r="C6" s="1"> // <c r="C6" s="1">
// <v>3</v> // <v>3</v>
@@ -122,7 +122,7 @@ pub(crate) fn get_worksheet_xml(
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!("<c r=\"{cell_name}\"{style}><v>{v}</v></c>")); row_data_str.push(format!("<c r=\"{cell_name}\"{style}><v>{v}</v></c>"));
} }
Cell::ErrorCell { ei, s } => { Cell::SpillErrorCell { ei, s, .. } | Cell::ErrorCell { ei, s } => {
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!( row_data_str.push(format!(
"<c r=\"{cell_name}\" t=\"e\"{style}><v>{ei}</v></c>" "<c r=\"{cell_name}\" t=\"e\"{style}><v>{ei}</v></c>"
@@ -153,7 +153,7 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize], &parsed_formulas[*f as usize].0,
); );
let b = i32::from(*v); let b = i32::from(*v);
@@ -172,7 +172,7 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize], &parsed_formulas[*f as usize].0,
); );
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
@@ -189,14 +189,14 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize], &parsed_formulas[*f as usize].0,
); );
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
let escaped_v = escape_xml(v); let escaped_v = escape_xml(v);
row_data_str.push(format!( row_data_str.push(format!(
"<c r=\"{cell_name}\" t=\"str\"{style}><f>{formula}</f><v>{escaped_v}</v></c>" "<c r=\"{cell_name}\" t=\"str\"{style}><f>{formula}</f><v>{escaped_v}</v></c>"
)); ));
} }
Cell::CellFormulaError { Cell::CellFormulaError {
f, f,
@@ -213,13 +213,135 @@ pub(crate) fn get_worksheet_xml(
worksheet.get_name(), worksheet.get_name(),
*row_index, *row_index,
*column_index, *column_index,
&parsed_formulas[*f as usize], &parsed_formulas[*f as usize].0,
); );
let style = get_cell_style_attribute(*s); let style = get_cell_style_attribute(*s);
row_data_str.push(format!( row_data_str.push(format!(
"<c r=\"{cell_name}\" t=\"e\"{style}><f>{formula}</f><v>{ei}</v></c>" "<c r=\"{cell_name}\" t=\"e\"{style}><f>{formula}</f><v>{ei}</v></c>"
)); ));
} }
Cell::SpillStringCell { v, s, .. } => {
// inline string
// <c r="A1" t="str">
let style = get_cell_style_attribute(*s);
let escaped_v = escape_xml(v);
row_data_str.push(format!(
"<c r=\"{cell_name}\" t=\"str\"{style}><v>{escaped_v}</v></c>"
));
}
Cell::DynamicCellFormula { .. } => {
panic!("Model needs to be evaluated before saving!");
}
Cell::DynamicCellFormulaBoolean { f, v, s, r, a: _ } => {
// <c r="A1" s="3" cm="1">
// <f t="array" ref="A1:A10">A1:A10</f>
// <v>1</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
let b = i32::from(*v);
row_data_str.push(format!(
r#"<c r="{cell_name}" t="b" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{b}</v></c>"#
));
}
Cell::DynamicCellFormulaNumber { f, v, s, r, a: _ } => {
// <c r="C4" s="3" cm="1">
// <f t="array" ref="C4:C10">C4:C10</f>
// <v>123</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
row_data_str.push(format!(
r#"<c r="{cell_name}" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{v}</v></c>"#
));
}
Cell::DynamicCellFormulaString { f, v, s, r, a: _ } => {
// <c r="C6" t="str" s="5" cm="1">
// <f t="array" ref="C6:C10">C6:C10</f>
// <v>Hello world!</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
let escaped_v = escape_xml(v);
row_data_str.push(format!(
r#"<c r="{cell_name}" t="str" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{escaped_v}</v></c>"#
));
}
Cell::DynamicCellFormulaError {
f,
ei,
s,
o: _,
m: _,
r,
a: _,
} => {
// <c r="C6" t="e" s="4" cm="1">
// <f t="array" ref="C6:C10">C6:C10</f>
// <v>#DIV/0!</v>
// </c>
let style = get_cell_style_attribute(*s);
let range = format!(
"{}{}:{}{}",
column_name,
row_index,
number_to_column(r.0 + column_index).unwrap(),
r.1 + row_index
);
let formula = get_formula_attribute(
worksheet.get_name(),
*row_index,
*column_index,
&parsed_formulas[*f as usize].0,
);
row_data_str.push(format!(
r#"<c r="{cell_name}" t="e" s="{style}" cm="1"><f t="array" ref="{range}">{formula}</f><v>{ei}</v></c>"#
));
}
} }
} }
let row_style_str = match row_style_dict.get(row_index) { let row_style_str = match row_style_dict.get(row_index) {

View File

@@ -303,13 +303,15 @@ fn from_a1_to_rc(
context: String, context: String,
tables: HashMap<String, Table>, tables: HashMap<String, Table>,
defined_names: Vec<DefinedNameS>, defined_names: Vec<DefinedNameS>,
is_array: bool,
) -> Result<String, XlsxError> { ) -> Result<String, XlsxError> {
let mut parser = Parser::new(worksheets.to_owned(), defined_names, tables); let mut parser = Parser::new(worksheets.to_owned(), defined_names, tables);
let cell_reference = let cell_reference =
parse_reference(&context).map_err(|error| XlsxError::Xml(error.to_string()))?; parse_reference(&context).map_err(|error| XlsxError::Xml(error.to_string()))?;
let mut t = parser.parse(&formula, &cell_reference); let mut t = parser.parse(&formula, &cell_reference);
add_implicit_intersection(&mut t, true); if !is_array {
add_implicit_intersection(&mut t, true);
}
Ok(to_rc_format(&t)) Ok(to_rc_format(&t))
} }
@@ -837,6 +839,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
}; };
let cell_metadata = cell.attribute("cm"); let cell_metadata = cell.attribute("cm");
let is_dynamic_array = cell_metadata == Some("1");
// type, the default type being "n" for number // type, the default type being "n" for number
// If the cell does not have a value is an empty cell // If the cell does not have a value is an empty cell
@@ -903,6 +906,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
context, context,
tables.clone(), tables.clone(),
defined_names.clone(), defined_names.clone(),
is_dynamic_array,
)?; )?;
match index_map.get(&si) { match index_map.get(&si) {
Some(index) => { Some(index) => {
@@ -951,7 +955,6 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
return Err(XlsxError::NotImplemented("data table formulas".to_string())); return Err(XlsxError::NotImplemented("data table formulas".to_string()));
} }
"array" | "normal" => { "array" | "normal" => {
let is_dynamic_array = cell_metadata == Some("1");
if formula_type == "array" && !is_dynamic_array { if formula_type == "array" && !is_dynamic_array {
// Dynamic formulas in Excel are formulas of type array with the cm=1, those we support. // Dynamic formulas in Excel are formulas of type array with the cm=1, those we support.
// On the other hand the old CSE formulas or array formulas are not supported in IronCalc for the time being // On the other hand the old CSE formulas or array formulas are not supported in IronCalc for the time being
@@ -966,6 +969,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
context, context,
tables.clone(), tables.clone(),
defined_names.clone(), defined_names.clone(),
is_dynamic_array,
)?; )?;
match get_formula_index(&formula, &shared_formulas) { match get_formula_index(&formula, &shared_formulas) {

Binary file not shown.

Binary file not shown.