From 8c5e50ef3962614180e3fb84393cdc669764d6a1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Mar 2026 12:25:40 +0000 Subject: [PATCH] feat: spreadsheet artifact (#13345) --- codex-rs/Cargo.lock | 15 + codex-rs/Cargo.toml | 3 + codex-rs/artifact-spreadsheet/BUILD.bazel | 6 + codex-rs/artifact-spreadsheet/Cargo.toml | 25 + codex-rs/artifact-spreadsheet/src/address.rs | 245 +++ codex-rs/artifact-spreadsheet/src/error.rs | 39 + codex-rs/artifact-spreadsheet/src/formula.rs | 535 ++++++ codex-rs/artifact-spreadsheet/src/lib.rs | 14 + codex-rs/artifact-spreadsheet/src/manager.rs | 1683 ++++++++++++++++++ codex-rs/artifact-spreadsheet/src/model.rs | 1529 ++++++++++++++++ codex-rs/artifact-spreadsheet/src/tests.rs | 302 ++++ codex-rs/artifact-spreadsheet/src/xlsx.rs | 817 +++++++++ 12 files changed, 5213 insertions(+) create mode 100644 codex-rs/artifact-spreadsheet/BUILD.bazel create mode 100644 codex-rs/artifact-spreadsheet/Cargo.toml create mode 100644 codex-rs/artifact-spreadsheet/src/address.rs create mode 100644 codex-rs/artifact-spreadsheet/src/error.rs create mode 100644 codex-rs/artifact-spreadsheet/src/formula.rs create mode 100644 codex-rs/artifact-spreadsheet/src/lib.rs create mode 100644 codex-rs/artifact-spreadsheet/src/manager.rs create mode 100644 codex-rs/artifact-spreadsheet/src/model.rs create mode 100644 codex-rs/artifact-spreadsheet/src/tests.rs create mode 100644 codex-rs/artifact-spreadsheet/src/xlsx.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 93aa10c1b..77dcbd75c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1558,6 +1558,21 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "codex-artifact-spreadsheet" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "pretty_assertions", + "regex", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "uuid", + "zip 2.4.2", +] + [[package]] name = "codex-async-utils" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 396babea3..9ee05e21b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -34,6 +34,7 @@ members = [ "network-proxy", "ollama", "artifact-presentation", + "artifact-spreadsheet", "process-hardening", "protocol", "rmcp-client", @@ -111,6 +112,7 @@ codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } codex-artifact-presentation = { path = "artifact-presentation" } +codex-artifact-spreadsheet = { path = "artifact-spreadsheet" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-responses-api-proxy = { path = "responses-api-proxy" } @@ -352,6 +354,7 @@ ignored = [ "openssl-sys", "codex-utils-readiness", "codex-secrets", + "codex-artifact-spreadsheet" ] [profile.release] diff --git a/codex-rs/artifact-spreadsheet/BUILD.bazel b/codex-rs/artifact-spreadsheet/BUILD.bazel new file mode 100644 index 000000000..07d133728 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "artifact-spreadsheet", + crate_name = "codex_artifact_spreadsheet", +) diff --git a/codex-rs/artifact-spreadsheet/Cargo.toml b/codex-rs/artifact-spreadsheet/Cargo.toml new file mode 100644 index 000000000..66e030ea4 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codex-artifact-spreadsheet" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_artifact_spreadsheet" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +base64 = { workspace = true } +regex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true, features = ["v4"] } +zip = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/artifact-spreadsheet/src/address.rs b/codex-rs/artifact-spreadsheet/src/address.rs new file mode 100644 index 000000000..28e4da5e3 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/address.rs @@ -0,0 +1,245 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::SpreadsheetArtifactError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct CellAddress { + pub column: u32, + pub row: u32, +} + +impl CellAddress { + pub fn parse(address: &str) -> Result { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "address is empty".to_string(), + }); + } + + let mut split = 0usize; + for (index, ch) in trimmed.char_indices() { + if ch.is_ascii_alphabetic() { + split = index + ch.len_utf8(); + } else { + break; + } + } + + let (letters, digits) = trimmed.split_at(split); + if letters.is_empty() || digits.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "expected A1-style address".to_string(), + }); + } + + if !letters.chars().all(|ch| ch.is_ascii_alphabetic()) + || !digits.chars().all(|ch| ch.is_ascii_digit()) + { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "expected letters followed by digits".to_string(), + }); + } + + let column = column_letters_to_index(letters)?; + let row = digits + .parse::() + .map_err(|_| SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "row must be a positive integer".to_string(), + })?; + + if row == 0 { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "row must be positive".to_string(), + }); + } + + Ok(Self { column, row }) + } + + pub fn to_a1(self) -> String { + format!("{}{}", column_index_to_letters(self.column), self.row) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CellRange { + pub start: CellAddress, + pub end: CellAddress, +} + +impl CellRange { + pub fn parse(address: &str) -> Result { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: address.to_string(), + message: "range is empty".to_string(), + }); + } + + let (start, end) = if let Some((left, right)) = trimmed.split_once(':') { + (CellAddress::parse(left)?, CellAddress::parse(right)?) + } else { + let cell = CellAddress::parse(trimmed)?; + (cell, cell) + }; + + let normalized = Self { + start: CellAddress { + column: start.column.min(end.column), + row: start.row.min(end.row), + }, + end: CellAddress { + column: start.column.max(end.column), + row: start.row.max(end.row), + }, + }; + Ok(normalized) + } + + pub fn from_start_end(start: CellAddress, end: CellAddress) -> Self { + Self { + start: CellAddress { + column: start.column.min(end.column), + row: start.row.min(end.row), + }, + end: CellAddress { + column: start.column.max(end.column), + row: start.row.max(end.row), + }, + } + } + + pub fn to_a1(&self) -> String { + if self.is_single_cell() { + self.start.to_a1() + } else { + format!("{}:{}", self.start.to_a1(), self.end.to_a1()) + } + } + + pub fn is_single_cell(&self) -> bool { + self.start == self.end + } + + pub fn is_single_row(&self) -> bool { + self.start.row == self.end.row + } + + pub fn is_single_column(&self) -> bool { + self.start.column == self.end.column + } + + pub fn width(&self) -> usize { + (self.end.column - self.start.column + 1) as usize + } + + pub fn height(&self) -> usize { + (self.end.row - self.start.row + 1) as usize + } + + pub fn contains(&self, address: CellAddress) -> bool { + self.start.column <= address.column + && address.column <= self.end.column + && self.start.row <= address.row + && address.row <= self.end.row + } + + pub fn contains_range(&self, other: &CellRange) -> bool { + self.contains(other.start) && self.contains(other.end) + } + + pub fn intersects(&self, other: &CellRange) -> bool { + !(self.end.column < other.start.column + || other.end.column < self.start.column + || self.end.row < other.start.row + || other.end.row < self.start.row) + } + + pub fn addresses(&self) -> impl Iterator { + let range = self.clone(); + (range.start.row..=range.end.row).flat_map(move |row| { + let range = range.clone(); + (range.start.column..=range.end.column).map(move |column| CellAddress { column, row }) + }) + } +} + +pub fn column_letters_to_index(column: &str) -> Result { + let trimmed = column.trim(); + if trimmed.is_empty() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: column.to_string(), + message: "column is empty".to_string(), + }); + } + + let mut result = 0u32; + for ch in trimmed.chars() { + if !ch.is_ascii_alphabetic() { + return Err(SpreadsheetArtifactError::InvalidAddress { + address: column.to_string(), + message: "column must contain only letters".to_string(), + }); + } + result = result + .checked_mul(26) + .and_then(|value| value.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as u32)) + .ok_or_else(|| SpreadsheetArtifactError::InvalidAddress { + address: column.to_string(), + message: "column is too large".to_string(), + })?; + } + Ok(result) +} + +pub fn column_index_to_letters(mut index: u32) -> String { + if index == 0 { + return String::new(); + } + + let mut letters = Vec::new(); + while index > 0 { + let remainder = (index - 1) % 26; + letters.push((b'A' + remainder as u8) as char); + index = (index - 1) / 26; + } + letters.iter().rev().collect() +} + +pub fn parse_column_reference(reference: &str) -> Result<(u32, u32), SpreadsheetArtifactError> { + let trimmed = reference.trim(); + if let Some((left, right)) = trimmed.split_once(':') { + let start = column_letters_to_index(left)?; + let end = column_letters_to_index(right)?; + Ok((start.min(end), start.max(end))) + } else { + let column = column_letters_to_index(trimmed)?; + Ok((column, column)) + } +} + +pub fn is_valid_cell_reference(address: &str) -> bool { + CellAddress::parse(address).is_ok() +} + +pub fn is_valid_range_reference(address: &str) -> bool { + CellRange::parse(address).is_ok() +} + +pub fn is_valid_row_reference(address: &str) -> bool { + CellRange::parse(address) + .map(|range| range.is_single_row()) + .unwrap_or(false) +} + +pub fn is_valid_column_reference(address: &str) -> bool { + parse_column_reference(address).is_ok() +} diff --git a/codex-rs/artifact-spreadsheet/src/error.rs b/codex-rs/artifact-spreadsheet/src/error.rs new file mode 100644 index 000000000..d6d0b31bc --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/error.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SpreadsheetArtifactError { + #[error("missing `artifact_id` for action `{action}`")] + MissingArtifactId { action: String }, + #[error("unknown artifact id `{artifact_id}` for action `{action}`")] + UnknownArtifactId { action: String, artifact_id: String }, + #[error("unknown action `{0}`")] + UnknownAction(String), + #[error("invalid args for action `{action}`: {message}")] + InvalidArgs { action: String, message: String }, + #[error("invalid address `{address}`: {message}")] + InvalidAddress { address: String, message: String }, + #[error("sheet lookup failed for action `{action}`: {message}")] + SheetLookup { action: String, message: String }, + #[error("index `{index}` is out of range for action `{action}`; len={len}")] + IndexOutOfRange { + action: String, + index: usize, + len: usize, + }, + #[error("merge conflict for action `{action}` on range `{range}` with `{conflict}`")] + MergeConflict { + action: String, + range: String, + conflict: String, + }, + #[error("formula error at `{location}`: {message}")] + Formula { location: String, message: String }, + #[error("serialization failed: {message}")] + Serialization { message: String }, + #[error("failed to import XLSX `{path}`: {message}")] + ImportFailed { path: PathBuf, message: String }, + #[error("failed to export XLSX `{path}`: {message}")] + ExportFailed { path: PathBuf, message: String }, +} diff --git a/codex-rs/artifact-spreadsheet/src/formula.rs b/codex-rs/artifact-spreadsheet/src/formula.rs new file mode 100644 index 000000000..9ee8d0824 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/formula.rs @@ -0,0 +1,535 @@ +use std::collections::BTreeSet; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellValue; + +#[derive(Debug, Clone)] +enum Token { + Number(f64), + Cell(String), + Ident(String), + Plus, + Minus, + Star, + Slash, + LParen, + RParen, + Colon, + Comma, +} + +#[derive(Debug, Clone)] +enum Expr { + Number(f64), + Cell(CellAddress), + Range(CellRange), + UnaryMinus(Box), + Binary { + op: BinaryOp, + left: Box, + right: Box, + }, + Function { + name: String, + args: Vec, + }, +} + +#[derive(Debug, Clone, Copy)] +enum BinaryOp { + Add, + Subtract, + Multiply, + Divide, +} + +#[derive(Debug, Clone)] +enum EvalValue { + Scalar(Option), + Range(Vec>), +} + +pub(crate) fn recalculate_workbook(artifact: &mut SpreadsheetArtifact) { + let updates = artifact + .sheets + .iter() + .enumerate() + .flat_map(|(sheet_index, sheet)| { + sheet.cells.iter().filter_map(move |(address, cell)| { + cell.formula + .as_ref() + .map(|formula| (sheet_index, *address, formula.clone())) + }) + }) + .map(|(sheet_index, address, formula)| { + let mut stack = BTreeSet::new(); + let value = evaluate_formula(artifact, sheet_index, &formula, &mut stack) + .unwrap_or_else(|error| { + Some(SpreadsheetCellValue::Error(map_error_to_code(&error))) + }); + (sheet_index, address, value) + }) + .collect::>(); + + for (sheet_index, address, value) in updates { + if let Some(sheet) = artifact.sheets.get_mut(sheet_index) + && let Some(cell) = sheet.cells.get_mut(&address) + { + cell.value = value; + } + } +} + +fn evaluate_formula( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + formula: &str, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result, SpreadsheetArtifactError> { + let source = formula.trim().trim_start_matches('='); + let tokens = tokenize(source)?; + let mut parser = Parser::new(tokens); + let expr = parser.parse_expression()?; + if parser.has_remaining() { + return Err(SpreadsheetArtifactError::Formula { + location: formula.to_string(), + message: "unexpected trailing tokens".to_string(), + }); + } + match evaluate_expr(artifact, sheet_index, &expr, stack)? { + EvalValue::Scalar(value) => Ok(value), + EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula { + location: formula.to_string(), + message: "range expressions are only allowed inside functions".to_string(), + }), + } +} + +fn evaluate_expr( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + expr: &Expr, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result { + match expr { + Expr::Number(value) => Ok(EvalValue::Scalar(Some(number_to_value(*value)))), + Expr::Cell(address) => evaluate_cell_reference(artifact, sheet_index, *address, stack), + Expr::Range(range) => { + let sheet = artifact.sheets.get(sheet_index).ok_or_else(|| { + SpreadsheetArtifactError::Formula { + location: range.to_a1(), + message: "sheet index was not found".to_string(), + } + })?; + let values = range + .addresses() + .map(|address| sheet.get_cell(address).and_then(|cell| cell.value.clone())) + .collect::>(); + Ok(EvalValue::Range(values)) + } + Expr::UnaryMinus(inner) => { + let value = evaluate_scalar(artifact, sheet_index, inner, stack)?; + Ok(EvalValue::Scalar(match value { + None => Some(SpreadsheetCellValue::Integer(0)), + Some(SpreadsheetCellValue::Integer(value)) => { + Some(SpreadsheetCellValue::Integer(-value)) + } + Some(SpreadsheetCellValue::Float(value)) => { + Some(SpreadsheetCellValue::Float(-value)) + } + Some(SpreadsheetCellValue::Error(value)) => { + Some(SpreadsheetCellValue::Error(value)) + } + Some(_) => Some(SpreadsheetCellValue::Error("#VALUE!".to_string())), + })) + } + Expr::Binary { op, left, right } => { + let left = evaluate_scalar(artifact, sheet_index, left, stack)?; + let right = evaluate_scalar(artifact, sheet_index, right, stack)?; + Ok(EvalValue::Scalar(Some(apply_binary_op(*op, left, right)?))) + } + Expr::Function { name, args } => { + let mut numeric = Vec::new(); + for arg in args { + match evaluate_expr(artifact, sheet_index, arg, stack)? { + EvalValue::Scalar(value) => { + if let Some(number) = scalar_to_number(value.clone())? { + numeric.push(number); + } + } + EvalValue::Range(values) => { + for value in values { + if let Some(number) = scalar_to_number(value.clone())? { + numeric.push(number); + } + } + } + } + } + let upper = name.to_ascii_uppercase(); + let result = match upper.as_str() { + "SUM" => numeric.iter().sum::(), + "AVERAGE" => { + if numeric.is_empty() { + return Ok(EvalValue::Scalar(None)); + } + numeric.iter().sum::() / numeric.len() as f64 + } + "MIN" => numeric.iter().copied().reduce(f64::min).unwrap_or(0.0), + "MAX" => numeric.iter().copied().reduce(f64::max).unwrap_or(0.0), + _ => { + return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error( + "#NAME?".to_string(), + )))); + } + }; + Ok(EvalValue::Scalar(Some(number_to_value(result)))) + } + } +} + +fn evaluate_scalar( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + expr: &Expr, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result, SpreadsheetArtifactError> { + match evaluate_expr(artifact, sheet_index, expr, stack)? { + EvalValue::Scalar(value) => Ok(value), + EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula { + location: format!("{expr:?}"), + message: "expected a scalar expression".to_string(), + }), + } +} + +fn evaluate_cell_reference( + artifact: &SpreadsheetArtifact, + sheet_index: usize, + address: CellAddress, + stack: &mut BTreeSet<(usize, CellAddress)>, +) -> Result { + let Some(sheet) = artifact.sheets.get(sheet_index) else { + return Err(SpreadsheetArtifactError::Formula { + location: address.to_a1(), + message: "sheet index was not found".to_string(), + }); + }; + let key = (sheet_index, address); + if !stack.insert(key) { + return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error( + "#CYCLE!".to_string(), + )))); + } + + let value = if let Some(cell) = sheet.get_cell(address) { + if let Some(formula) = &cell.formula { + evaluate_formula(artifact, sheet_index, formula, stack)? + } else { + cell.value.clone() + } + } else { + None + }; + stack.remove(&key); + Ok(EvalValue::Scalar(value)) +} + +fn apply_binary_op( + op: BinaryOp, + left: Option, + right: Option, +) -> Result { + if let Some(SpreadsheetCellValue::Error(value)) = &left { + return Ok(SpreadsheetCellValue::Error(value.clone())); + } + if let Some(SpreadsheetCellValue::Error(value)) = &right { + return Ok(SpreadsheetCellValue::Error(value.clone())); + } + + let left = scalar_to_number(left)?; + let right = scalar_to_number(right)?; + let left = left.unwrap_or(0.0); + let right = right.unwrap_or(0.0); + let result = match op { + BinaryOp::Add => left + right, + BinaryOp::Subtract => left - right, + BinaryOp::Multiply => left * right, + BinaryOp::Divide => { + if right == 0.0 { + return Ok(SpreadsheetCellValue::Error("#DIV/0!".to_string())); + } + left / right + } + }; + Ok(number_to_value(result)) +} + +fn scalar_to_number( + value: Option, +) -> Result, SpreadsheetArtifactError> { + match value { + None => Ok(None), + Some(SpreadsheetCellValue::Integer(value)) => Ok(Some(value as f64)), + Some(SpreadsheetCellValue::Float(value)) => Ok(Some(value)), + Some(SpreadsheetCellValue::Bool(value)) => Ok(Some(if value { 1.0 } else { 0.0 })), + Some(SpreadsheetCellValue::Error(value)) => Err(SpreadsheetArtifactError::Formula { + location: value, + message: "encountered error value".to_string(), + }), + Some(other) => Err(SpreadsheetArtifactError::Formula { + location: format!("{other:?}"), + message: "value is not numeric".to_string(), + }), + } +} + +fn number_to_value(number: f64) -> SpreadsheetCellValue { + if number.fract() == 0.0 { + SpreadsheetCellValue::Integer(number as i64) + } else { + SpreadsheetCellValue::Float(number) + } +} + +fn map_error_to_code(error: &SpreadsheetArtifactError) -> String { + match error { + SpreadsheetArtifactError::Formula { message, .. } => { + if message.contains("cycle") { + "#CYCLE!".to_string() + } else if message.contains("not numeric") || message.contains("scalar") { + "#VALUE!".to_string() + } else { + "#ERROR!".to_string() + } + } + SpreadsheetArtifactError::InvalidAddress { .. } => "#REF!".to_string(), + _ => "#ERROR!".to_string(), + } +} + +fn tokenize(source: &str) -> Result, SpreadsheetArtifactError> { + let chars = source.chars().collect::>(); + let mut index = 0usize; + let mut tokens = Vec::new(); + while index < chars.len() { + let ch = chars[index]; + if ch.is_ascii_whitespace() { + index += 1; + continue; + } + match ch { + '+' => { + tokens.push(Token::Plus); + index += 1; + } + '-' => { + tokens.push(Token::Minus); + index += 1; + } + '*' => { + tokens.push(Token::Star); + index += 1; + } + '/' => { + tokens.push(Token::Slash); + index += 1; + } + '(' => { + tokens.push(Token::LParen); + index += 1; + } + ')' => { + tokens.push(Token::RParen); + index += 1; + } + ':' => { + tokens.push(Token::Colon); + index += 1; + } + ',' => { + tokens.push(Token::Comma); + index += 1; + } + '0'..='9' | '.' => { + let start = index; + index += 1; + while index < chars.len() && (chars[index].is_ascii_digit() || chars[index] == '.') + { + index += 1; + } + let number = source[start..index].parse::().map_err(|_| { + SpreadsheetArtifactError::Formula { + location: source.to_string(), + message: "invalid numeric literal".to_string(), + } + })?; + tokens.push(Token::Number(number)); + } + 'A'..='Z' | 'a'..='z' | '_' => { + let start = index; + index += 1; + while index < chars.len() + && (chars[index].is_ascii_alphanumeric() || chars[index] == '_') + { + index += 1; + } + let text = source[start..index].to_string(); + if text.chars().any(|part| part.is_ascii_digit()) + && text.chars().any(|part| part.is_ascii_alphabetic()) + { + tokens.push(Token::Cell(text)); + } else { + tokens.push(Token::Ident(text)); + } + } + other => { + return Err(SpreadsheetArtifactError::Formula { + location: source.to_string(), + message: format!("unsupported token `{other}`"), + }); + } + } + } + Ok(tokens) +} + +struct Parser { + tokens: Vec, + index: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Self { tokens, index: 0 } + } + + fn has_remaining(&self) -> bool { + self.index < self.tokens.len() + } + + fn parse_expression(&mut self) -> Result { + let mut expr = self.parse_term()?; + while let Some(token) = self.peek() { + let op = match token { + Token::Plus => BinaryOp::Add, + Token::Minus => BinaryOp::Subtract, + _ => break, + }; + self.index += 1; + let right = self.parse_term()?; + expr = Expr::Binary { + op, + left: Box::new(expr), + right: Box::new(right), + }; + } + Ok(expr) + } + + fn parse_term(&mut self) -> Result { + let mut expr = self.parse_factor()?; + while let Some(token) = self.peek() { + let op = match token { + Token::Star => BinaryOp::Multiply, + Token::Slash => BinaryOp::Divide, + _ => break, + }; + self.index += 1; + let right = self.parse_factor()?; + expr = Expr::Binary { + op, + left: Box::new(expr), + right: Box::new(right), + }; + } + Ok(expr) + } + + fn parse_factor(&mut self) -> Result { + match self.peek() { + Some(Token::Minus) => { + self.index += 1; + Ok(Expr::UnaryMinus(Box::new(self.parse_factor()?))) + } + _ => self.parse_primary(), + } + } + + fn parse_primary(&mut self) -> Result { + match self.next().cloned() { + Some(Token::Number(value)) => Ok(Expr::Number(value)), + Some(Token::Cell(address)) => { + let start = CellAddress::parse(&address)?; + if matches!(self.peek(), Some(Token::Colon)) { + self.index += 1; + let Some(Token::Cell(end)) = self.next().cloned() else { + return Err(SpreadsheetArtifactError::Formula { + location: address, + message: "expected cell after `:`".to_string(), + }); + }; + Ok(Expr::Range(CellRange::from_start_end( + start, + CellAddress::parse(&end)?, + ))) + } else { + Ok(Expr::Cell(start)) + } + } + Some(Token::Ident(name)) => { + if !matches!(self.next(), Some(Token::LParen)) { + return Err(SpreadsheetArtifactError::Formula { + location: name, + message: "expected `(` after function name".to_string(), + }); + } + let mut args = Vec::new(); + if !matches!(self.peek(), Some(Token::RParen)) { + loop { + args.push(self.parse_expression()?); + if matches!(self.peek(), Some(Token::Comma)) { + self.index += 1; + continue; + } + break; + } + } + if !matches!(self.next(), Some(Token::RParen)) { + return Err(SpreadsheetArtifactError::Formula { + location: name, + message: "expected `)`".to_string(), + }); + } + Ok(Expr::Function { name, args }) + } + Some(Token::LParen) => { + let expr = self.parse_expression()?; + if !matches!(self.next(), Some(Token::RParen)) { + return Err(SpreadsheetArtifactError::Formula { + location: format!("{expr:?}"), + message: "expected `)`".to_string(), + }); + } + Ok(expr) + } + other => Err(SpreadsheetArtifactError::Formula { + location: format!("{other:?}"), + message: "unexpected token".to_string(), + }), + } + } + + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.index) + } + + fn next(&mut self) -> Option<&Token> { + let token = self.tokens.get(self.index); + self.index += usize::from(token.is_some()); + token + } +} diff --git a/codex-rs/artifact-spreadsheet/src/lib.rs b/codex-rs/artifact-spreadsheet/src/lib.rs new file mode 100644 index 000000000..262ae7e9a --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/lib.rs @@ -0,0 +1,14 @@ +mod address; +mod error; +mod formula; +mod manager; +mod model; +mod xlsx; + +#[cfg(test)] +mod tests; + +pub use address::*; +pub use error::*; +pub use manager::*; +pub use model::*; diff --git a/codex-rs/artifact-spreadsheet/src/manager.rs b/codex-rs/artifact-spreadsheet/src/manager.rs new file mode 100644 index 000000000..14a7b70e9 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/manager.rs @@ -0,0 +1,1683 @@ +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellRangeRef; +use crate::SpreadsheetCellRef; +use crate::SpreadsheetCellValue; +use crate::SpreadsheetCitation; +use crate::SpreadsheetRangeView; +use crate::SpreadsheetSheetSummary; +use crate::SpreadsheetSummary; + +#[derive(Debug, Clone, Deserialize)] +pub struct SpreadsheetArtifactRequest { + pub artifact_id: Option, + pub action: String, + #[serde(default)] + pub args: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathAccessKind { + Read, + Write, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathAccessRequirement { + pub action: String, + pub kind: PathAccessKind, + pub path: PathBuf, +} + +impl SpreadsheetArtifactRequest { + pub fn required_path_accesses( + &self, + cwd: &Path, + ) -> Result, SpreadsheetArtifactError> { + let access = match self.action.as_str() { + "import_xlsx" | "load" | "read" => { + let args: PathArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, &args.path), + }] + } + "export_xlsx" => { + let args: PathArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + "save" => { + let args: SaveArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + _ => Vec::new(), + }; + Ok(access) + } +} + +#[derive(Debug, Default)] +pub struct SpreadsheetArtifactManager { + documents: HashMap, +} + +impl SpreadsheetArtifactManager { + pub fn execute( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + match request.action.as_str() { + "create" => self.create(request), + "import_xlsx" | "load" | "read" => self.import_xlsx(request, cwd), + "export_xlsx" => self.export_xlsx(request, cwd), + "save" => self.save(request, cwd), + "get_summary" => self.get_summary(request), + "list_sheets" => self.list_sheets(request), + "get_sheet" => self.get_sheet(request), + "inspect" => self.inspect(request), + "create_sheet" => self.create_sheet(request), + "rename_sheet" => self.rename_sheet(request), + "delete_sheet" => self.delete_sheet(request), + "set_sheet_properties" => self.set_sheet_properties(request), + "set_column_widths" => self.set_column_widths(request), + "get_cell" => self.get_cell(request), + "get_cell_by_indices" => self.get_cell_by_indices(request), + "get_cell_field" => self.get_cell_field(request), + "get_cell_field_by_indices" => self.get_cell_field_by_indices(request), + "get_range" => self.get_range(request), + "set_cell_value" => self.set_cell_value(request), + "set_range_value" => self.set_range_value(request), + "set_range_values" => self.set_range_values(request), + "set_cell_formula" => self.set_cell_formula(request), + "set_range_formula" => self.set_range_formula(request), + "set_range_formulas" => self.set_range_formulas(request), + "set_cell_style" => self.set_cell_style(request), + "set_range_style" => self.set_range_style(request), + "clear_range" => self.clear_range(request), + "merge_range" => self.merge_range(request), + "unmerge_range" => self.unmerge_range(request), + "cite_cell" => self.cite_cell(request), + "cite_range" => self.cite_range(request), + "calculate" | "recalculate" => self.calculate(request), + "serialize_dict" => self.serialize_dict(request), + "serialize_json" => self.serialize_json(request), + "serialize_bytes" => self.serialize_bytes(request), + "deserialize_dict" => self.deserialize_dict(request), + "deserialize_json" => self.deserialize_json(request), + "deserialize_bytes" => self.deserialize_bytes(request), + "delete_artifact" => self.delete_artifact(request), + other => Err(SpreadsheetArtifactError::UnknownAction(other.to_string())), + } + } + + fn create( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateArgs = parse_args(&request.action, &request.args)?; + let mut artifact = SpreadsheetArtifact::new(args.name); + if let Some(auto_recalculate) = args.auto_recalculate { + artifact.auto_recalculate = auto_recalculate; + } + let artifact_id = artifact.artifact_id.clone(); + let summary = format!("Created spreadsheet artifact `{artifact_id}`"); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + summary, + snapshot, + )) + } + + fn import_xlsx( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: PathArgs = parse_args(&request.action, &request.args)?; + let path = resolve_path(cwd, &args.path); + let artifact = SpreadsheetArtifact::from_source_file(&path, None)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + let summary = format!( + "Imported `{}` as spreadsheet artifact `{artifact_id}` with {} sheets", + path.display(), + artifact.sheets.len() + ); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + summary, + snapshot, + )) + } + + fn export_xlsx( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: PathArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let path = resolve_path(cwd, &args.path); + let exported = artifact.export(&path)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Exported spreadsheet to `{}`", exported.display()), + snapshot_for_artifact(artifact), + ); + response.exported_paths.push(exported); + response.workbook_summary = Some(artifact.summary()); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn save( + &mut self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let args: SaveArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let path = resolve_path(cwd, &args.path); + let exported = artifact.save(&path, args.file_type.as_deref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Saved spreadsheet to `{}`", exported.display()), + snapshot_for_artifact(artifact), + ); + response.exported_paths.push(exported); + response.workbook_summary = Some(artifact.summary()); + Ok(response) + } + + fn get_summary( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Spreadsheet has {} sheets and {} bytes of serialized state", + artifact.sheets.len(), + artifact.summary().size_bytes + ), + snapshot_for_artifact(artifact), + ); + response.workbook_summary = Some(artifact.summary()); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn list_sheets( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} sheets", artifact.sheets.len()), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn get_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = if let Some(range) = args.range.as_deref() { + Some(CellRange::parse(range)?) + } else { + sheet.minimum_range() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved sheet `{}`", sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet.summary()]); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.rendered_text = Some(sheet.to_rendered_text(range.as_ref())); + response.range = range.as_ref().map(|entry| sheet.get_range_view(entry)); + response.serialized_dict = Some(sheet.to_dict()?); + Ok(response) + } + + fn inspect( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let inspected = if args.sheet_name.is_none() && args.sheet_index.is_none() { + artifact.to_dict()? + } else { + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + serde_json::to_value(sheet).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Generated inspection snapshot".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(inspected); + Ok(response) + } + + fn create_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateSheetArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let name = args.name.clone(); + artifact.create_sheet(args.name)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created sheet `{name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn rename_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RenameSheetArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let new_name = args.new_name.clone(); + artifact.rename_sheet( + args.new_name, + args.old_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Renamed sheet to `{new_name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn delete_sheet( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeleteSheetArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + artifact.delete_sheet( + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deleted sheet".to_string(), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some( + artifact + .sheets + .iter() + .map(super::model::SpreadsheetSheet::summary) + .collect(), + ); + Ok(response) + } + + fn set_sheet_properties( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetSheetPropertiesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + if let Some(default_row_height) = args.default_row_height { + sheet.default_row_height = Some(default_row_height); + } + if let Some(default_column_width) = args.default_column_width { + sheet.default_column_width = Some(default_column_width); + } + if let Some(show_grid_lines) = args.show_grid_lines { + sheet.show_grid_lines = show_grid_lines; + } + sheet.summary() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated sheet `{}` properties", sheet_summary.name), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn set_column_widths( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetColumnWidthsArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_summary = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_column_widths(&args.reference, args.width)?; + sheet.summary() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated column widths `{}` on `{}`", + args.reference, sheet_summary.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![sheet_summary]); + Ok(response) + } + + fn get_cell( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellAddressArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let cell = sheet.get_cell_view(CellAddress::parse(&args.address)?); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved cell `{}` from `{}`", args.address, sheet.name), + snapshot_for_artifact(artifact), + ); + response.cell = Some(cell); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn get_cell_by_indices( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellIndicesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let address = CellAddress { + column: args.column_index, + row: args.row_index, + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved cell by indices ({}, {}) from `{}`", + args.column_index, args.row_index, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.cell = Some(sheet.get_cell_view_by_indices(args.column_index, args.row_index)); + response.cell_ref = Some(sheet.cell_ref(address.to_a1())?); + Ok(response) + } + + fn get_cell_field( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellFieldArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let value = sheet.get_cell_field(CellAddress::parse(&args.address)?, &args.field)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved field `{}` from `{}` on `{}`", + args.field, args.address, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.cell_field = value; + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn get_cell_field_by_indices( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellFieldByIndicesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let value = + sheet.get_cell_field_by_indices(args.column_index, args.row_index, &args.field)?; + let address = CellAddress { + column: args.column_index, + row: args.row_index, + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved field `{}` from indices ({}, {}) on `{}`", + args.field, args.column_index, args.row_index, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.cell_field = value; + response.cell_ref = Some(sheet.cell_ref(address.to_a1())?); + Ok(response) + } + + fn get_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = CellRange::parse(&args.range)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved range `{}` from `{}`", args.range, sheet.name), + snapshot_for_artifact(artifact), + ); + response.range = Some(sheet.get_range_view(&range)); + response.rendered_text = Some(sheet.to_rendered_text(Some(&range))); + Ok(response) + } + + fn set_cell_value( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetCellValueArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let address = CellAddress::parse(&args.address)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let value = normalize_optional_cell_value(args.value)?; + sheet.set_value(address, value)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated cell `{}`", args.address), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.cell = Some(sheet.get_cell_view(address)); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn set_range_value( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeValueArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_range_to_value(&range, normalize_optional_cell_value(args.value)?)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated range `{}` to a single value", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_range_values( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeValuesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + let values = normalize_value_matrix(args.values, &request.action)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_values_matrix(&range, &values)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated range `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_cell_formula( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetCellFormulaArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let address = CellAddress::parse(&args.address)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_formula(address, Some(normalize_formula(args.formula)))?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated formula in `{}`", args.address), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.cell = Some(sheet.get_cell_view(address)); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn set_range_formula( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeFormulaArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_range_to_formula(&range, Some(normalize_formula(args.formula)))?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated range `{}` to a single formula", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_range_formulas( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: SetRangeFormulasArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + let formulas = args + .formulas + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| value.map(normalize_formula)) + .collect() + }) + .collect::>>>(); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.set_formulas_matrix(&range, &formulas)?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated formulas in `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn set_cell_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetCellStyleArgs = parse_args(&request.action, &request.args)?; + let range = CellRange::parse(&args.address)?; + self.set_style_impl( + request, + args.sheet_name, + args.sheet_index, + range, + args.style_index, + ) + } + + fn set_range_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetRangeStyleArgs = parse_args(&request.action, &request.args)?; + let range = CellRange::parse(&args.range)?; + self.set_style_impl( + request, + args.sheet_name, + args.sheet_index, + range, + args.style_index, + ) + } + + fn set_style_impl( + &mut self, + request: SpreadsheetArtifactRequest, + sheet_name: Option, + sheet_index: Option, + range: CellRange, + style_index: u32, + ) -> Result { + let action = request.action.clone(); + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + sheet_name.as_deref(), + sheet_index.map(|value| value as usize), + )?; + sheet.set_style_index(&range, style_index)?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Updated style index {} on `{}`", style_index, range.to_a1()), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + sheet_name.as_deref(), + sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn clear_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: ClearRangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let recalculate = args.recalculate.unwrap_or(artifact.auto_recalculate); + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.clear_range(&range, args.fields.as_deref())?; + } + if recalculate { + artifact.recalculate(); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Cleared range `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn merge_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: MergeRangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.merge_cells(&range, args.raise_on_conflict.unwrap_or(false))?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Merged `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + Ok(response) + } + + fn cite_cell( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: CiteCellArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let address = CellAddress::parse(&args.address)?; + let citation = SpreadsheetCitation { + tether_id: args.tether_id, + start_line: args.start_line, + end_line: args.end_line, + content_reference_type: args.content_reference_type, + source_type: args.source_type, + }; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let cell_ref = sheet.cell_ref(&args.address)?; + cell_ref.cite(sheet, citation)?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Attached citation to cell `{}`", args.address), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.cell = Some(sheet.get_cell_view(address)); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + Ok(response) + } + + fn unmerge_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: RangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.unmerge_cells(&range); + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Unmerged `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + Ok(response) + } + + fn cite_range( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let action = request.action.clone(); + let args: CiteRangeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let citation = SpreadsheetCitation { + tether_id: args.tether_id, + start_line: args.start_line, + end_line: args.end_line, + content_reference_type: args.content_reference_type, + source_type: args.source_type, + }; + { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + sheet.cite_range(&range, citation)?; + } + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Attached citation to `{}`", args.range), + snapshot_for_artifact(artifact), + ); + let sheet = artifact.sheet_lookup( + &action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + response.range = Some(sheet.get_range_view(&range)); + Ok(response) + } + + fn calculate( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + artifact.recalculate(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Recalculated workbook".to_string(), + snapshot_for_artifact(artifact), + ); + response.workbook_summary = Some(artifact.summary()); + Ok(response) + } + + fn serialize_dict( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Serialized workbook to dict".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(artifact.to_dict()?); + Ok(response) + } + + fn serialize_json( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Serialized workbook to JSON".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_json = Some(artifact.to_json()?); + Ok(response) + } + + fn serialize_bytes( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Serialized workbook to bytes".to_string(), + snapshot_for_artifact(artifact), + ); + response.serialized_bytes_base64 = Some(artifact.to_bytes_base64()); + Ok(response) + } + + fn deserialize_dict( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeserializeDictArgs = parse_args(&request.action, &request.args)?; + let artifact = SpreadsheetArtifact::from_dict(args.data, args.artifact_id)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deserialized workbook from dict".to_string(), + snapshot, + )) + } + + fn deserialize_json( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeserializeJsonArgs = parse_args(&request.action, &request.args)?; + let artifact = SpreadsheetArtifact::from_json(args.json, args.artifact_id)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deserialized workbook from JSON".to_string(), + snapshot, + )) + } + + fn deserialize_bytes( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeserializeBytesArgs = parse_args(&request.action, &request.args)?; + let bytes = BASE64_STANDARD.decode(args.bytes_base64).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + let artifact = SpreadsheetArtifact::from_bytes(&bytes, args.artifact_id)?; + let artifact_id = artifact.artifact_id.clone(); + let snapshot = snapshot_for_artifact(&artifact); + self.documents.insert(artifact_id.clone(), artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deserialized workbook from bytes".to_string(), + snapshot, + )) + } + + fn delete_artifact( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let artifact = self.documents.remove(&artifact_id).ok_or_else(|| { + SpreadsheetArtifactError::UnknownArtifactId { + action: request.action.clone(), + artifact_id: artifact_id.clone(), + } + })?; + let snapshot = snapshot_for_artifact(&artifact); + Ok(SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + "Deleted spreadsheet artifact".to_string(), + snapshot, + )) + } + + fn get_artifact( + &self, + artifact_id: &str, + action: &str, + ) -> Result<&SpreadsheetArtifact, SpreadsheetArtifactError> { + self.documents + .get(artifact_id) + .ok_or_else(|| SpreadsheetArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + }) + } + + fn get_artifact_mut( + &mut self, + artifact_id: &str, + action: &str, + ) -> Result<&mut SpreadsheetArtifact, SpreadsheetArtifactError> { + self.documents.get_mut(artifact_id).ok_or_else(|| { + SpreadsheetArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + } + }) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetArtifactResponse { + pub artifact_id: String, + pub action: String, + pub summary: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub exported_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workbook_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sheet_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sheet_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell_field: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rendered_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serialized_dict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serialized_json: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serialized_bytes_base64: Option, +} + +impl SpreadsheetArtifactResponse { + fn new( + artifact_id: String, + action: String, + summary: String, + artifact_snapshot: SpreadsheetArtifactSnapshot, + ) -> Self { + Self { + artifact_id, + action, + summary, + exported_paths: Vec::new(), + artifact_snapshot: Some(artifact_snapshot), + workbook_summary: None, + sheet_list: None, + sheet_ref: None, + cell_ref: None, + range_ref: None, + cell: None, + cell_field: None, + range: None, + rendered_text: None, + serialized_dict: None, + serialized_json: None, + serialized_bytes_base64: None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetSheetRef { + pub sheet_name: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetArtifactSnapshot { + pub sheet_count: usize, + pub sheet_names: Vec, + pub sheets: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpreadsheetSheetSnapshot { + pub sheet_id: String, + pub name: String, + pub filled_rows: usize, + pub filled_columns: usize, + pub minimum_range_filled: String, + pub merged_range_count: usize, +} + +#[derive(Debug, Deserialize)] +struct CreateArgs { + name: Option, + auto_recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct PathArgs { + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct SaveArgs { + path: PathBuf, + file_type: Option, +} + +#[derive(Debug, Deserialize)] +struct SheetLookupArgs { + sheet_name: Option, + sheet_index: Option, + range: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateSheetArgs { + name: String, +} + +#[derive(Debug, Deserialize)] +struct RenameSheetArgs { + old_name: Option, + sheet_index: Option, + new_name: String, +} + +#[derive(Debug, Deserialize)] +struct DeleteSheetArgs { + sheet_name: Option, + sheet_index: Option, +} + +#[derive(Debug, Deserialize)] +struct SetSheetPropertiesArgs { + sheet_name: Option, + sheet_index: Option, + default_row_height: Option, + default_column_width: Option, + show_grid_lines: Option, +} + +#[derive(Debug, Deserialize)] +struct SetColumnWidthsArgs { + sheet_name: Option, + sheet_index: Option, + reference: String, + width: f64, +} + +#[derive(Debug, Deserialize)] +struct CellAddressArgs { + sheet_name: Option, + sheet_index: Option, + address: String, +} + +#[derive(Debug, Deserialize)] +struct CellIndicesArgs { + sheet_name: Option, + sheet_index: Option, + column_index: u32, + row_index: u32, +} + +#[derive(Debug, Deserialize)] +struct CellFieldArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + field: String, +} + +#[derive(Debug, Deserialize)] +struct CellFieldByIndicesArgs { + sheet_name: Option, + sheet_index: Option, + column_index: u32, + row_index: u32, + field: String, +} + +#[derive(Debug, Deserialize)] +struct RangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, +} + +#[derive(Debug, Deserialize)] +struct SetCellValueArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + value: Value, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeValuesArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + values: Vec>, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeValueArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + value: Value, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetCellFormulaArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + formula: String, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeFormulasArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + formulas: Vec>>, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetRangeFormulaArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + formula: String, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct SetCellStyleArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + style_index: u32, +} + +#[derive(Debug, Deserialize)] +struct SetRangeStyleArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + style_index: u32, +} + +#[derive(Debug, Deserialize)] +struct ClearRangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + fields: Option>, + recalculate: Option, +} + +#[derive(Debug, Deserialize)] +struct MergeRangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + raise_on_conflict: Option, +} + +#[derive(Debug, Deserialize)] +struct CiteRangeArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + tether_id: String, + start_line: Option, + end_line: Option, + content_reference_type: Option, + source_type: Option, +} + +#[derive(Debug, Deserialize)] +struct CiteCellArgs { + sheet_name: Option, + sheet_index: Option, + address: String, + tether_id: String, + start_line: Option, + end_line: Option, + content_reference_type: Option, + source_type: Option, +} + +#[derive(Debug, Deserialize)] +struct DeserializeDictArgs { + data: Value, + artifact_id: Option, +} + +#[derive(Debug, Deserialize)] +struct DeserializeJsonArgs { + json: String, + artifact_id: Option, +} + +#[derive(Debug, Deserialize)] +struct DeserializeBytesArgs { + bytes_base64: String, + artifact_id: Option, +} + +fn snapshot_for_artifact(artifact: &SpreadsheetArtifact) -> SpreadsheetArtifactSnapshot { + SpreadsheetArtifactSnapshot { + sheet_count: artifact.sheets.len(), + sheet_names: artifact.list_sheet_names(), + sheets: artifact + .sheets + .iter() + .map(|sheet| SpreadsheetSheetSnapshot { + sheet_id: sheet.sheet_id.clone(), + name: sheet.name.clone(), + filled_rows: sheet.filled_rows(), + filled_columns: sheet.filled_columns(), + minimum_range_filled: sheet.minimum_range_filled(), + merged_range_count: sheet.merged_ranges.len(), + }) + .collect(), + } +} + +fn sheet_reference(sheet: &crate::SpreadsheetSheet) -> SpreadsheetSheetRef { + SpreadsheetSheetRef { + sheet_name: sheet.name.clone(), + } +} + +fn normalize_optional_cell_value( + value: Value, +) -> Result, SpreadsheetArtifactError> { + if value.is_null() { + Ok(None) + } else { + Ok(Some(SpreadsheetCellValue::try_from(value)?)) + } +} + +fn normalize_value_matrix( + values: Vec>, + action: &str, +) -> Result>>, SpreadsheetArtifactError> { + if values.is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "values matrix cannot be empty".to_string(), + }); + } + let width = values.first().map(Vec::len).unwrap_or(0); + if width == 0 || values.iter().any(|row| row.len() != width) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: "values matrix must be rectangular".to_string(), + }); + } + values + .into_iter() + .map(|row| { + row.into_iter() + .map(normalize_optional_cell_value) + .collect::, _>>() + }) + .collect::, _>>() +} + +fn normalize_formula(formula: String) -> String { + let trimmed = formula.trim(); + if trimmed.starts_with('=') { + trimmed.to_string() + } else { + format!("={trimmed}") + } +} + +fn required_artifact_id( + request: &SpreadsheetArtifactRequest, +) -> Result { + request + .artifact_id + .clone() + .ok_or_else(|| SpreadsheetArtifactError::MissingArtifactId { + action: request.action.clone(), + }) +} + +fn parse_args Deserialize<'de>>( + action: &str, + value: &Value, +) -> Result { + serde_json::from_value(value.clone()).map_err(|error| SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: error.to_string(), + }) +} + +fn resolve_path(cwd: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} diff --git a/codex-rs/artifact-spreadsheet/src/model.rs b/codex-rs/artifact-spreadsheet/src/model.rs new file mode 100644 index 000000000..a74402274 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/model.rs @@ -0,0 +1,1529 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifactError; +use crate::formula::recalculate_workbook; +use crate::parse_column_reference; +use crate::xlsx::import_xlsx; +use crate::xlsx::write_xlsx; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum SpreadsheetCellValue { + Bool(bool), + Integer(i64), + Float(f64), + String(String), + DateTime(String), + Error(String), +} + +impl SpreadsheetCellValue { + pub fn to_json_value(&self) -> Value { + match self { + Self::Bool(value) => Value::Bool(*value), + Self::Integer(value) => Value::Number((*value).into()), + Self::Float(value) => serde_json::Number::from_f64(*value) + .map(Value::Number) + .unwrap_or_else(|| Value::String(value.to_string())), + Self::String(value) | Self::DateTime(value) | Self::Error(value) => { + Value::String(value.clone()) + } + } + } + + pub fn as_f64(&self) -> Option { + match self { + Self::Integer(value) => Some(*value as f64), + Self::Float(value) => Some(*value), + _ => None, + } + } +} + +impl TryFrom for SpreadsheetCellValue { + type Error = SpreadsheetArtifactError; + + fn try_from(value: Value) -> Result { + match value { + Value::Bool(value) => Ok(Self::Bool(value)), + Value::Number(value) => { + if let Some(integer) = value.as_i64() { + Ok(Self::Integer(integer)) + } else if let Some(float) = value.as_f64() { + Ok(Self::Float(float)) + } else { + Err(SpreadsheetArtifactError::Serialization { + message: "unsupported JSON number".to_string(), + }) + } + } + Value::String(value) => Ok(Self::String(value)), + Value::Null => Err(SpreadsheetArtifactError::Serialization { + message: "null is represented as an empty cell, not a cell value".to_string(), + }), + other => Err(SpreadsheetArtifactError::Serialization { + message: format!("unsupported JSON cell value `{other}`"), + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCitation { + pub tether_id: String, + pub start_line: Option, + pub end_line: Option, + pub content_reference_type: Option, + pub source_type: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetCell { + pub value: Option, + pub formula: Option, + pub style_index: u32, + #[serde(default)] + pub citations: Vec, +} + +impl SpreadsheetCell { + pub fn to_dict(&self) -> Result { + serde_json::to_value(self).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) + } + + pub fn data(&self) -> Option { + if let Some(formula) = &self.formula { + return Some(Value::String(formula.clone())); + } + self.value.as_ref().map(SpreadsheetCellValue::to_json_value) + } + + pub fn is_empty(&self) -> bool { + self.value.is_none() + && self.formula.is_none() + && self.style_index == 0 + && self.citations.is_empty() + } + + pub fn is_calculation_error(&self) -> bool { + matches!( + self.value, + Some(SpreadsheetCellValue::Error(ref value)) + if matches!( + value.as_str(), + "#DIV/0!" + | "#N/A" + | "#NAME?" + | "#NUM!" + | "#REF!" + | "#VALUE!" + | "#CYCLE!" + | "#ERROR!" + | "#LIC!" + ) + ) + } + + pub fn calculation_error_message(&self) -> Option<&'static str> { + match self.value.as_ref() { + Some(SpreadsheetCellValue::Error(value)) => match value.as_str() { + "#DIV/0!" => Some("Division by zero"), + "#N/A" => Some("Value is not available"), + "#NAME?" => Some("Unknown function or name"), + "#NUM!" => Some("Invalid numeric result"), + "#REF!" => Some("Invalid cell reference"), + "#VALUE!" => Some("Invalid value type"), + "#CYCLE!" => Some("Formula cycle detected"), + "#ERROR!" => Some("Formula parse error"), + "#LIC!" => Some("Calculation engine license error"), + _ => None, + }, + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCellRef { + pub sheet_name: String, + pub address: String, +} + +impl SpreadsheetCellRef { + pub fn new(sheet_name: String, address: CellAddress) -> Self { + Self { + sheet_name, + address: address.to_a1(), + } + } + + pub fn cell_address(&self) -> Result { + CellAddress::parse(&self.address) + } + + pub fn get( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.ensure_sheet(sheet)?; + Ok(sheet.get_cell_view(self.cell_address()?)) + } + + pub fn data( + &self, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.data) + } + + pub fn raw_cell( + &self, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + Ok(sheet.get_cell(self.cell_address()?).cloned()) + } + + pub fn to_dict(&self, sheet: &SpreadsheetSheet) -> Result { + Ok(match self.raw_cell(sheet)? { + Some(cell) => cell.to_dict()?, + None => Value::Object(Default::default()), + }) + } + + pub fn set_value( + &self, + sheet: &mut SpreadsheetSheet, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_value(self.cell_address()?, value) + } + + pub fn set_formula( + &self, + sheet: &mut SpreadsheetSheet, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_formula(self.cell_address()?, formula) + } + + pub fn cite( + &self, + sheet: &mut SpreadsheetSheet, + citation: SpreadsheetCitation, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.cite_range( + &CellRange::from_start_end(self.cell_address()?, self.cell_address()?), + citation, + ) + } + + pub fn set_style_index( + &self, + sheet: &mut SpreadsheetSheet, + style_index: u32, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_style_index( + &CellRange::from_start_end(self.cell_address()?, self.cell_address()?), + style_index, + ) + } + + pub fn is_calculation_error( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + Ok(self.get(sheet)?.is_calculation_error) + } + + pub fn get_calculation_error_message( + &self, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.calculation_error_message) + } + + fn ensure_sheet(&self, sheet: &SpreadsheetSheet) -> Result<(), SpreadsheetArtifactError> { + if self.sheet_name != sheet.name { + return Err(SpreadsheetArtifactError::SheetLookup { + action: "cell_ref".to_string(), + message: format!( + "cell ref points to `{}` but sheet is `{}`", + self.sheet_name, sheet.name + ), + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCellRangeRef { + pub sheet_name: String, + pub address: String, +} + +impl SpreadsheetCellRangeRef { + pub fn new(sheet_name: String, range: &CellRange) -> Self { + Self { + sheet_name, + address: range.to_a1(), + } + } + + pub fn range(&self) -> Result { + CellRange::parse(&self.address) + } + + pub fn get( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.ensure_sheet(sheet)?; + Ok(sheet.get_range_view(&self.range()?)) + } + + pub fn get_values( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.values) + } + + pub fn get_formulas( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.formulas) + } + + pub fn get_style_indices( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.style_indices) + } + + pub fn get_data( + &self, + sheet: &SpreadsheetSheet, + ) -> Result>>, SpreadsheetArtifactError> { + Ok(self.get(sheet)?.data) + } + + pub fn set_value( + &self, + sheet: &mut SpreadsheetSheet, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_range_to_value(&self.range()?, value) + } + + pub fn set_values( + &self, + sheet: &mut SpreadsheetSheet, + values: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_values_matrix(&self.range()?, values) + } + + pub fn set_formula( + &self, + sheet: &mut SpreadsheetSheet, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_range_to_formula(&self.range()?, formula) + } + + pub fn set_formulas( + &self, + sheet: &mut SpreadsheetSheet, + formulas: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_formulas_matrix(&self.range()?, formulas) + } + + pub fn set_style_index( + &self, + sheet: &mut SpreadsheetSheet, + style_index: u32, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.set_style_index(&self.range()?, style_index) + } + + pub fn merge( + &self, + sheet: &mut SpreadsheetSheet, + raise_on_conflict: bool, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.merge_cells(&self.range()?, raise_on_conflict) + } + + pub fn unmerge(&self, sheet: &mut SpreadsheetSheet) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.unmerge_cells(&self.range()?); + Ok(()) + } + + pub fn cite( + &self, + sheet: &mut SpreadsheetSheet, + citation: SpreadsheetCitation, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_sheet(sheet)?; + sheet.cite_range(&self.range()?, citation) + } + + fn ensure_sheet(&self, sheet: &SpreadsheetSheet) -> Result<(), SpreadsheetArtifactError> { + if self.sheet_name != sheet.name { + return Err(SpreadsheetArtifactError::SheetLookup { + action: "range_ref".to_string(), + message: format!( + "range ref points to `{}` but sheet is `{}`", + self.sheet_name, sheet.name + ), + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetSheetSummary { + pub name: String, + pub filled_rows: usize, + pub filled_columns: usize, + pub minimum_range_filled: String, + pub min_row_idx: Option, + pub max_row_idx: Option, + pub min_column_idx: Option, + pub max_column_idx: Option, + pub min_column_letter: Option, + pub max_column_letter: Option, + pub first_row_address_range: Option, + pub default_row_height: Option, + pub default_column_width: Option, + pub show_grid_lines: bool, + pub merged_range_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetSummary { + pub artifact_id: String, + pub sheets: Vec, + pub size_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetCellView { + pub sheet_name: String, + pub address: String, + pub effective_address: String, + pub exists: bool, + pub value: Option, + pub formula: Option, + pub style_index: u32, + pub data: Option, + pub is_calculation_error: bool, + pub calculation_error_message: Option, + pub citations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetRangeView { + pub sheet_name: String, + pub address: String, + pub values: Vec>>, + pub formulas: Vec>>, + pub style_indices: Vec>, + pub data: Vec>>, + pub is_single_cell: bool, + pub is_single_row: bool, + pub is_single_column: bool, + pub contains_merged_cells: bool, + pub is_exactly_one_merged_cell: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetSheet { + pub sheet_id: String, + pub name: String, + #[serde(default, with = "cell_map_serde")] + pub cells: BTreeMap, + #[serde(default)] + pub merged_ranges: Vec, + pub default_row_height: Option, + pub default_column_width: Option, + pub show_grid_lines: bool, + #[serde(default)] + pub column_widths: BTreeMap, + #[serde(default)] + pub row_heights: BTreeMap, +} + +mod cell_map_serde { + use std::collections::BTreeMap; + + use serde::Deserialize; + use serde::Deserializer; + use serde::Serialize; + use serde::Serializer; + + use crate::CellAddress; + use crate::SpreadsheetCell; + #[derive(Serialize, Deserialize)] + struct CellEntry { + address: String, + cell: SpreadsheetCell, + } + + pub fn serialize( + cells: &BTreeMap, + serializer: S, + ) -> Result + where + S: Serializer, + { + let entries = cells + .iter() + .map(|(address, cell)| CellEntry { + address: address.to_a1(), + cell: cell.clone(), + }) + .collect::>(); + entries.serialize(serializer) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let entries = Vec::::deserialize(deserializer)?; + let mut cells = BTreeMap::new(); + for entry in entries { + let address = CellAddress::parse(&entry.address).map_err(serde::de::Error::custom)?; + cells.insert(address, entry.cell); + } + Ok(cells) + } +} + +impl SpreadsheetSheet { + pub fn new(name: String) -> Self { + Self { + sheet_id: format!("sheet_{}", Uuid::new_v4().simple()), + name, + cells: BTreeMap::new(), + merged_ranges: Vec::new(), + default_row_height: None, + default_column_width: None, + show_grid_lines: true, + column_widths: BTreeMap::new(), + row_heights: BTreeMap::new(), + } + } + + pub fn filled_rows(&self) -> usize { + self.cells + .keys() + .map(|address| address.row) + .collect::>() + .len() + } + + pub fn filled_columns(&self) -> usize { + self.cells + .keys() + .map(|address| address.column) + .collect::>() + .len() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + pub fn minimum_range(&self) -> Option { + let mut iter = self.cells.keys(); + let first = *iter.next()?; + let mut min_row = first.row; + let mut max_row = first.row; + let mut min_column = first.column; + let mut max_column = first.column; + + for address in iter { + min_row = min_row.min(address.row); + max_row = max_row.max(address.row); + min_column = min_column.min(address.column); + max_column = max_column.max(address.column); + } + + Some(CellRange::from_start_end( + CellAddress { + column: min_column, + row: min_row, + }, + CellAddress { + column: max_column, + row: max_row, + }, + )) + } + + pub fn minimum_range_ref(&self) -> Option { + self.minimum_range() + .map(|range| SpreadsheetCellRangeRef::new(self.name.clone(), &range)) + } + + pub fn minimum_range_filled(&self) -> String { + self.minimum_range() + .map(|range| range.to_a1()) + .unwrap_or_default() + } + + pub fn min_column_letter(&self) -> Option { + self.minimum_range() + .map(|range| crate::column_index_to_letters(range.start.column)) + } + + pub fn max_column_letter(&self) -> Option { + self.minimum_range() + .map(|range| crate::column_index_to_letters(range.end.column)) + } + + pub fn first_row_address_range(&self) -> Option { + self.minimum_range().map(|range| { + CellRange::from_start_end( + CellAddress { + column: range.start.column, + row: range.start.row, + }, + CellAddress { + column: range.end.column, + row: range.start.row, + }, + ) + .to_a1() + }) + } + + pub fn summary(&self) -> SpreadsheetSheetSummary { + let minimum = self.minimum_range(); + SpreadsheetSheetSummary { + name: self.name.clone(), + filled_rows: self.filled_rows(), + filled_columns: self.filled_columns(), + minimum_range_filled: self.minimum_range_filled(), + min_row_idx: minimum.as_ref().map(|range| range.start.row), + max_row_idx: minimum.as_ref().map(|range| range.end.row), + min_column_idx: minimum.as_ref().map(|range| range.start.column), + max_column_idx: minimum.as_ref().map(|range| range.end.column), + min_column_letter: self.min_column_letter(), + max_column_letter: self.max_column_letter(), + first_row_address_range: self.first_row_address_range(), + default_row_height: self.default_row_height, + default_column_width: self.default_column_width, + show_grid_lines: self.show_grid_lines, + merged_range_count: self.merged_ranges.len(), + } + } + + pub fn to_dict(&self) -> Result { + serde_json::to_value(self).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) + } + + pub fn cell_ref( + &self, + address: impl AsRef, + ) -> Result { + Ok(SpreadsheetCellRef::new( + self.name.clone(), + CellAddress::parse(address.as_ref())?, + )) + } + + pub fn range_ref( + &self, + address: impl AsRef, + ) -> Result { + let range = CellRange::parse(address.as_ref())?; + Ok(SpreadsheetCellRangeRef::new(self.name.clone(), &range)) + } + + pub fn set_column_widths( + &mut self, + reference: &str, + width: f64, + ) -> Result<(), SpreadsheetArtifactError> { + let (start, end) = parse_column_reference(reference)?; + for column in start..=end { + self.column_widths.insert(column, width); + } + Ok(()) + } + + pub fn get_column_width( + &self, + reference: &str, + ) -> Result, SpreadsheetArtifactError> { + let (start, end) = parse_column_reference(reference)?; + if start != end { + return Ok((start..=end) + .find_map(|column| self.column_widths.get(&column).copied()) + .or(self.default_column_width)); + } + Ok(self + .column_widths + .get(&start) + .copied() + .or(self.default_column_width)) + } + + pub fn cell_exists(&self, address: CellAddress) -> bool { + self.cells.contains_key(&address) + } + + pub fn merged_range_for(&self, address: CellAddress) -> Option<&CellRange> { + self.merged_ranges + .iter() + .find(|range| range.contains(address)) + } + + pub fn effective_address(&self, address: CellAddress) -> CellAddress { + self.merged_range_for(address) + .map(|range| range.start) + .unwrap_or(address) + } + + pub fn get_cell(&self, address: CellAddress) -> Option<&SpreadsheetCell> { + let effective = self.effective_address(address); + self.cells.get(&effective) + } + + pub fn get_cell_by_indices(&self, column: u32, row: u32) -> Option<&SpreadsheetCell> { + self.get_cell(CellAddress { column, row }) + } + + pub fn get_cell_mut(&mut self, address: CellAddress) -> Option<&mut SpreadsheetCell> { + let effective = self.effective_address(address); + self.cells.get_mut(&effective) + } + + pub fn get_or_create_cell_mut(&mut self, address: CellAddress) -> &mut SpreadsheetCell { + let effective = self.effective_address(address); + self.cells.entry(effective).or_insert(SpreadsheetCell { + value: None, + formula: None, + style_index: 0, + citations: Vec::new(), + }) + } + + pub fn clear_range( + &mut self, + range: &CellRange, + fields: Option<&[String]>, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "clear_range")?; + for address in range.addresses() { + if let Some(cell) = self.get_cell_mut(address) { + match fields { + Some(fields) => { + for field in fields { + match field.as_str() { + "value" => cell.value = None, + "formula" => cell.formula = None, + "style_index" => cell.style_index = 0, + "citations" => cell.citations.clear(), + other => { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "clear_range".to_string(), + message: format!("unsupported cell field `{other}`"), + }); + } + } + } + } + None => { + cell.value = None; + cell.formula = None; + cell.style_index = 0; + cell.citations.clear(); + } + } + } + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_value( + &mut self, + address: CellAddress, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::from_start_end(address, address); + self.ensure_range_write_allowed(&range, false, "set_value")?; + let cell = self.get_or_create_cell_mut(address); + cell.formula = None; + cell.value = value; + if cell.is_empty() { + self.cells.remove(&address); + } + Ok(()) + } + + pub fn set_range_to_value( + &mut self, + range: &CellRange, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_range_to_value")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.formula = None; + cell.value = value.clone(); + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_formula( + &mut self, + address: CellAddress, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::from_start_end(address, address); + self.ensure_range_write_allowed(&range, false, "set_formula")?; + let cell = self.get_or_create_cell_mut(address); + cell.formula = formula; + if cell.formula.is_none() { + cell.value = None; + } + if cell.is_empty() { + self.cells.remove(&address); + } + Ok(()) + } + + pub fn set_range_to_formula( + &mut self, + range: &CellRange, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_range_to_formula")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.formula = formula.clone(); + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_cell_values_to( + &mut self, + address: &str, + value: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::parse(address)?; + self.set_range_to_value(&range, value) + } + + pub fn set_cell_formulas_to( + &mut self, + address: &str, + formula: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let range = CellRange::parse(address)?; + self.set_range_to_formula(&range, formula) + } + + pub fn set_style_index( + &mut self, + range: &CellRange, + style_index: u32, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_style_index")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.style_index = style_index; + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn cite_range( + &mut self, + range: &CellRange, + citation: SpreadsheetCitation, + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, true, "cite_range")?; + for address in range.addresses() { + let cell = self.get_or_create_cell_mut(address); + cell.citations.push(citation.clone()); + } + Ok(()) + } + + pub fn set_values_matrix( + &mut self, + range: &CellRange, + values: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_values_matrix")?; + if values.len() != range.height() || values.iter().any(|row| row.len() != range.width()) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_range_values".to_string(), + message: format!( + "matrix dimensions {}x{} do not match range {}x{}", + values.len(), + values.first().map(Vec::len).unwrap_or(0), + range.height(), + range.width() + ), + }); + } + + for (row_offset, row) in values.iter().enumerate() { + for (column_offset, value) in row.iter().enumerate() { + let address = CellAddress { + column: range.start.column + column_offset as u32, + row: range.start.row + row_offset as u32, + }; + let cell = self.get_or_create_cell_mut(address); + cell.formula = None; + cell.value = value.clone(); + } + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn set_formulas_matrix( + &mut self, + range: &CellRange, + formulas: &[Vec>], + ) -> Result<(), SpreadsheetArtifactError> { + self.ensure_range_write_allowed(range, false, "set_formulas_matrix")?; + if formulas.len() != range.height() || formulas.iter().any(|row| row.len() != range.width()) + { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "set_range_formulas".to_string(), + message: format!( + "matrix dimensions {}x{} do not match range {}x{}", + formulas.len(), + formulas.first().map(Vec::len).unwrap_or(0), + range.height(), + range.width() + ), + }); + } + + for (row_offset, row) in formulas.iter().enumerate() { + for (column_offset, formula) in row.iter().enumerate() { + let address = CellAddress { + column: range.start.column + column_offset as u32, + row: range.start.row + row_offset as u32, + }; + let cell = self.get_or_create_cell_mut(address); + cell.formula = formula.clone(); + } + } + self.cells.retain(|_, cell| !cell.is_empty()); + Ok(()) + } + + pub fn merge_cells( + &mut self, + range: &CellRange, + raise_on_conflict: bool, + ) -> Result<(), SpreadsheetArtifactError> { + for existing in &self.merged_ranges { + if existing.intersects(range) && existing != range { + if raise_on_conflict { + return Err(SpreadsheetArtifactError::MergeConflict { + action: "merge_range".to_string(), + range: range.to_a1(), + conflict: existing.to_a1(), + }); + } + return Ok(()); + } + } + self.merged_ranges.push(range.clone()); + self.merged_ranges + .sort_by_key(|entry| (entry.start.row, entry.start.column)); + Ok(()) + } + + pub fn unmerge_cells(&mut self, range: &CellRange) { + self.merged_ranges.retain(|entry| entry != range); + } + + pub fn contains_merged_cells(&self, range: &CellRange) -> bool { + self.merged_ranges + .iter() + .any(|entry| entry.intersects(range)) + } + + pub fn is_exactly_one_merged_cell(&self, range: &CellRange) -> bool { + self.merged_ranges.iter().any(|entry| entry == range) + } + + pub fn get_cell_view(&self, address: CellAddress) -> SpreadsheetCellView { + let effective = self.effective_address(address); + let cell = self.get_cell(address); + SpreadsheetCellView { + sheet_name: self.name.clone(), + address: address.to_a1(), + effective_address: effective.to_a1(), + exists: cell.is_some(), + value: cell.and_then(|entry| entry.value.clone()), + formula: cell.and_then(|entry| entry.formula.clone()), + style_index: cell.map(|entry| entry.style_index).unwrap_or(0), + data: cell.and_then(SpreadsheetCell::data), + is_calculation_error: cell + .map(SpreadsheetCell::is_calculation_error) + .unwrap_or(false), + calculation_error_message: cell + .and_then(SpreadsheetCell::calculation_error_message) + .map(str::to_string), + citations: cell + .map(|entry| entry.citations.clone()) + .unwrap_or_default(), + } + } + + pub fn get_cell_view_by_indices(&self, column: u32, row: u32) -> SpreadsheetCellView { + self.get_cell_view(CellAddress { column, row }) + } + + pub fn get_cell_field( + &self, + address: CellAddress, + field: &str, + ) -> Result, SpreadsheetArtifactError> { + let cell = self.get_cell(address); + Ok(match field { + "value" => cell.and_then(|entry| { + entry + .value + .as_ref() + .map(SpreadsheetCellValue::to_json_value) + }), + "formula" => cell.and_then(|entry| entry.formula.clone().map(Value::String)), + "style_index" => cell.map(|entry| Value::Number(entry.style_index.into())), + "data" => cell.and_then(SpreadsheetCell::data), + "citations" => cell + .map(|entry| serde_json::to_value(&entry.citations)) + .transpose() + .map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + })?, + other => { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "get_cell_field".to_string(), + message: format!("unsupported field `{other}`"), + }); + } + }) + } + + pub fn get_cell_field_by_indices( + &self, + column: u32, + row: u32, + field: &str, + ) -> Result, SpreadsheetArtifactError> { + self.get_cell_field(CellAddress { column, row }, field) + } + + pub fn get_raw_cell(&self, address: CellAddress) -> Option { + self.get_cell(address).cloned() + } + + pub fn get_range_view(&self, range: &CellRange) -> SpreadsheetRangeView { + let mut values = Vec::new(); + let mut formulas = Vec::new(); + let mut style_indices = Vec::new(); + let mut data = Vec::new(); + + for row in range.start.row..=range.end.row { + let mut value_row = Vec::new(); + let mut formula_row = Vec::new(); + let mut style_row = Vec::new(); + let mut data_row = Vec::new(); + for column in range.start.column..=range.end.column { + let cell = self.get_cell(CellAddress { column, row }); + value_row.push(cell.and_then(|entry| entry.value.clone())); + formula_row.push(cell.and_then(|entry| entry.formula.clone())); + style_row.push(cell.map(|entry| entry.style_index).unwrap_or(0)); + data_row.push(cell.and_then(SpreadsheetCell::data)); + } + values.push(value_row); + formulas.push(formula_row); + style_indices.push(style_row); + data.push(data_row); + } + + SpreadsheetRangeView { + sheet_name: self.name.clone(), + address: range.to_a1(), + values, + formulas, + style_indices, + data, + is_single_cell: range.is_single_cell(), + is_single_row: range.is_single_row(), + is_single_column: range.is_single_column(), + contains_merged_cells: self.contains_merged_cells(range), + is_exactly_one_merged_cell: self.is_exactly_one_merged_cell(range), + } + } + + pub fn to_rendered_text(&self, range: Option<&CellRange>) -> String { + let target = range + .cloned() + .or_else(|| self.minimum_range()) + .unwrap_or_else(|| { + CellRange::from_start_end( + CellAddress { column: 1, row: 1 }, + CellAddress { column: 1, row: 1 }, + ) + }); + let mut lines = Vec::new(); + for row in target.start.row..=target.end.row { + let mut entries = Vec::new(); + for column in target.start.column..=target.end.column { + let view = self.get_cell_view(CellAddress { column, row }); + let text = match view.data { + Some(Value::String(value)) => value, + Some(Value::Bool(value)) => value.to_string(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Null) | None => String::new(), + Some(other) => other.to_string(), + }; + entries.push(text); + } + lines.push(entries.join("\t")); + } + lines.join("\n") + } + + fn ensure_range_write_allowed( + &self, + range: &CellRange, + allow_exact_merged_cell: bool, + action: &str, + ) -> Result<(), SpreadsheetArtifactError> { + for merged in &self.merged_ranges { + if !merged.intersects(range) { + continue; + } + if allow_exact_merged_cell && merged == range { + continue; + } + if merged.contains_range(range) || range.contains_range(merged) { + if allow_exact_merged_cell || merged != range { + return Err(SpreadsheetArtifactError::MergeConflict { + action: action.to_string(), + range: range.to_a1(), + conflict: merged.to_a1(), + }); + } + } else { + return Err(SpreadsheetArtifactError::MergeConflict { + action: action.to_string(), + range: range.to_a1(), + conflict: merged.to_a1(), + }); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetArtifact { + pub artifact_id: String, + pub name: Option, + #[serde(default)] + pub sheets: Vec, + pub auto_recalculate: bool, +} + +impl SpreadsheetArtifact { + pub fn new(name: Option) -> Self { + Self { + artifact_id: format!("spreadsheet_{}", Uuid::new_v4().simple()), + name, + sheets: Vec::new(), + auto_recalculate: false, + } + } + + pub fn allowed_file_extensions() -> &'static [&'static str] { + &["xlsx", "json", "bin"] + } + + pub fn create_sheet( + &mut self, + name: String, + ) -> Result<&mut SpreadsheetSheet, SpreadsheetArtifactError> { + if name.trim().is_empty() { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "create_sheet".to_string(), + message: "sheet name cannot be empty".to_string(), + }); + } + if self.sheets.iter().any(|sheet| sheet.name == name) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "create_sheet".to_string(), + message: format!("sheet `{name}` already exists"), + }); + } + self.sheets.push(SpreadsheetSheet::new(name)); + self.sheets + .last_mut() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: "created sheet was not available".to_string(), + }) + } + + pub fn get_sheet(&self, name: Option<&str>, index: Option) -> Option<&SpreadsheetSheet> { + if let Some(name) = name { + self.sheets.iter().find(|sheet| sheet.name == name) + } else if let Some(index) = index { + self.sheets.get(index) + } else { + None + } + } + + pub fn get_sheet_mut( + &mut self, + name: Option<&str>, + index: Option, + ) -> Option<&mut SpreadsheetSheet> { + if let Some(name) = name { + self.sheets.iter_mut().find(|sheet| sheet.name == name) + } else if let Some(index) = index { + self.sheets.get_mut(index) + } else { + None + } + } + + pub fn sheet_lookup( + &self, + action: &str, + name: Option<&str>, + index: Option, + ) -> Result<&SpreadsheetSheet, SpreadsheetArtifactError> { + self.get_sheet(name, index) + .ok_or_else(|| SpreadsheetArtifactError::SheetLookup { + action: action.to_string(), + message: match (name, index) { + (Some(name), _) => format!("sheet `{name}` was not found"), + (None, Some(index)) => format!("sheet index {index} was not found"), + (None, None) => "sheet name or index is required".to_string(), + }, + }) + } + + pub fn sheet_lookup_mut( + &mut self, + action: &str, + name: Option<&str>, + index: Option, + ) -> Result<&mut SpreadsheetSheet, SpreadsheetArtifactError> { + self.get_sheet_mut(name, index) + .ok_or_else(|| SpreadsheetArtifactError::SheetLookup { + action: action.to_string(), + message: match (name, index) { + (Some(name), _) => format!("sheet `{name}` was not found"), + (None, Some(index)) => format!("sheet index {index} was not found"), + (None, None) => "sheet name or index is required".to_string(), + }, + }) + } + + pub fn rename_sheet( + &mut self, + new_name: String, + old_name: Option<&str>, + index: Option, + ) -> Result<(), SpreadsheetArtifactError> { + if self.sheets.iter().any(|sheet| sheet.name == new_name) { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "rename_sheet".to_string(), + message: format!("sheet `{new_name}` already exists"), + }); + } + let sheet = self.sheet_lookup_mut("rename_sheet", old_name, index)?; + sheet.name = new_name; + Ok(()) + } + + pub fn delete_sheet( + &mut self, + name: Option<&str>, + index: Option, + ) -> Result<(), SpreadsheetArtifactError> { + let remove_index = if let Some(name) = name { + self.sheets + .iter() + .position(|sheet| sheet.name == name) + .ok_or_else(|| SpreadsheetArtifactError::SheetLookup { + action: "delete_sheet".to_string(), + message: format!("sheet `{name}` was not found"), + })? + } else if let Some(index) = index { + if index >= self.sheets.len() { + return Err(SpreadsheetArtifactError::IndexOutOfRange { + action: "delete_sheet".to_string(), + index, + len: self.sheets.len(), + }); + } + index + } else { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: "delete_sheet".to_string(), + message: "sheet name or index is required".to_string(), + }); + }; + self.sheets.remove(remove_index); + Ok(()) + } + + pub fn list_sheet_names(&self) -> Vec { + self.sheets.iter().map(|sheet| sheet.name.clone()).collect() + } + + pub fn summary(&self) -> SpreadsheetSummary { + SpreadsheetSummary { + artifact_id: self.artifact_id.clone(), + sheets: self.sheets.iter().map(SpreadsheetSheet::summary).collect(), + size_bytes: self.to_bytes().len(), + } + } + + pub fn calculate(&mut self) { + recalculate_workbook(self); + } + + pub fn recalculate(&mut self) { + self.calculate(); + } + + pub fn to_dict(&self) -> Result { + serde_json::to_value(self).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) + } + + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + }) + } + + pub fn to_bytes(&self) -> Vec { + self.to_json() + .unwrap_or_else(|_| "{}".to_string()) + .into_bytes() + } + + pub fn to_bytes_base64(&self) -> String { + BASE64_STANDARD.encode(self.to_bytes()) + } + + pub fn from_dict( + data: Value, + artifact_id: Option, + ) -> Result { + let mut artifact: Self = serde_json::from_value(data).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + if let Some(artifact_id) = artifact_id { + artifact.artifact_id = artifact_id; + } else if artifact.artifact_id.is_empty() { + artifact.artifact_id = format!("spreadsheet_{}", Uuid::new_v4().simple()); + } + Ok(artifact) + } + + pub fn from_json( + json: impl AsRef<[u8]>, + artifact_id: Option, + ) -> Result { + let text = std::str::from_utf8(json.as_ref()).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + Self::from_dict( + serde_json::from_str(text).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?, + artifact_id, + ) + } + + pub fn from_bytes( + bytes: &[u8], + artifact_id: Option, + ) -> Result { + Self::from_json(bytes, artifact_id) + } + + pub fn from_source_file( + path: &Path, + artifact_id: Option, + ) -> Result { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "xlsx" => import_xlsx(path, artifact_id), + "json" => { + let bytes = std::fs::read(path).map_err(|error| { + SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Self::from_json(bytes, artifact_id) + } + "bin" => { + let bytes = std::fs::read(path).map_err(|error| { + SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Self::from_bytes(&bytes, artifact_id) + } + other => Err(SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: format!("unsupported import file type `{other}`"), + }), + } + } + + pub fn read( + path: &Path, + artifact_id: Option, + ) -> Result { + Self::from_source_file(path, artifact_id) + } + + pub fn load( + path: &Path, + artifact_id: Option, + ) -> Result { + Self::from_source_file(path, artifact_id) + } + + pub fn save( + &mut self, + path: &Path, + file_type: Option<&str>, + ) -> Result { + self.to_source_file(path, file_type) + } + + pub fn export(&mut self, path: &Path) -> Result { + self.to_source_file(path, Some("xlsx")) + } + + pub fn to_source_file( + &mut self, + path: &Path, + file_type: Option<&str>, + ) -> Result { + let selected = file_type + .map(str::to_string) + .or_else(|| { + path.extension() + .and_then(|value| value.to_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "xlsx".to_string()) + .to_ascii_lowercase(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + } + + match selected.as_str() { + "xlsx" => write_xlsx(self, path), + "json" => { + let json = self.to_json()?; + std::fs::write(path, json).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Ok(path.to_path_buf()) + } + "bin" => { + std::fs::write(path, self.to_bytes()).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + Ok(path.to_path_buf()) + } + other => Err(SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: format!("unsupported export file type `{other}`"), + }), + } + } +} diff --git a/codex-rs/artifact-spreadsheet/src/tests.rs b/codex-rs/artifact-spreadsheet/src/tests.rs new file mode 100644 index 000000000..8e7ce82fd --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/tests.rs @@ -0,0 +1,302 @@ +use pretty_assertions::assert_eq; + +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactManager; +use crate::SpreadsheetArtifactRequest; +use crate::SpreadsheetCellValue; + +#[test] +fn manager_can_create_edit_recalculate_and_export() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Budget" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_range_values".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A1:B2", + "values": [[1, 2], [3, 4]] + }), + }, + temp_dir.path(), + )?; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_cell_formula".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1", + "formula": "=SUM(A1:B2)", + "recalculate": true + }), + }, + temp_dir.path(), + )?; + + let cell = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + cell.cell.and_then(|entry| entry.value), + Some(SpreadsheetCellValue::Integer(10)) + ); + + let export_path = temp_dir.path().join("budget.xlsx"); + let export = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_xlsx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + assert_eq!(export.exported_paths.len(), 1); + assert!(export.exported_paths[0].exists()); + Ok(()) +} + +#[test] +fn spreadsheet_serialization_roundtrip_preserves_cells() -> Result<(), Box> { + let mut artifact = SpreadsheetArtifact::new(Some("Roundtrip".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + crate::CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("hello".to_string())), + )?; + sheet.set_formula(crate::CellAddress::parse("B1")?, Some("=A1".to_string()))?; + artifact.recalculate(); + + let json = artifact.to_json()?; + let restored = SpreadsheetArtifact::from_json(json, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + let cell = restored_sheet.get_cell_view(crate::CellAddress::parse("A1")?); + assert_eq!( + cell.value, + Some(SpreadsheetCellValue::String("hello".to_string())) + ); + Ok(()) +} + +#[test] +fn xlsx_roundtrip_preserves_merged_ranges_and_style_indices() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("styled.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Styled".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + crate::CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Integer(42)), + )?; + sheet.set_style_index(&crate::CellRange::parse("A1:B1")?, 3)?; + sheet.merge_cells(&crate::CellRange::parse("A1:B1")?, true)?; + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.merged_ranges.len(), 1); + assert_eq!( + restored_sheet + .get_cell_view(crate::CellAddress::parse("A1")?) + .style_index, + 3 + ); + Ok(()) +} + +#[test] +fn path_accesses_cover_import_and_export() -> Result<(), Box> { + let cwd = tempfile::tempdir()?; + let request = crate::SpreadsheetArtifactRequest { + artifact_id: Some("spreadsheet_1".to_string()), + action: "export_xlsx".to_string(), + args: serde_json::json!({ "path": "out/report.xlsx" }), + }; + let accesses = request.required_path_accesses(cwd.path())?; + assert_eq!(accesses.len(), 1); + assert!(accesses[0].path.ends_with("out/report.xlsx")); + Ok(()) +} + +#[test] +fn sheet_refs_support_handle_and_field_apis() -> Result<(), Box> { + let mut artifact = SpreadsheetArtifact::new(Some("Handles".to_string())); + let (range_ref, cell_ref) = { + let sheet = artifact.create_sheet("Sheet1".to_string())?; + let range_ref = sheet.range_ref("A1:B2")?; + range_ref.set_value(sheet, Some(SpreadsheetCellValue::Integer(7)))?; + let cell_ref = sheet.cell_ref("B2")?; + cell_ref.set_formula(sheet, Some("=SUM(A1:B2)".to_string()))?; + (range_ref, cell_ref) + }; + artifact.recalculate(); + let sheet = artifact.get_sheet(Some("Sheet1"), None).expect("sheet"); + + let values = range_ref.get_values(sheet)?; + assert_eq!(values[0][0], Some(SpreadsheetCellValue::Integer(7))); + assert_eq!( + cell_ref.get(sheet)?.value, + Some(SpreadsheetCellValue::Integer(28)) + ); + assert_eq!( + sheet.get_cell_field_by_indices(2, 2, "formula")?, + Some(serde_json::Value::String("=SUM(A1:B2)".to_string())) + ); + assert_eq!( + sheet.minimum_range_ref().map(|entry| entry.address), + Some("A1:B2".to_string()) + ); + assert!(matches!( + sheet.to_dict()?, + serde_json::Value::Object(_) | serde_json::Value::Array(_) + )); + Ok(()) +} + +#[test] +fn manager_supports_single_value_formula_and_cite_cell_actions() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Actions" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + + let uniform = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_range_value".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A1:B2", + "value": 5 + }), + }, + temp_dir.path(), + )?; + assert_eq!( + uniform + .range_ref + .as_ref() + .map(|entry| entry.address.clone()), + Some("A1:B2".to_string()) + ); + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_range_formula".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "C1:C2", + "formula": "=SUM(A1:B2)", + "recalculate": true + }), + }, + temp_dir.path(), + )?; + + let cited = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "cite_cell".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1", + "tether_id": "source-1", + "start_line": 3, + "end_line": 8 + }), + }, + temp_dir.path(), + )?; + assert_eq!( + cited.cell.as_ref().map(|entry| entry.citations.len()), + Some(1) + ); + + let by_indices = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell_by_indices".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "column_index": 3, + "row_index": 1 + }), + }, + temp_dir.path(), + )?; + assert_eq!( + by_indices + .cell + .as_ref() + .and_then(|entry| entry.value.clone()), + Some(SpreadsheetCellValue::Integer(20)) + ); + + let field = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "get_cell_field".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "C1", + "field": "formula" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + field.cell_field, + Some(serde_json::Value::String("=SUM(A1:B2)".to_string())) + ); + Ok(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/xlsx.rs b/codex-rs/artifact-spreadsheet/src/xlsx.rs new file mode 100644 index 000000000..dc49a61d3 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/xlsx.rs @@ -0,0 +1,817 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use regex::Regex; +use zip::ZipArchive; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +use crate::CellAddress; +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCell; +use crate::SpreadsheetCellValue; +use crate::SpreadsheetSheet; + +pub(crate) fn write_xlsx( + artifact: &mut SpreadsheetArtifact, + path: &Path, +) -> Result { + if artifact.auto_recalculate { + artifact.recalculate(); + } + + let file = File::create(path).map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + + let sheet_count = artifact.sheets.len().max(1); + zip.start_file("[Content_Types].xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(content_types_xml(sheet_count).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("_rels/", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.start_file("_rels/.rels", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.write_all(root_relationships_xml().as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("docProps/", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.start_file("docProps/app.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(app_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.start_file("docProps/core.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(core_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("xl/", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.start_file("xl/workbook.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(workbook_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("xl/_rels/", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.start_file("xl/_rels/workbook.xml.rels", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(workbook_relationships_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.start_file("xl/styles.xml", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.write_all(styles_xml(artifact).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + zip.add_directory("xl/worksheets/", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + if artifact.sheets.is_empty() { + let empty = SpreadsheetSheet::new("Sheet1".to_string()); + zip.start_file("xl/worksheets/sheet1.xml", options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(sheet_xml(&empty).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + } else { + for (index, sheet) in artifact.sheets.iter().enumerate() { + zip.start_file(format!("xl/worksheets/sheet{}.xml", index + 1), options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(sheet_xml(sheet).as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + } + } + + zip.finish() + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + Ok(path.to_path_buf()) +} + +pub(crate) fn import_xlsx( + path: &Path, + artifact_id: Option, +) -> Result { + let file = File::open(path).map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut archive = + ZipArchive::new(file).map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + + let workbook_xml = read_zip_entry(&mut archive, "xl/workbook.xml", path)?; + let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels", path)?; + let shared_strings = if archive.by_name("xl/sharedStrings.xml").is_ok() { + Some(parse_shared_strings(&read_zip_entry( + &mut archive, + "xl/sharedStrings.xml", + path, + )?)?) + } else { + None + }; + + let relationships = parse_relationships(&workbook_rels)?; + let sheets = parse_sheet_definitions(&workbook_xml)? + .into_iter() + .map(|(name, relation)| { + let target = relationships.get(&relation).ok_or_else(|| { + SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: format!("missing relationship `{relation}` for sheet `{name}`"), + } + })?; + let normalized = if target.starts_with('/') { + target.trim_start_matches('/').to_string() + } else if target.starts_with("xl/") { + target.clone() + } else { + format!("xl/{target}") + }; + Ok((name, normalized)) + }) + .collect::, SpreadsheetArtifactError>>()?; + + let mut artifact = SpreadsheetArtifact::new( + path.file_stem() + .and_then(|value| value.to_str()) + .map(str::to_string), + ); + if let Some(artifact_id) = artifact_id { + artifact.artifact_id = artifact_id; + } + artifact.sheets.clear(); + + for (name, target) in sheets { + let xml = read_zip_entry(&mut archive, &target, path)?; + let sheet = parse_sheet(&name, &xml, shared_strings.as_deref())?; + artifact.sheets.push(sheet); + } + + Ok(artifact) +} + +fn read_zip_entry( + archive: &mut ZipArchive, + entry: &str, + path: &Path, +) -> Result { + let mut file = + archive + .by_name(entry) + .map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut text = String::new(); + file.read_to_string(&mut text) + .map_err(|error| SpreadsheetArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + Ok(text) +} + +fn parse_sheet_definitions( + workbook_xml: &str, +) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + let mut sheets = Vec::new(); + for captures in regex.captures_iter(workbook_xml) { + let Some(attributes) = captures.get(1).map(|value| value.as_str()) else { + continue; + }; + let Some(name) = extract_attribute(attributes, "name") else { + continue; + }; + let relation = extract_attribute(attributes, "r:id") + .or_else(|| extract_attribute(attributes, "id")) + .unwrap_or_default(); + sheets.push((xml_unescape(&name), relation)); + } + Ok(sheets) +} + +fn parse_relationships(xml: &str) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + Ok(regex + .captures_iter(xml) + .filter_map(|captures| { + let attributes = captures.get(1)?.as_str(); + let id = extract_attribute(attributes, "Id")?; + let target = extract_attribute(attributes, "Target")?; + Some((id, target)) + }) + .collect()) +} + +fn parse_shared_strings(xml: &str) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(r#"(?s)]*>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + regex + .captures_iter(xml) + .filter_map(|captures| captures.get(1).map(|value| value.as_str())) + .map(all_text_nodes) + .collect() +} + +fn parse_sheet( + name: &str, + xml: &str, + shared_strings: Option<&[String]>, +) -> Result { + let mut sheet = SpreadsheetSheet::new(name.to_string()); + + if let Some(sheet_view) = first_tag_attributes(xml, "sheetView") + && let Some(show_grid_lines) = extract_attribute(&sheet_view, "showGridLines") + { + sheet.show_grid_lines = show_grid_lines != "0"; + } + if let Some(format_pr) = first_tag_attributes(xml, "sheetFormatPr") { + sheet.default_row_height = extract_attribute(&format_pr, "defaultRowHeight") + .and_then(|value| value.parse::().ok()); + sheet.default_column_width = extract_attribute(&format_pr, "defaultColWidth") + .and_then(|value| value.parse::().ok()); + } + + let col_regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + for captures in col_regex.captures_iter(xml) { + let Some(attributes) = captures.get(1).map(|value| value.as_str()) else { + continue; + }; + let Some(min) = + extract_attribute(attributes, "min").and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(max) = + extract_attribute(attributes, "max").and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(width) = + extract_attribute(attributes, "width").and_then(|value| value.parse::().ok()) + else { + continue; + }; + for column in min..=max { + sheet.column_widths.insert(column, width); + } + } + + let row_regex = Regex::new(r#"(?s)]*>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + let cell_regex = Regex::new(r#"(?s)]*)>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + for row_captures in row_regex.captures_iter(xml) { + let Some(row_body) = row_captures.get(1).map(|value| value.as_str()) else { + continue; + }; + for cell_captures in cell_regex.captures_iter(row_body) { + let Some(attributes) = cell_captures.get(1).map(|value| value.as_str()) else { + continue; + }; + let Some(body) = cell_captures.get(2).map(|value| value.as_str()) else { + continue; + }; + let Some(address) = extract_attribute(attributes, "r") else { + continue; + }; + let address = CellAddress::parse(&address)?; + let style_index = extract_attribute(attributes, "s") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let cell_type = extract_attribute(attributes, "t").unwrap_or_default(); + let formula = first_tag_text(body, "f").map(|value| format!("={value}")); + let value = parse_cell_value(body, &cell_type, shared_strings)?; + + let cell = SpreadsheetCell { + value, + formula, + style_index, + citations: Vec::new(), + }; + if !cell.is_empty() { + sheet.cells.insert(address, cell); + } + } + } + + let merge_regex = Regex::new(r#"]*)/?>"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + for captures in merge_regex.captures_iter(xml) { + let Some(attributes) = captures.get(1).map(|value| value.as_str()) else { + continue; + }; + if let Some(reference) = extract_attribute(attributes, "ref") { + sheet.merged_ranges.push(CellRange::parse(&reference)?); + } + } + + Ok(sheet) +} + +fn parse_cell_value( + body: &str, + cell_type: &str, + shared_strings: Option<&[String]>, +) -> Result, SpreadsheetArtifactError> { + let inline_text = first_tag_text(body, "t").map(|value| xml_unescape(&value)); + let raw_value = first_tag_text(body, "v").map(|value| xml_unescape(&value)); + + let parsed = match cell_type { + "inlineStr" => inline_text.map(SpreadsheetCellValue::String), + "s" => raw_value + .and_then(|value| value.parse::().ok()) + .and_then(|index| shared_strings.and_then(|entries| entries.get(index).cloned())) + .map(SpreadsheetCellValue::String), + "b" => raw_value.map(|value| SpreadsheetCellValue::Bool(value == "1")), + "str" => raw_value.map(SpreadsheetCellValue::String), + "e" => raw_value.map(SpreadsheetCellValue::Error), + _ => match raw_value { + Some(value) => { + if let Ok(integer) = value.parse::() { + Some(SpreadsheetCellValue::Integer(integer)) + } else if let Ok(float) = value.parse::() { + Some(SpreadsheetCellValue::Float(float)) + } else { + Some(SpreadsheetCellValue::String(value)) + } + } + None => None, + }, + }; + Ok(parsed) +} + +fn content_types_xml(sheet_count: usize) -> String { + let mut overrides = String::new(); + for index in 1..=sheet_count { + overrides.push_str(&format!( + r#""# + )); + } + format!( + "{}{}{}{}{}{}{}{}{}{}", + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + overrides, + r#""# + ) +} + +fn root_relationships_xml() -> &'static str { + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ) +} + +fn app_xml(artifact: &SpreadsheetArtifact) -> String { + let title = artifact + .name + .clone() + .unwrap_or_else(|| "Spreadsheet".to_string()); + format!( + concat!( + r#""#, + r#""#, + r#"Codex"#, + r#"0"#, + r#"false"#, + r#"Worksheets{}"#, + r#"{}"#, + r#"OpenAI"#, + r#"{}"#, + r#""# + ), + artifact.sheets.len(), + artifact.sheets.len(), + artifact + .sheets + .iter() + .map(|sheet| format!(r#"{}"#, xml_escape(&sheet.name))) + .collect::>() + .join(""), + xml_escape(&title), + ) +} + +fn core_xml(artifact: &SpreadsheetArtifact) -> String { + let title = artifact + .name + .clone() + .unwrap_or_else(|| artifact.artifact_id.clone()); + format!( + concat!( + r#""#, + r#""#, + r#"{}"#, + r#"Codex"#, + r#"Codex"#, + r#""# + ), + xml_escape(&title), + ) +} + +fn workbook_xml(artifact: &SpreadsheetArtifact) -> String { + let sheets = if artifact.sheets.is_empty() { + r#""#.to_string() + } else { + artifact + .sheets + .iter() + .enumerate() + .map(|(index, sheet)| { + format!( + r#""#, + xml_escape(&sheet.name), + index + 1, + index + 1 + ) + }) + .collect::>() + .join("") + }; + format!( + "{}{}{}{}{}", + r#""#, + r#""#, + r#""#, + sheets, + r#""# + ) +} + +fn workbook_relationships_xml(artifact: &SpreadsheetArtifact) -> String { + let sheet_relationships = if artifact.sheets.is_empty() { + r#""#.to_string() + } else { + artifact + .sheets + .iter() + .enumerate() + .map(|(index, _)| { + format!( + r#""#, + index + 1, + index + 1 + ) + }) + .collect::>() + .join("") + }; + let style_relation_id = artifact.sheets.len().max(1) + 1; + format!( + "{}{}{}{}", + r#""#, + r#""#, + sheet_relationships, + style_relation_id, + r#""# + ) +} + +fn styles_xml(artifact: &SpreadsheetArtifact) -> String { + let max_style_index = artifact + .sheets + .iter() + .flat_map(|sheet| sheet.cells.values().map(|cell| cell.style_index)) + .max() + .unwrap_or(0); + let cell_xfs = (0..=max_style_index) + .map(|_| r#""#) + .collect::>() + .join(""); + format!( + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#"{}"#, + r#""#, + r#""# + ), + max_style_index + 1, + cell_xfs, + ) +} + +fn sheet_xml(sheet: &SpreadsheetSheet) -> String { + let mut rows = BTreeMap::>::new(); + for (address, cell) in &sheet.cells { + rows.entry(address.row).or_default().push((*address, cell)); + } + + let sheet_data = rows + .into_iter() + .map(|(row_index, mut entries)| { + entries.sort_by_key(|(address, _)| address.column); + let cells = entries + .into_iter() + .map(|(address, cell)| cell_xml(address, cell)) + .collect::>() + .join(""); + format!(r#"{cells}"#) + }) + .collect::>() + .join(""); + + let cols = if sheet.column_widths.is_empty() { + String::new() + } else { + let mut groups = Vec::new(); + let mut iter = sheet.column_widths.iter().peekable(); + while let Some((&start, &width)) = iter.next() { + let mut end = start; + while let Some((next_column, next_width)) = + iter.peek().map(|(column, width)| (**column, **width)) + { + if next_column == end + 1 && (next_width - width).abs() < f64::EPSILON { + end = next_column; + iter.next(); + } else { + break; + } + } + groups.push(format!( + r#""# + )); + } + format!("{}", groups.join("")) + }; + + let merge_cells = if sheet.merged_ranges.is_empty() { + String::new() + } else { + format!( + r#"{}"#, + sheet.merged_ranges.len(), + sheet + .merged_ranges + .iter() + .map(|range| format!(r#""#, range.to_a1())) + .collect::>() + .join("") + ) + }; + + let default_row_height = sheet.default_row_height.unwrap_or(15.0); + let default_column_width = sheet.default_column_width.unwrap_or(8.43); + let grid_lines = if sheet.show_grid_lines { "1" } else { "0" }; + + format!( + "{}{}{}{}{}{}", + r#""#, + r#""#, + grid_lines, + default_row_height, + default_column_width, + cols, + sheet_data, + merge_cells, + r#""# + ) +} + +fn cell_xml(address: CellAddress, cell: &SpreadsheetCell) -> String { + let style = if cell.style_index == 0 { + String::new() + } else { + format!(r#" s="{}""#, cell.style_index) + }; + + if let Some(formula) = &cell.formula { + let formula = xml_escape(formula.trim_start_matches('=')); + let value_xml = match &cell.value { + Some(SpreadsheetCellValue::Bool(value)) => { + format!( + r#" t="b">{formula}{}"#, + usize::from(*value) + ) + } + Some(SpreadsheetCellValue::Integer(value)) => { + format!(r#">{formula}{value}"#) + } + Some(SpreadsheetCellValue::Float(value)) => { + format!(r#">{formula}{value}"#) + } + Some(SpreadsheetCellValue::String(value)) + | Some(SpreadsheetCellValue::DateTime(value)) => format!( + r#" t="str">{formula}{}"#, + xml_escape(value) + ), + Some(SpreadsheetCellValue::Error(value)) => { + format!(r#" t="e">{formula}{}"#, xml_escape(value)) + } + None => format!(r#">{formula}"#), + }; + return format!(r#" format!( + r#"{}"#, + address.to_a1(), + usize::from(*value) + ), + Some(SpreadsheetCellValue::Integer(value)) => { + format!(r#"{value}"#, address.to_a1()) + } + Some(SpreadsheetCellValue::Float(value)) => { + format!(r#"{value}"#, address.to_a1()) + } + Some(SpreadsheetCellValue::String(value)) | Some(SpreadsheetCellValue::DateTime(value)) => { + format!( + r#"{}"#, + address.to_a1(), + xml_escape(value) + ) + } + Some(SpreadsheetCellValue::Error(value)) => format!( + r#"{}"#, + address.to_a1(), + xml_escape(value) + ), + None => format!(r#""#, address.to_a1()), + } +} + +fn first_tag_attributes(xml: &str, tag: &str) -> Option { + let regex = Regex::new(&format!(r#"<{tag}\b([^>]*)/?>"#)).ok()?; + let captures = regex.captures(xml)?; + captures.get(1).map(|value| value.as_str().to_string()) +} + +fn first_tag_text(xml: &str, tag: &str) -> Option { + let regex = Regex::new(&format!(r#"(?s)<{tag}\b[^>]*>(.*?)"#)).ok()?; + let captures = regex.captures(xml)?; + captures.get(1).map(|value| value.as_str().to_string()) +} + +fn all_text_nodes(xml: &str) -> Result { + let regex = Regex::new(r#"(?s)]*>(.*?)"#).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + Ok(regex + .captures_iter(xml) + .filter_map(|captures| captures.get(1).map(|value| xml_unescape(value.as_str()))) + .collect::>() + .join("")) +} + +fn extract_attribute(attributes: &str, name: &str) -> Option { + let pattern = format!(r#"{name}="([^"]*)""#); + let regex = Regex::new(&pattern).ok()?; + let captures = regex.captures(attributes)?; + captures.get(1).map(|value| xml_unescape(value.as_str())) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn xml_unescape(value: &str) -> String { + value + .replace("'", "'") + .replace(""", "\"") + .replace(">", ">") + .replace("<", "<") + .replace("&", "&") +}