Compare commits

...

16 Commits

Author SHA1 Message Date
Nicolás Hatcher Andrés
fad8bc7d0c UPDATE: Update release to 0.6.0 (#463)
Should we have a single version number at the root?
2025-10-19 18:32:07 +02:00
Nicolás Hatcher Andrés
bbba875da3 FIX: Cleanup CSS code (#462) 2025-10-19 17:57:26 +02:00
Daniel González-Albo
1b34db0bc3 Merge pull request #455 from ironcalc/empty-fix
FIX: Evaluate after deleting content in the user API
2025-10-19 17:25:48 +02:00
Daniel González-Albo
d9aac1d77c Merge pull request #460 from ironcalc/xfln
FIX: DAYS and ISOWEEKNUM are "XLFN" functions
2025-10-19 17:24:40 +02:00
Nicolás Hatcher Andrés
d429bd8f60 FIX: Remove transition so there is no close drawer glitch (#459) 2025-10-19 17:12:49 +02:00
Nicolás Hatcher
2dbc3f4790 FIX: DAYS and ISOWEEKNUM are "XLFN" functions 2025-10-19 17:12:12 +02:00
Daniel González-Albo
292ecafb31 Merge pull request #458 from ironcalc/name-sync
FIX: sync changes of the localstorage in the left drawer
2025-10-19 16:13:38 +02:00
Daniel González-Albo
ead4bc713c docs: update the section 'managing workbooks' with new info about the left sidebar (#457) 2025-10-19 16:11:59 +02:00
Nicolás Hatcher
a9748eafec FIX: sync changes of the localstorage in the left drawer
This is a bit of a HACK. going a bit "against" React philosophy.
2025-10-19 16:05:50 +02:00
Nicolás Hatcher Andrés
330a018202 FIX: Adds test for TIME/HOUR/MINUTE/SECOND (#456) 2025-10-19 15:38:11 +02:00
Daniel González-Albo
d9812876e2 update: show which format is active in FormatMenu (#450)
* update: show which format is active in formatmenu

* update: requested fixes
2025-10-19 12:44:58 +02:00
Nicolás Hatcher
895244ed11 FIX: Evaluate after deleting content in the user API 2025-10-19 12:41:05 +02:00
Daniel González-Albo
f2da24326b update: Add a left drawer to improve workbook management (#453)
* update: add leftbar to app

* style: a few cosmetic changes

* update: allow pinning workbooks

* style: show ellipsis button only on hover

* update: add basic responsiveness

* style: use active state when file and help menus are open

* style: increase transition time

* update: allow duplication of workbooks

* chore: standardize menus
2025-10-19 10:20:31 +02:00
Brian Hung
dd4467f95d date time functions (#425)
* merge networkdays, networkdays.intl #33

* merge time, timevalue, hour, minute, second #35

* merge datedif, datevalue #36

* merge days, days360, weekday, weeknum, workday, workday.intl, yearfrac, isoweeknum #41

* from excel helper

* fix build

* date time macros

* de-dupe weekend

* serial helper

* de-dupe now today

* weekend pattern enum

* remove unused clippy wrong self

* fix docs

* add test coverage

* fix build

* fix cursor comment

* PR coments + xlsx date time
2025-10-19 10:19:19 +02:00
Nicolás Hatcher Andrés
29989b9fd7 UPDATE: Add info in README about the Dockerfile (#452)
* UPDATE: Add info in README about the Dockerfile

* Update README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-15 22:51:18 +02:00
Nicolás Hatcher
1efc921ce6 UPDATE: First Docker images! 2025-10-15 22:19:25 +02:00
65 changed files with 4661 additions and 430 deletions

10
Cargo.lock generated
View File

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

93
Dockerfile Normal file
View File

@@ -0,0 +1,93 @@
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,17 @@ 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 you own web application. We will build different _skins_: in the terminal, as a desktop application or use it in your 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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironcalc_base" name = "ironcalc_base"
version = "0.5.0" version = "0.6.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

@@ -341,7 +341,8 @@ fn static_analysis_offset(args: &[Node]) -> StaticResult {
} }
_ => return StaticResult::Unknown, _ => return StaticResult::Unknown,
}; };
StaticResult::Unknown // Both height and width are explicitly 1, so OFFSET will return a single cell
StaticResult::Scalar
} }
// fn static_analysis_choose(_args: &[Node]) -> StaticResult { // fn static_analysis_choose(_args: &[Node]) -> StaticResult {
@@ -575,6 +576,37 @@ 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
@@ -690,13 +722,28 @@ 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),
@@ -785,6 +832,8 @@ 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),
} }
} }
@@ -896,12 +945,27 @@ 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),
@@ -990,5 +1054,7 @@ 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,6 +8,8 @@ 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
@@ -37,7 +39,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("Out of range parameters for date".to_string()), None => Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()),
} }
} }
@@ -55,7 +57,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("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.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
@@ -68,7 +70,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("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string());
} }
date = { date = {
@@ -80,7 +82,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("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string());
} }
date date
}; };
@@ -94,7 +96,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("Out of range parameters for date".to_string()); return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string());
} }
date date
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -148,13 +148,30 @@ 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,
@@ -253,7 +270,7 @@ pub enum Function {
} }
impl Function { impl Function {
pub fn into_iter() -> IntoIter<Function, 198> { pub fn into_iter() -> IntoIter<Function, 215> {
[ [
Function::And, Function::And,
Function::False, Function::False,
@@ -362,9 +379,26 @@ 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,
@@ -494,6 +528,7 @@ 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(),
} }
} }
@@ -631,9 +666,26 @@ 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),
@@ -841,9 +893,26 @@ 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"),
@@ -1082,9 +1151,26 @@ 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

@@ -7,6 +7,8 @@ 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;
@@ -27,6 +29,7 @@ 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;
@@ -43,8 +46,11 @@ 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;
@@ -65,6 +71,7 @@ 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,6 +6,11 @@
/// 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();
@@ -216,3 +221,382 @@ 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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,43 @@
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,182 @@
#![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

@@ -0,0 +1,520 @@
#![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

@@ -0,0 +1,347 @@
#![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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,60 @@
#![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

@@ -89,3 +89,67 @@ 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

@@ -627,6 +627,7 @@ impl UserModel {
} }
} }
self.push_diff_list(diff_list); self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(()) Ok(())
} }
@@ -656,6 +657,7 @@ impl UserModel {
} }
} }
self.push_diff_list(diff_list); self.push_diff_list(diff_list);
self.evaluate_if_not_paused();
Ok(()) Ok(())
} }

View File

@@ -1,7 +1,7 @@
[package] [package]
edition = "2021" edition = "2021"
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.5.0" version = "0.6.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.5.0" } ironcalc = { path = "../../xlsx", version = "0.6.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.5.7" version = "0.6.0"
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.5.0" } xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.6.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.5.7" version = "0.6.0"
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.5.0" version = "0.6.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.5", features = ["use_regex_lite"] } ironcalc_base = { path = "../../base", version = "0.6", 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"

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
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="info" text="Not implemented yet" /> | | | DATEDIF | <Badge type="tip" text="Available" /> | [DATEDIF](date_and_time/datedif) |
| DATEVALUE | <Badge type="info" text="Not implemented yet" /> | | | DATEVALUE | <Badge type="tip" text="Available" /> | [DATEVALUE](date_and_time/datevalue) |
| 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="info" text="Not implemented yet" /> | | | DAYS | <Badge type="tip" text="Available" /> | |
| DAYS360 | <Badge type="info" text="Not implemented yet" /> | | | DAYS360 | <Badge type="tip" text="Available" /> | |
| 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="info" text="Not implemented yet" /> | | | HOUR | <Badge type="tip" text="Available" /> | [HOUR](date_and_time/hour) |
| ISOWEEKNUM | <Badge type="info" text="Not implemented yet" /> | | | ISOWEEKNUM | <Badge type="tip" text="Available" /> | |
| MINUTE | <Badge type="info" text="Not implemented yet" /> | | | MINUTE | <Badge type="tip" text="Available" /> | [MINUTE](date_and_time/minute) |
| 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="info" text="Not implemented yet" /> | | | NETWORKDAYS | <Badge type="tip" text="Available" /> | [NETWORKDAYS](date_and_time/networkdays) |
| NETWORKDAYS.INTL | <Badge type="info" text="Not implemented yet" /> | | | NETWORKDAYS.INTL | <Badge type="tip" text="Available" /> | [NETWORKDAYS.INTL](date_and_time/networkdays.intl) |
| NOW | <Badge type="tip" text="Available" /> | | | NOW | <Badge type="tip" text="Available" /> | |
| SECOND | <Badge type="info" text="Not implemented yet" /> | | | SECOND | <Badge type="tip" text="Available" /> | [SECOND](date_and_time/second) |
| TIME | <Badge type="info" text="Not implemented yet" /> | | | TIME | <Badge type="tip" text="Available" /> | [TIME](date_and_time/time) |
| TIMEVALUE | <Badge type="info" text="Not implemented yet" /> | | | TIMEVALUE | <Badge type="tip" text="Available" /> | [TIMEVALUE](date_and_time/timevalue) |
| TODAY | <Badge type="tip" text="Available" /> | | | TODAY | <Badge type="tip" text="Available" /> | |
| WEEKDAY | <Badge type="info" text="Not implemented yet" /> | | | WEEKDAY | <Badge type="tip" text="Available" /> | |
| WEEKNUM | <Badge type="info" text="Not implemented yet" /> | | | WEEKNUM | <Badge type="tip" text="Available" /> | |
| WORKDAY | <Badge type="info" text="Not implemented yet" /> | | | WORKDAY | <Badge type="tip" text="Available" /> | |
| WORKDAY.INTL | <Badge type="info" text="Not implemented yet" /> | | | WORKDAY.INTL | <Badge type="tip" text="Available" /> | |
| 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="info" text="Not implemented yet" /> | | | YEARFRAC | <Badge type="tip" text="Available" /> | |

View File

@@ -7,6 +7,5 @@ lang: en-US
# DATEDIF # DATEDIF
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,5 @@ lang: en-US
# DATEVALUE # DATEVALUE
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,5 @@ lang: en-US
# DAYS # DAYS
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,5 @@ lang: en-US
# DAYS360 # DAYS360
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

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

View File

@@ -7,6 +7,5 @@ lang: en-US
# ISOWEEKNUM # ISOWEEKNUM
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

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

View File

@@ -4,9 +4,73 @@ outline: deep
lang: en-US lang: en-US
--- ---
# NETWORKDAYS.INTL # NETWORKDAYS.INTL function
::: warning ::: warning
🚧 This function is not yet available in IronCalc. **Note:** This draft page is under construction 🚧
[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,9 +4,51 @@ outline: deep
lang: en-US lang: en-US
--- ---
# NETWORKDAYS # NETWORKDAYS function
::: warning ::: warning
🚧 This function is not yet available in IronCalc. **Note:** This draft page is under construction 🚧
[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
🚧 This function is not yet available in IronCalc. **Note:** This draft page is under construction 🚧
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) The SECOND function is implemented and available in IronCalc.
::: :::

View File

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

View File

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

View File

@@ -7,6 +7,5 @@ lang: en-US
# WEEKDAY # WEEKDAY
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,5 @@ lang: en-US
# WEEKNUM # WEEKNUM
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,5 @@ lang: en-US
# WORKDAY.INTL # WORKDAY.INTL
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,5 @@ lang: en-US
# WORKDAY # WORKDAY
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -7,6 +7,5 @@ lang: en-US
# YEARFRAC # YEARFRAC
::: warning ::: warning
🚧 This function is not yet available in IronCalc. 🚧 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).
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
::: :::

View File

@@ -0,0 +1,18 @@
---
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,12 +26,3 @@ 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,8 +1,9 @@
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 { NumberFormats } from "./formatUtil"; import { KNOWN_FORMATS, NumberFormats } from "./formatUtil";
type FormatMenuProps = { type FormatMenuProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -26,6 +27,8 @@ const FormatMenu = (properties: FormatMenuProps) => {
[properties.onChange], [properties.onChange],
); );
const isCustomFormat = !KNOWN_FORMATS.has(properties.numFmt);
return ( return (
<> <>
<ChildrenWrapper <ChildrenWrapper
@@ -48,11 +51,17 @@ const FormatMenu = (properties: FormatMenuProps) => {
}} }}
> >
<MenuItemWrapper onClick={(): void => onSelect(NumberFormats.AUTO)}> <MenuItemWrapper onClick={(): void => onSelect(NumberFormats.AUTO)}>
<MenuItemText>{t("toolbar.format_menu.auto")}</MenuItemText> <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>{t("toolbar.format_menu.number")}</MenuItemText> <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>
@@ -60,7 +69,12 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.PERCENTAGE)} onClick={(): void => onSelect(NumberFormats.PERCENTAGE)}
> >
<MenuItemText>{t("toolbar.format_menu.percentage")}</MenuItemText> <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>
@@ -70,7 +84,12 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.CURRENCY_EUR)} onClick={(): void => onSelect(NumberFormats.CURRENCY_EUR)}
> >
<MenuItemText>{t("toolbar.format_menu.currency_eur")}</MenuItemText> <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>
@@ -78,7 +97,12 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.CURRENCY_USD)} onClick={(): void => onSelect(NumberFormats.CURRENCY_USD)}
> >
<MenuItemText>{t("toolbar.format_menu.currency_usd")}</MenuItemText> <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>
@@ -86,7 +110,12 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.CURRENCY_GBP)} onClick={(): void => onSelect(NumberFormats.CURRENCY_GBP)}
> >
<MenuItemText>{t("toolbar.format_menu.currency_gbp")}</MenuItemText> <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>
@@ -96,7 +125,12 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.DATE_SHORT)} onClick={(): void => onSelect(NumberFormats.DATE_SHORT)}
> >
<MenuItemText>{t("toolbar.format_menu.date_short")}</MenuItemText> <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>
@@ -104,7 +138,12 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onSelect(NumberFormats.DATE_LONG)} onClick={(): void => onSelect(NumberFormats.DATE_LONG)}
> >
<MenuItemText>{t("toolbar.format_menu.date_long")}</MenuItemText> <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>
@@ -112,7 +151,10 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuDivider /> <MenuDivider />
<MenuItemWrapper onClick={(): void => setPickerOpen(true)}> <MenuItemWrapper onClick={(): void => setPickerOpen(true)}>
<MenuItemText>{t("toolbar.format_menu.custom")}</MenuItemText> <MenuItemText>
<CheckIcon $active={isCustomFormat} />
{t("toolbar.format_menu.custom")}
</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
</StyledMenu> </StyledMenu>
<FormatPicker <FormatPicker
@@ -139,6 +181,7 @@ 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);
@@ -161,8 +204,18 @@ 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,3 +42,5 @@ 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

@@ -0,0 +1,17 @@
: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,6 +2,7 @@ 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 WelcomeDialog from "./components/WelcomeDialog/WelcomeDialog";
import { import {
get_documentation_model, get_documentation_model,
@@ -10,6 +11,7 @@ import {
} from "./components/rpc"; } from "./components/rpc";
import { import {
createNewModel, createNewModel,
deleteModelByUuid,
deleteSelectedModel, deleteSelectedModel,
isStorageEmpty, isStorageEmpty,
loadSelectedModelFromStorage, loadSelectedModelFromStorage,
@@ -27,6 +29,8 @@ function App() {
const [model, setModel] = useState<Model | null>(null); const [model, setModel] = useState<Model | null>(null);
const [showWelcomeDialog, setShowWelcomeDialog] = useState(false); const [showWelcomeDialog, setShowWelcomeDialog] = useState(false);
const [isTemplatesDialogOpen, setTemplatesDialogOpen] = 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() {
@@ -88,11 +92,50 @@ 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
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
newModel={handleNewModel}
setModel={handleSetModel}
onDelete={handleDeleteModelByUuid}
localStorageId={localStorageId}
/>
<MainContent isDrawerOpen={isDrawerOpen}>
{isDrawerOpen && (
<MobileOverlay onClick={() => setIsDrawerOpen(false)} />
)}
<FileBar <FileBar
model={model} model={model}
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => { onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
@@ -104,27 +147,18 @@ function App() {
setModel(newModel); setModel(newModel);
}} }}
newModel={() => { newModel={handleNewModel}
const createdModel = createNewModel();
setModel(createdModel);
}}
newModelFromTemplate={() => { newModelFromTemplate={() => {
setTemplatesDialogOpen(true); setTemplatesDialogOpen(true);
}} }}
setModel={(uuid: string) => { setModel={handleSetModel}
const newModel = selectModelFromStorage(uuid); onDelete={handleDeleteModel}
if (newModel) { isDrawerOpen={isDrawerOpen}
setModel(newModel); setIsDrawerOpen={setIsDrawerOpen}
} setLocalStorageId={setLocalStorageId}
}}
onDelete={() => {
const newModel = deleteSelectedModel();
if (newModel) {
setModel(newModel);
}
}}
/> />
<IronCalc model={model} /> <IronCalc model={model} />
</MainContent>
{showWelcomeDialog && ( {showWelcomeDialog && (
<WelcomeDialog <WelcomeDialog
onClose={() => { onClose={() => {
@@ -175,13 +209,40 @@ function App() {
} }
const Wrapper = styled("div")` const Wrapper = styled("div")`
margin: 0px; display: flex;
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,19 +12,9 @@ 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,6 +1,7 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import type { Model } from "@ironcalc/workbook"; import type { Model } from "@ironcalc/workbook";
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook"; import { IconButton, Tooltip } from "@mui/material";
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";
@@ -31,6 +32,9 @@ export function FileBar(properties: {
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);
@@ -48,9 +52,29 @@ export function FileBar(properties: {
return ( return (
<FileBarWrapper> <FileBarWrapper>
<StyledDesktopLogo /> <Tooltip
<StyledIronCalcIcon /> title="Toggle sidebar"
<Divider /> slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<DrawerButton
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
disableRipple
>
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
</DrawerButton>
</Tooltip>
{width > 440 && (
<FileMenu <FileMenu
newModel={properties.newModel} newModel={properties.newModel}
newModelFromTemplate={properties.newModelFromTemplate} newModelFromTemplate={properties.newModelFromTemplate}
@@ -64,13 +88,15 @@ export function FileBar(properties: {
}} }}
onDelete={properties.onDelete} onDelete={properties.onDelete}
/> />
<HelpMenu /> )}
{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}
/> />
@@ -103,35 +129,38 @@ const Spacer = styled("div")`
flex-grow: 1; flex-grow: 1;
`; `;
const StyledDesktopLogo = styled(IronCalcLogo)` // const DrawerButton = styled(IconButton)<{ $isDrawerOpen: boolean }>`
width: 120px; // cursor: ${(props) => (props.$isDrawerOpen ? "w-resize" : "e-resize")};
margin-left: 12px; const DrawerButton = styled(IconButton)`
@media (max-width: 769px) { margin-left: 8px;
display: none; height: 32px;
} width: 32px;
`; padding: 8px;
border-radius: 4px;
const StyledIronCalcIcon = styled(IronCalcIcon)` svg {
width: 36px; stroke-width: 2px;
margin-left: 10px; stroke: #757575;
@media (min-width: 769px) { width: 16px;
display: none; height: 16px;
}
&: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,6 +1,6 @@
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 { Check, FileDown, FileUp, Plus, Table2, Trash2 } from "lucide-react"; import { FileDown, FileUp, Plus, Table2, 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";
@@ -19,40 +19,8 @@ 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 (
<> <>
@@ -90,10 +58,8 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<Plus /> <Plus />
</StyledIcon> New blank workbook
<MenuItemText>New blank workbook</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -101,10 +67,8 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<Table2 /> <Table2 />
</StyledIcon> New from template
<MenuItemText>New from template</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -112,31 +76,23 @@ export function FileMenu(props: {
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<FileUp /> <FileUp />
</StyledIcon> Import
<MenuItemText>Import</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuDivider /> <MenuDivider />
<MenuItemWrapper onClick={props.onDownload}> <MenuItemWrapper onClick={props.onDownload}>
<StyledIcon>
<FileDown /> <FileDown />
</StyledIcon> Download (.xlsx)
<MenuItemText>Download (.xlsx)</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <DeleteButton
onClick={() => { onClick={() => {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
setMenuOpen(false); setMenuOpen(false);
}} }}
> >
<StyledIcon>
<Trash2 /> <Trash2 />
</StyledIcon> Delete workbook
<MenuItemText>Delete workbook</MenuItemText> </DeleteButton>
</MenuItemWrapper>
<MenuDivider />
{elements}
</Menu> </Menu>
<Modal <Modal
open={isImportMenuOpen} open={isImportMenuOpen}
@@ -162,25 +118,14 @@ export function FileMenu(props: {
<DeleteWorkbookDialog <DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)} onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete} onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid] : ""} workbookName={selectedUuid ? models[selectedUuid].name : ""}
/> />
</Modal> </Modal>
</> </>
); );
} }
const StyledIcon = styled.div` export const MenuDivider = 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;
@@ -188,12 +133,7 @@ const MenuDivider = styled.div`
border-top: 1px solid #eeeeee; border-top: 1px solid #eeeeee;
`; `;
const MenuItemText = styled.div` export const MenuItemWrapper = styled(MenuItem)`
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;
@@ -203,6 +143,14 @@ 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 }>`
@@ -215,14 +163,20 @@ 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;
} }
`; `;
const CheckIndicator = styled.span` export const DeleteButton = styled(MenuItemWrapper)`
display: flex; color: #EB5757;
justify-content: center; svg {
min-width: 26px; color: #EB5757;
}
&:hover {
background-color: #EB57571A;
}
&:active {
background-color: #EB57571A;
}
`; `;

View File

@@ -1,7 +1,8 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Menu, MenuItem } from "@mui/material"; import { Menu } 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);
@@ -61,10 +62,8 @@ export function HelpMenu() {
); );
}} }}
> >
<StyledIcon>
<BookOpen /> <BookOpen />
</StyledIcon> Documentation
<MenuItemText>Documentation</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
@@ -76,10 +75,8 @@ export function HelpMenu() {
); );
}} }}
> >
<StyledIcon>
<Keyboard /> <Keyboard />
</StyledIcon> Keyboard Shortcuts
<MenuItemText>Keyboard Shortcuts</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
</Menu> </Menu>
</div> </div>
@@ -96,37 +93,7 @@ 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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,71 @@
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

@@ -0,0 +1,98 @@
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,426 @@
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={() => {}}>
<Share2 style={{ width: "16px", height: "16px", marginRight: "10px" }} /> <ShareIcon />
<span>Share</span> <ShareText>Share</ShareText>
</Wrapper> </Wrapper>
); );
} }
@@ -23,8 +23,24 @@ const Wrapper = styled("div")`
display: flex; display: flex;
align-items: center; align-items: center;
font-family: "Inter"; font-family: "Inter";
font-size: 14px; font-size: 12px;
&: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

@@ -106,7 +106,7 @@ const DialogHeaderTitleSubtitle = styled("span")`
color: #757575; color: #757575;
`; `;
const DialogHeaderLogoWrapper = styled("div")` export const DialogHeaderLogoWrapper = styled("div")`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;

View File

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

View File

@@ -3,7 +3,10 @@ import { base64ToBytes, bytesToBase64 } from "./util";
const MAX_WORKBOOKS = 50; const MAX_WORKBOOKS = 50;
type ModelsMetadata = Record<string, string>; type ModelsMetadata = Record<
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");
@@ -12,7 +15,11 @@ export function updateNameSelectedWorkbook(model: Model, newName: string) {
if (modelsJson) { if (modelsJson) {
try { try {
const models = JSON.parse(modelsJson); const models = JSON.parse(modelsJson);
models[uuid] = newName; if (models[uuid]) {
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");
@@ -28,7 +35,26 @@ export function getModelsMetadata(): ModelsMetadata {
if (!modelsJson) { if (!modelsJson) {
modelsJson = "{}"; modelsJson = "{}";
} }
return JSON.parse(modelsJson); const models = 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
@@ -48,14 +74,14 @@ 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)); const name = getNewName(Object.values(models).map((m) => m.name));
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; models[uuid] = { name, createdAt: Date.now() };
localStorage.setItem("models", JSON.stringify(models)); localStorage.setItem("models", JSON.stringify(models));
return model; return model;
} }
@@ -103,7 +129,7 @@ export function saveModelToStorage(model: Model) {
modelsJson = "{}"; modelsJson = "{}";
} }
const models = JSON.parse(modelsJson); const models = JSON.parse(modelsJson);
models[uuid] = model.getName(); models[uuid] = { name: model.getName(), createdAt: Date.now() };
localStorage.setItem("models", JSON.stringify(models)); localStorage.setItem("models", JSON.stringify(models));
} }
@@ -135,3 +161,79 @@ 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.5.0" version = "0.6.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.5" } ironcalc_base = { path = "../base", version = "0.6" }
itertools = "0.12" itertools = "0.12"
chrono = "0.4" chrono = "0.4"
bitcode = "0.6.0" bitcode = "0.6.0"

Binary file not shown.

Binary file not shown.