mulmoclaude 2.7.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/assets/Graph-ChAxkn9o.js +1 -0
- package/client/assets/dist-Bij44LdC.js +2 -0
- package/client/assets/echarts-GV3U9sVf.js +1 -0
- package/client/assets/echarts-gl-BXx4On0Y.js +4282 -0
- package/client/assets/echarts-wordcloud-_9uQUAfp.js +1 -0
- package/client/assets/emscripten-module-Q67P5WYC-BU3Hlttz.js +2160 -0
- package/client/assets/ffi-4V2b7xu3.js +1 -0
- package/client/assets/{index-lkpgbKtk.js → index-D6xzCZhP.js} +600 -589
- package/client/assets/index-DzMsXDnj.css +2 -0
- package/client/assets/{install-wxcddUsO.js → install-C1moNcrn.js} +1 -1
- package/client/assets/{marp-DcTywifN.js → marp-Du5Sym5b.js} +1 -1
- package/client/assets/module-ES6BEMUI-CDTIpMiA.js +6 -0
- package/client/index.html +4 -3
- package/package.json +5 -4
- package/server/api/routes/plugins.ts +8 -59
- package/server/build/mcp-server.mjs +609 -608
- package/server/index.ts +1 -0
- package/server/plugins/confined-file-ops.ts +136 -0
- package/server/plugins/runtime.ts +8 -115
- package/server/plugins/spreadsheet-builtin.ts +26 -0
- package/server/plugins/spreadsheet-save-outcome.ts +7 -0
- package/server/utils/files/store-resolvers.ts +1 -1
- package/src/lang/de.ts +0 -20
- package/src/lang/en.ts +0 -20
- package/src/lang/es.ts +0 -20
- package/src/lang/fr.ts +0 -20
- package/src/lang/ja.ts +0 -20
- package/src/lang/ko.ts +0 -20
- package/src/lang/pt-BR.ts +0 -20
- package/src/lang/zh.ts +0 -20
- package/src/plugins/spreadsheet/definition.ts +9 -127
- package/src/plugins/spreadsheet/index.ts +7 -4
- package/src/plugins/spreadsheet/meta.ts +2 -4
- package/client/assets/echarts-gl-Cq76Z6DA.js +0 -4282
- package/client/assets/index-Cz7JLE0k.css +0 -2
- package/server/utils/files/spreadsheet-store.ts +0 -26
- package/src/plugins/spreadsheet/Preview.vue +0 -31
- package/src/plugins/spreadsheet/View.vue +0 -1007
- package/src/plugins/spreadsheet/cellHighlights.ts +0 -78
- package/src/plugins/spreadsheet/engine/calculator.ts +0 -427
- package/src/plugins/spreadsheet/engine/cellBuilder.ts +0 -89
- package/src/plugins/spreadsheet/engine/cellEmpty.ts +0 -26
- package/src/plugins/spreadsheet/engine/cellFormatting.ts +0 -59
- package/src/plugins/spreadsheet/engine/coerce-boolean.ts +0 -26
- package/src/plugins/spreadsheet/engine/condition.ts +0 -182
- package/src/plugins/spreadsheet/engine/date-locale.ts +0 -47
- package/src/plugins/spreadsheet/engine/date-parser.ts +0 -234
- package/src/plugins/spreadsheet/engine/date-utils.ts +0 -73
- package/src/plugins/spreadsheet/engine/datedif.ts +0 -93
- package/src/plugins/spreadsheet/engine/engine.ts +0 -177
- package/src/plugins/spreadsheet/engine/evaluator.ts +0 -494
- package/src/plugins/spreadsheet/engine/financial-math.ts +0 -114
- package/src/plugins/spreadsheet/engine/formatter.ts +0 -158
- package/src/plugins/spreadsheet/engine/formulaError.ts +0 -67
- package/src/plugins/spreadsheet/engine/formulaRefs.ts +0 -202
- package/src/plugins/spreadsheet/engine/functions/date.ts +0 -212
- package/src/plugins/spreadsheet/engine/functions/financial.ts +0 -244
- package/src/plugins/spreadsheet/engine/functions/index.ts +0 -16
- package/src/plugins/spreadsheet/engine/functions/logical.ts +0 -221
- package/src/plugins/spreadsheet/engine/functions/lookup-math.ts +0 -28
- package/src/plugins/spreadsheet/engine/functions/lookup.ts +0 -257
- package/src/plugins/spreadsheet/engine/functions/mathematical.ts +0 -283
- package/src/plugins/spreadsheet/engine/functions/statistical-math.ts +0 -91
- package/src/plugins/spreadsheet/engine/functions/statistical.ts +0 -290
- package/src/plugins/spreadsheet/engine/functions/text.ts +0 -398
- package/src/plugins/spreadsheet/engine/index.ts +0 -34
- package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +0 -111
- package/src/plugins/spreadsheet/engine/math-ops.ts +0 -98
- package/src/plugins/spreadsheet/engine/numericCoercion.ts +0 -61
- package/src/plugins/spreadsheet/engine/parser.ts +0 -40
- package/src/plugins/spreadsheet/engine/registry.ts +0 -201
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +0 -68
- package/src/plugins/spreadsheet/engine/spreadsheet-errors.ts +0 -82
- package/src/plugins/spreadsheet/engine/textFormat.ts +0 -108
- package/src/plugins/spreadsheet/engine/translateFormula.ts +0 -69
- package/src/plugins/spreadsheet/engine/types.ts +0 -81
- package/src/plugins/spreadsheet/keyboardNav.ts +0 -38
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SpreadsheetEngine - Main API
|
|
3
|
-
*
|
|
4
|
-
* High-level interface for spreadsheet calculations
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { calculateSheet, calculateWorkbook } from "./calculator";
|
|
8
|
-
import type { SheetData, CalculatedSheet, EngineOptions, SpreadsheetCell } from "./types";
|
|
9
|
-
import { isObj } from "../../../utils/types";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* SpreadsheetEngine - Main calculation engine class
|
|
13
|
-
*
|
|
14
|
-
* Provides a clean API for calculating spreadsheet formulas with support for:
|
|
15
|
-
* - Formula evaluation (SUM, AVERAGE, IF, etc.)
|
|
16
|
-
* - Cell references (A1, $B$2, Sheet1!C3)
|
|
17
|
-
* - Cross-sheet references
|
|
18
|
-
* - Number formatting ($#,##0.00, 0.00%, etc.)
|
|
19
|
-
* - Circular reference detection
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* ```typescript
|
|
23
|
-
* const engine = new SpreadsheetEngine();
|
|
24
|
-
* const sheet = {
|
|
25
|
-
* name: 'Sales',
|
|
26
|
-
* data: [
|
|
27
|
-
* [{v: 'Product'}, {v: 'Price'}, {v: 'Qty'}, {v: 'Total'}],
|
|
28
|
-
* [{v: 'Widget'}, {v: 10}, {v: 100}, {v: '=B2*C2'}],
|
|
29
|
-
* ]
|
|
30
|
-
* };
|
|
31
|
-
* const result = engine.calculate(sheet);
|
|
32
|
-
* console.log(result.data); // [['Product', 'Price', 'Qty', 'Total'], ['Widget', 10, 100, 1000]]
|
|
33
|
-
* ```
|
|
34
|
-
*/
|
|
35
|
-
export class SpreadsheetEngine {
|
|
36
|
-
private options: Required<EngineOptions>;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Create a new SpreadsheetEngine
|
|
40
|
-
*
|
|
41
|
-
* @param options - Configuration options
|
|
42
|
-
*/
|
|
43
|
-
constructor(options: EngineOptions = {}) {
|
|
44
|
-
this.options = {
|
|
45
|
-
maxIterations: options.maxIterations ?? 100,
|
|
46
|
-
enableCrossSheetRefs: options.enableCrossSheetRefs ?? true,
|
|
47
|
-
strictMode: options.strictMode ?? false,
|
|
48
|
-
preferDDMMYYYY: options.preferDDMMYYYY ?? false,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Calculate a single sheet
|
|
54
|
-
*
|
|
55
|
-
* Evaluates all formulas in the sheet and applies number formatting.
|
|
56
|
-
* Returns calculated values with formula metadata and any errors.
|
|
57
|
-
*
|
|
58
|
-
* @param sheet - Sheet data with formulas
|
|
59
|
-
* @param allSheets - Optional array of all sheets for cross-sheet references
|
|
60
|
-
* @returns Calculated sheet with evaluated formulas
|
|
61
|
-
*
|
|
62
|
-
* @example
|
|
63
|
-
* ```typescript
|
|
64
|
-
* const result = engine.calculate({
|
|
65
|
-
* name: 'Budget',
|
|
66
|
-
* data: [
|
|
67
|
-
* [{v: 'Item'}, {v: 'Amount'}],
|
|
68
|
-
* [{v: 'Revenue'}, {v: 1000}],
|
|
69
|
-
* [{v: 'Expenses'}, {v: 600}],
|
|
70
|
-
* [{v: 'Profit'}, {v: '=B2-B3'}],
|
|
71
|
-
* ]
|
|
72
|
-
* });
|
|
73
|
-
* console.log(result.data[3][1]); // 400
|
|
74
|
-
* ```
|
|
75
|
-
*/
|
|
76
|
-
calculate(sheet: SheetData, allSheets?: SheetData[]): CalculatedSheet {
|
|
77
|
-
return calculateSheet(sheet, allSheets, { preferDDMMYYYY: this.options.preferDDMMYYYY });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Calculate all sheets in a workbook
|
|
82
|
-
*
|
|
83
|
-
* Evaluates formulas across multiple sheets with support for
|
|
84
|
-
* cross-sheet references (e.g., Sheet1!A1).
|
|
85
|
-
*
|
|
86
|
-
* @param sheets - Array of sheets to calculate
|
|
87
|
-
* @returns Array of calculated sheets
|
|
88
|
-
*
|
|
89
|
-
* @example
|
|
90
|
-
* ```typescript
|
|
91
|
-
* const results = engine.calculateWorkbook([
|
|
92
|
-
* { name: 'Data', data: [[{v: 100}]] },
|
|
93
|
-
* { name: 'Summary', data: [[{v: '=Data!A1*2'}]] }
|
|
94
|
-
* ]);
|
|
95
|
-
* console.log(results[1].data[0][0]); // 200
|
|
96
|
-
* ```
|
|
97
|
-
*/
|
|
98
|
-
calculateWorkbook(sheets: SheetData[]): CalculatedSheet[] {
|
|
99
|
-
return calculateWorkbook(sheets, { preferDDMMYYYY: this.options.preferDDMMYYYY });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Get current engine options
|
|
104
|
-
*
|
|
105
|
-
* @returns Current configuration options
|
|
106
|
-
*/
|
|
107
|
-
getOptions(): Required<EngineOptions> {
|
|
108
|
-
return { ...this.options };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Update engine options
|
|
113
|
-
*
|
|
114
|
-
* @param options - Options to update
|
|
115
|
-
*
|
|
116
|
-
* @example
|
|
117
|
-
* ```typescript
|
|
118
|
-
* engine.setOptions({ strictMode: true });
|
|
119
|
-
* ```
|
|
120
|
-
*/
|
|
121
|
-
setOptions(options: Partial<EngineOptions>): void {
|
|
122
|
-
this.options = {
|
|
123
|
-
...this.options,
|
|
124
|
-
...options,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Create a simple sheet from array data
|
|
130
|
-
*
|
|
131
|
-
* Helper method to create a SheetData object from simple arrays.
|
|
132
|
-
* Automatically converts values to SpreadsheetCell format.
|
|
133
|
-
*
|
|
134
|
-
* @param name - Sheet name
|
|
135
|
-
* @param data - Array of arrays (rows and cells)
|
|
136
|
-
* @returns SheetData object
|
|
137
|
-
*
|
|
138
|
-
* @example
|
|
139
|
-
* ```typescript
|
|
140
|
-
* const sheet = engine.createSheet('Sales', [
|
|
141
|
-
* ['Product', 'Price', 'Qty', 'Total'],
|
|
142
|
-
* ['Widget', 10, 100, '=B2*C2'],
|
|
143
|
-
* ]);
|
|
144
|
-
* ```
|
|
145
|
-
*/
|
|
146
|
-
createSheet(name: string, data: Array<Array<SpreadsheetCell | string | number>>): SheetData {
|
|
147
|
-
return {
|
|
148
|
-
name,
|
|
149
|
-
data: data.map((row) =>
|
|
150
|
-
row.map((cell) => {
|
|
151
|
-
if (isObj(cell) && "v" in cell) {
|
|
152
|
-
return cell as SpreadsheetCell;
|
|
153
|
-
}
|
|
154
|
-
return { v: cell };
|
|
155
|
-
}),
|
|
156
|
-
),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Convert calculated sheet data to string array
|
|
162
|
-
*
|
|
163
|
-
* Helper method for testing and output formatting.
|
|
164
|
-
*
|
|
165
|
-
* @param calculated - Calculated sheet
|
|
166
|
-
* @returns 2D array of strings
|
|
167
|
-
*
|
|
168
|
-
* @example
|
|
169
|
-
* ```typescript
|
|
170
|
-
* const result = engine.calculate(sheet);
|
|
171
|
-
* const stringArray = engine.toStringArray(result);
|
|
172
|
-
* ```
|
|
173
|
-
*/
|
|
174
|
-
toStringArray(calculated: CalculatedSheet): string[][] {
|
|
175
|
-
return calculated.data.map((row) => row.map((cell) => String(cell ?? "")));
|
|
176
|
-
}
|
|
177
|
-
}
|
|
@@ -1,494 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Formula Evaluator
|
|
3
|
-
*
|
|
4
|
-
* Evaluates spreadsheet formulas including functions, cell references, and arithmetic
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { functionRegistry, isCallableName, tooFewArgumentsError } from "./registry";
|
|
8
|
-
import type { CellValue } from "./types";
|
|
9
|
-
import { parseDate } from "./date-parser";
|
|
10
|
-
import { caretToPow, replaceConcatOperator, rewriteComparisonEq, isSafeArithmetic, isSafeComparison } from "./translateFormula";
|
|
11
|
-
import { divZeroError, unknownError, nameError, propagatedError } from "./formulaError";
|
|
12
|
-
import { errorCodeOf, isSpreadsheetErrorValue } from "./spreadsheet-errors";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Evaluation context for formulas
|
|
16
|
-
*/
|
|
17
|
-
export interface EvaluatorContext {
|
|
18
|
-
getCellValue: (ref: string) => CellValue;
|
|
19
|
-
getRangeValues: (range: string) => CellValue[];
|
|
20
|
-
getRangeValuesRaw?: ((range: string) => CellValue[]) | undefined;
|
|
21
|
-
evaluateFormula: (formula: string) => CellValue;
|
|
22
|
-
/** Reading order for an ambiguous slash date; see engine/date-locale.ts. */
|
|
23
|
-
preferDDMMYYYY?: boolean | undefined;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Render a cell's value as the text that stands in for it inside an
|
|
27
|
-
* expression. Strings are quoted so they cannot be read as identifiers or
|
|
28
|
-
* operators, and their own quotes and backslashes are escaped so the literal
|
|
29
|
-
* cannot be closed early. A missing value becomes 0, matching how blanks are
|
|
30
|
-
* treated everywhere else in the engine. */
|
|
31
|
-
export function renderOperand(value: CellValue | null | undefined): string {
|
|
32
|
-
if (value === null || value === undefined) return "0";
|
|
33
|
-
const text = isSpreadsheetErrorValue(value) ? value.code : value;
|
|
34
|
-
if (typeof text === "string") return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
35
|
-
return text.toString();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Replace every quoted string literal with an empty pair of quotes, honouring
|
|
39
|
-
* `\` escapes so an escaped quote does not end the literal early. Used to
|
|
40
|
-
* validate the STRUCTURE of a concat/arithmetic expression without letting the
|
|
41
|
-
* arbitrary CONTENT of a string decide whether the whole thing looks safe. */
|
|
42
|
-
export function maskStringLiterals(expr: string): string {
|
|
43
|
-
let out = "";
|
|
44
|
-
let quote: string | null = null;
|
|
45
|
-
for (let index = 0; index < expr.length; index++) {
|
|
46
|
-
const char = expr[index];
|
|
47
|
-
if (quote !== null) {
|
|
48
|
-
if (char === "\\") {
|
|
49
|
-
index++; // skip the escaped character — it is part of the literal
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (char === quote) {
|
|
53
|
-
quote = null;
|
|
54
|
-
out += char;
|
|
55
|
-
}
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (char === '"' || char === "'") {
|
|
59
|
-
quote = char;
|
|
60
|
-
out += char;
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
out += char;
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Whether a `&`-to-`+` concatenation expression is safe to evaluate. Checks the
|
|
69
|
-
* structure with string CONTENT masked out — a string literal may hold any
|
|
70
|
-
* character (a `!`, a `\`), and validating those against a character allowlist
|
|
71
|
-
* rejected valid formulas like `=A1&"!"` and every escaped operand. Once the
|
|
72
|
-
* literals are masked, only the joining structure remains to validate. */
|
|
73
|
-
export function isSafeConcatExpression(expr: string): boolean {
|
|
74
|
-
// A boolean cell renders as a bare `true` / `false` here (renderOperand) —
|
|
75
|
-
// the only non-literal identifier the substitution can emit. Drop those words
|
|
76
|
-
// before validating so a boolean operand (`=A1&"!"` with A1 = true) still
|
|
77
|
-
// evaluates, while any other identifier keeps the structure from matching and
|
|
78
|
-
// never reaches `new Function`.
|
|
79
|
-
const structure = maskStringLiterals(expr).replace(/\b(?:true|false)\b/g, "");
|
|
80
|
-
return /^[\d+\-*/(). "']*$/.test(structure);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Index just past the string literal that opens at `start` (a quote char),
|
|
84
|
-
* honouring `\` escapes so an escaped quote does not close it early. Returns
|
|
85
|
-
* `expr.length` when the literal is never closed. */
|
|
86
|
-
export function endOfStringLiteral(expr: string, start: number): number {
|
|
87
|
-
const quote = expr[start];
|
|
88
|
-
let index = start + 1;
|
|
89
|
-
while (index < expr.length) {
|
|
90
|
-
if (expr[index] === "\\") {
|
|
91
|
-
index += 2; // skip the escaped character — it is part of the literal
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (expr[index] === quote) return index + 1;
|
|
95
|
-
index++;
|
|
96
|
-
}
|
|
97
|
-
return expr.length;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** A `'Sheet Name'!A1` reference beginning at `start` (a `'`), or null when the
|
|
101
|
-
* quotes open a plain string literal rather than a sheet-qualified reference. */
|
|
102
|
-
function matchQuotedSheetRef(expr: string, start: number): string | null {
|
|
103
|
-
const endQuote = expr.indexOf("'", start + 1);
|
|
104
|
-
if (endQuote === -1 || expr[endQuote + 1] !== "!") return null;
|
|
105
|
-
const cellPart = expr.substring(endQuote + 2).match(/^(\$?[A-Z]+\$?\d+)/);
|
|
106
|
-
if (!cellPart) return null;
|
|
107
|
-
return expr.substring(start, endQuote + 2 + cellPart[0].length);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** A `Sheet!A1` or bare `A1` / `$A$1` reference beginning at `start`, or null. */
|
|
111
|
-
function matchUnquotedRef(expr: string, start: number): string | null {
|
|
112
|
-
const rest = expr.substring(start);
|
|
113
|
-
const sheetMatch = rest.match(/^([A-Z][A-Z0-9]*)!/i);
|
|
114
|
-
if (sheetMatch) {
|
|
115
|
-
const cellPart = rest.substring(sheetMatch[0].length).match(/^(\$?[A-Z]+\$?\d+)/);
|
|
116
|
-
if (cellPart) return sheetMatch[0] + cellPart[0];
|
|
117
|
-
}
|
|
118
|
-
const cellMatch = rest.match(/^(\$?[A-Z]+\$?\d+)/);
|
|
119
|
-
return cellMatch ? cellMatch[0] : null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Every cell reference in an expression, as `{ref, start}` spans in source
|
|
123
|
-
* order. Quoted string literals are skipped whole: a `"A1"` in the text is a
|
|
124
|
-
* constant, not a reference, and substituting it would turn `="A1"&"!"` into
|
|
125
|
-
* A1's value. A `'` opens either a `'Sheet'!A1` reference or a string literal —
|
|
126
|
-
* only the former is a reference; the latter is skipped like a `"` literal. */
|
|
127
|
-
export interface CellRefSpan {
|
|
128
|
-
ref: string;
|
|
129
|
-
start: number;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function findCellRefs(expr: string): CellRefSpan[] {
|
|
133
|
-
const cellRefs: CellRefSpan[] = [];
|
|
134
|
-
let i = 0;
|
|
135
|
-
while (i < expr.length) {
|
|
136
|
-
const char = expr[i];
|
|
137
|
-
if (char === '"') {
|
|
138
|
-
i = endOfStringLiteral(expr, i);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (char === "'") {
|
|
142
|
-
const sheetRef = matchQuotedSheetRef(expr, i);
|
|
143
|
-
if (sheetRef) {
|
|
144
|
-
cellRefs.push({ ref: sheetRef, start: i });
|
|
145
|
-
i += sheetRef.length;
|
|
146
|
-
} else {
|
|
147
|
-
i = endOfStringLiteral(expr, i);
|
|
148
|
-
}
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
const ref = matchUnquotedRef(expr, i);
|
|
152
|
-
if (ref) {
|
|
153
|
-
cellRefs.push({ ref, start: i });
|
|
154
|
-
i += ref.length;
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
i++;
|
|
158
|
-
}
|
|
159
|
-
return cellRefs;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Replace every reference span in `expr` with the text `render` gives for it,
|
|
163
|
-
* walking BACK TO FRONT so the spans ahead of each edit stay valid. A global
|
|
164
|
-
* string replace rewrote every occurrence of the shorter reference first, so
|
|
165
|
-
* `=A1+A10` had its `A10` broken into `<A1's value>0` and produced a plausible
|
|
166
|
-
* wrong number (#2357). */
|
|
167
|
-
export function substituteCellRefs(expr: string, cellRefs: CellRefSpan[], render: (ref: string) => string): string {
|
|
168
|
-
return [...cellRefs].reverse().reduce((text, { ref, start }) => text.slice(0, start) + render(ref) + text.slice(start + ref.length), expr);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Parse function arguments, handling nested functions and quoted strings
|
|
173
|
-
*
|
|
174
|
-
* @param argsStr - String containing function arguments
|
|
175
|
-
* @returns Array of argument strings
|
|
176
|
-
*/
|
|
177
|
-
export function parseFunctionArgs(argsStr: string): string[] {
|
|
178
|
-
const args: string[] = [];
|
|
179
|
-
let currentArg = "";
|
|
180
|
-
let depth = 0;
|
|
181
|
-
let inString = false;
|
|
182
|
-
let stringChar = "";
|
|
183
|
-
|
|
184
|
-
for (let i = 0; i < argsStr.length; i++) {
|
|
185
|
-
const char = argsStr[i];
|
|
186
|
-
const prevChar = i > 0 ? argsStr[i - 1] : "";
|
|
187
|
-
|
|
188
|
-
// Handle string boundaries
|
|
189
|
-
if ((char === '"' || char === "'") && prevChar !== "\\") {
|
|
190
|
-
if (!inString) {
|
|
191
|
-
inString = true;
|
|
192
|
-
stringChar = char;
|
|
193
|
-
} else if (char === stringChar) {
|
|
194
|
-
inString = false;
|
|
195
|
-
stringChar = "";
|
|
196
|
-
}
|
|
197
|
-
currentArg += char;
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Track parentheses depth (for nested functions)
|
|
202
|
-
if (!inString) {
|
|
203
|
-
if (char === "(") depth++;
|
|
204
|
-
if (char === ")") depth--;
|
|
205
|
-
|
|
206
|
-
// Split on comma only at depth 0 and not in string
|
|
207
|
-
if (char === "," && depth === 0) {
|
|
208
|
-
args.push(currentArg.trim());
|
|
209
|
-
currentArg = "";
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
currentArg += char;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (currentArg.trim()) {
|
|
218
|
-
args.push(currentArg.trim());
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return args;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Run `new Function` on an expression the callers have already gated with a
|
|
225
|
-
* character allowlist. A JS parse/eval failure becomes #ERROR! — a genuinely
|
|
226
|
-
* broken formula that used to be swallowed into a bare string (#2359). A
|
|
227
|
-
* non-finite NUMBER result is only reachable by dividing by zero (comparisons
|
|
228
|
-
* yield booleans, concatenation yields strings), so it becomes #DIV/0!. */
|
|
229
|
-
function evalValidatedExpression(jsExpr: string): CellValue {
|
|
230
|
-
let result: unknown;
|
|
231
|
-
try {
|
|
232
|
-
// eslint-disable -- sonarjs/code-eval
|
|
233
|
-
result = new Function(`return (${jsExpr})`)();
|
|
234
|
-
} catch {
|
|
235
|
-
throw unknownError();
|
|
236
|
-
}
|
|
237
|
-
if (typeof result === "number") {
|
|
238
|
-
if (!Number.isFinite(result)) throw divZeroError();
|
|
239
|
-
return result;
|
|
240
|
-
}
|
|
241
|
-
if (typeof result === "string" || typeof result === "boolean") return result;
|
|
242
|
-
throw unknownError();
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Evaluate a formula string
|
|
247
|
-
*
|
|
248
|
-
* Supports:
|
|
249
|
-
* - Function calls: SUM(A1:A10), ROUND(B2, 2)
|
|
250
|
-
* - Cell references: A1, B2, Sheet1!A1
|
|
251
|
-
* - Arithmetic: 2+3, A1*B1, (A1+B1)/2
|
|
252
|
-
* - Nested expressions: ROUND(SUM(A1:A10)/COUNT(A1:A10), 2)
|
|
253
|
-
*
|
|
254
|
-
* A genuine failure THROWS (a typed FormulaError or any handler error) rather
|
|
255
|
-
* than returning the raw formula text; the calculator classifies it into
|
|
256
|
-
* errors[] and shows the Excel error value in the cell (#2359).
|
|
257
|
-
*
|
|
258
|
-
* @param formula - Formula string (without leading =)
|
|
259
|
-
* @param context - Evaluation context with cell/range accessors
|
|
260
|
-
* @returns Evaluated result (number or string)
|
|
261
|
-
*/
|
|
262
|
-
export function evaluateFormula(formula: string, context: EvaluatorContext): CellValue {
|
|
263
|
-
// Handle string literals - remove surrounding quotes
|
|
264
|
-
// But NOT string concatenations (which contain & operators)
|
|
265
|
-
const trimmed = formula.trim();
|
|
266
|
-
if (
|
|
267
|
-
((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) &&
|
|
268
|
-
!trimmed.includes("&") // Exclude string concatenations
|
|
269
|
-
) {
|
|
270
|
-
const stringValue = trimmed.slice(1, -1); // Remove first and last character (quotes)
|
|
271
|
-
|
|
272
|
-
// Auto-parse date strings to serial numbers for compatibility with date arithmetic
|
|
273
|
-
// This allows formulas like =HLOOKUP("6/1/2024", ...) to work with parsed date cells
|
|
274
|
-
const dateSerial = parseDate(stringValue, context.preferDDMMYYYY);
|
|
275
|
-
if (dateSerial !== null) {
|
|
276
|
-
return dateSerial;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return stringValue;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Check if it's a SIMPLE function call (not a complex expression)
|
|
283
|
-
// We need to ensure the formula is JUST a function, not "FUNC(...) + something"
|
|
284
|
-
const [, funcName, argsStr] = formula.match(/^([A-Z][A-Z0-9]*)\((.*)\)$/i) ?? [];
|
|
285
|
-
if (funcName !== undefined && argsStr !== undefined && isCallableName(funcName)) {
|
|
286
|
-
// Check that the closing paren is actually the end of the function
|
|
287
|
-
// by counting parentheses in argsStr
|
|
288
|
-
let parenDepth = 0;
|
|
289
|
-
let isValidFunction = true;
|
|
290
|
-
for (const char of argsStr) {
|
|
291
|
-
if (char === "(") parenDepth++;
|
|
292
|
-
else if (char === ")") {
|
|
293
|
-
parenDepth--;
|
|
294
|
-
if (parenDepth < 0) {
|
|
295
|
-
// More closing parens than opening - this means we matched too much
|
|
296
|
-
isValidFunction = false;
|
|
297
|
-
break;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Normalize function name to uppercase for registry lookup
|
|
303
|
-
const normalizedFuncName = funcName.toUpperCase();
|
|
304
|
-
const func = functionRegistry.get(normalizedFuncName);
|
|
305
|
-
|
|
306
|
-
if (func && isValidFunction) {
|
|
307
|
-
const args = parseFunctionArgs(argsStr);
|
|
308
|
-
|
|
309
|
-
// Validate argument count
|
|
310
|
-
if (func.minArgs !== undefined && args.length < func.minArgs) {
|
|
311
|
-
throw tooFewArgumentsError(normalizedFuncName, func.minArgs);
|
|
312
|
-
}
|
|
313
|
-
if (func.maxArgs !== undefined && args.length > func.maxArgs) {
|
|
314
|
-
throw new Error(`${normalizedFuncName} accepts at most ${func.maxArgs} argument${func.maxArgs !== 1 ? "s" : ""}`);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Execute function with context
|
|
318
|
-
return func.handler(args, {
|
|
319
|
-
functionName: normalizedFuncName,
|
|
320
|
-
getCellValue: context.getCellValue,
|
|
321
|
-
getRangeValues: context.getRangeValues,
|
|
322
|
-
getRangeValuesRaw: context.getRangeValuesRaw,
|
|
323
|
-
evaluateFormula: context.evaluateFormula,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// A balanced `NAME(...)` spanning the whole formula whose NAME is not a
|
|
328
|
-
// registered function is an unrecognized name — Excel's #NAME?. Surfacing
|
|
329
|
-
// it here also stops the arithmetic path below from recursing on it forever
|
|
330
|
-
// and leaving the literal text in the cell (#2359).
|
|
331
|
-
if (isValidFunction && !func) {
|
|
332
|
-
throw nameError(normalizedFuncName);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Handle simple arithmetic expressions with cell references
|
|
337
|
-
// First, replace any function calls within the expression
|
|
338
|
-
let expr = formula;
|
|
339
|
-
|
|
340
|
-
// Find and evaluate function calls (e.g., TODAY(), SUM(A1:A10), LOWER(A1), etc.)
|
|
341
|
-
// Use a simpler approach: find function names followed by parentheses
|
|
342
|
-
// and manually parse the matching closing parenthesis
|
|
343
|
-
let searchIndex = 0;
|
|
344
|
-
const maxIterations = 100; // Prevent infinite loops
|
|
345
|
-
let iterations = 0;
|
|
346
|
-
|
|
347
|
-
while (searchIndex < expr.length && iterations < maxIterations) {
|
|
348
|
-
iterations++;
|
|
349
|
-
// A call written inside a string literal is text, not a call.
|
|
350
|
-
if (expr[searchIndex] === '"' || expr[searchIndex] === "'") {
|
|
351
|
-
searchIndex = endOfStringLiteral(expr, searchIndex);
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
const [, funcName] = expr.substring(searchIndex).match(/^([A-Z][A-Z0-9]*)\(/i) ?? [];
|
|
355
|
-
if (funcName === undefined || !isCallableName(funcName)) {
|
|
356
|
-
// No more functions found, move to next character
|
|
357
|
-
searchIndex++;
|
|
358
|
-
if (searchIndex >= expr.length) break;
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const funcStartIndex = searchIndex;
|
|
363
|
-
const argsStartIndex = searchIndex + funcName.length + 1;
|
|
364
|
-
|
|
365
|
-
// Find matching closing parenthesis
|
|
366
|
-
let depth = 1;
|
|
367
|
-
let argsEndIndex = argsStartIndex;
|
|
368
|
-
let inString = false;
|
|
369
|
-
let stringChar = "";
|
|
370
|
-
|
|
371
|
-
while (argsEndIndex < expr.length && depth > 0) {
|
|
372
|
-
const char = expr[argsEndIndex];
|
|
373
|
-
const prevChar = argsEndIndex > 0 ? expr[argsEndIndex - 1] : "";
|
|
374
|
-
|
|
375
|
-
// Track string boundaries
|
|
376
|
-
if ((char === '"' || char === "'") && prevChar !== "\\") {
|
|
377
|
-
if (!inString) {
|
|
378
|
-
inString = true;
|
|
379
|
-
stringChar = char;
|
|
380
|
-
} else if (char === stringChar) {
|
|
381
|
-
inString = false;
|
|
382
|
-
stringChar = "";
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Only count parens outside of strings
|
|
387
|
-
if (!inString) {
|
|
388
|
-
if (char === "(") depth++;
|
|
389
|
-
else if (char === ")") depth--;
|
|
390
|
-
}
|
|
391
|
-
argsEndIndex++;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (depth === 0) {
|
|
395
|
-
const fullMatch = expr.substring(funcStartIndex, argsEndIndex);
|
|
396
|
-
const result = context.evaluateFormula(fullMatch);
|
|
397
|
-
// A nested call that errored poisons the expression the same way an
|
|
398
|
-
// errored reference does — substituting it produced `"#NUM!"+1` text.
|
|
399
|
-
// Only the error VALUE counts: text that merely spells an error (CONCAT)
|
|
400
|
-
// is an ordinary operand.
|
|
401
|
-
if (isSpreadsheetErrorValue(result)) throw propagatedError(result.code);
|
|
402
|
-
// For string results, wrap in quotes; for numbers, wrap in parentheses
|
|
403
|
-
const replacement = typeof result === "string" ? `"${result}"` : `(${result})`;
|
|
404
|
-
expr = expr.substring(0, funcStartIndex) + replacement + expr.substring(argsEndIndex);
|
|
405
|
-
// Continue from after the replacement
|
|
406
|
-
searchIndex = funcStartIndex + replacement.length;
|
|
407
|
-
} else {
|
|
408
|
-
searchIndex++;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Then replace cell references with their values. Detection skips quoted
|
|
413
|
-
// string literals so a `"A1"` constant is not read as a reference.
|
|
414
|
-
const cellRefs = findCellRefs(expr);
|
|
415
|
-
|
|
416
|
-
// A formula that is nothing but one reference returns that cell's value
|
|
417
|
-
// directly. Rendering it into an expression first would mean escaping the
|
|
418
|
-
// text, and the escapes would survive into the result — `=A1` on a cell
|
|
419
|
-
// holding `say "hi"` would come back `say \"hi\"`. Surrounding whitespace
|
|
420
|
-
// (`= A1`, `=A1 `) is part of "nothing but one reference", so compare the
|
|
421
|
-
// span against the trimmed expression rather than the raw one.
|
|
422
|
-
const soleRef = cellRefs.length === 1 ? cellRefs[0] : undefined;
|
|
423
|
-
if (soleRef) {
|
|
424
|
-
const before = expr.slice(0, soleRef.start);
|
|
425
|
-
const after = expr.slice(soleRef.start + soleRef.ref.length);
|
|
426
|
-
if (before.trim() === "" && after.trim() === "") {
|
|
427
|
-
return context.getCellValue(soleRef.ref);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
expr = substituteCellRefs(expr, cellRefs, (ref) => {
|
|
432
|
-
const value = context.getCellValue(ref);
|
|
433
|
-
// A referenced cell holding an error poisons the whole expression:
|
|
434
|
-
// rendering it would produce `"#DIV/0!"+1` garbage, so propagate the error
|
|
435
|
-
// instead of substituting it (#2359, Excel behaviour). A cell whose stored
|
|
436
|
-
// TEXT spells an error counts here too — Excel stores a typed error literal
|
|
437
|
-
// as an error, and arithmetic over it is never meaningful.
|
|
438
|
-
const referencedErrorCode = errorCodeOf(value);
|
|
439
|
-
if (referencedErrorCode !== null) throw propagatedError(referencedErrorCode);
|
|
440
|
-
return renderOperand(value);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
// Parse date strings in arithmetic expressions (e.g., "06/01/2025" → serial number)
|
|
444
|
-
// This allows formulas like =B3-"06/01/2025" to work correctly
|
|
445
|
-
expr = expr.replace(/"([^"]+)"/g, (match, dateStr) => {
|
|
446
|
-
const dateSerial = parseDate(dateStr, context.preferDDMMYYYY);
|
|
447
|
-
if (dateSerial !== null) {
|
|
448
|
-
return dateSerial.toString();
|
|
449
|
-
}
|
|
450
|
-
return match; // Keep original if not a date
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// Replace ^ with ** for exponentiation
|
|
454
|
-
expr = caretToPow(expr);
|
|
455
|
-
|
|
456
|
-
// Check if this is a string concatenation expression (contains & and quoted strings)
|
|
457
|
-
const hasStringConcat = expr.includes("&");
|
|
458
|
-
const hasQuotedStrings = /["']/.test(expr);
|
|
459
|
-
|
|
460
|
-
// If it contains string concatenation, handle it specially. Convert & to +
|
|
461
|
-
// for JS concatenation (leaving any & inside a literal untouched), then
|
|
462
|
-
// validate the STRUCTURE with literals masked — a literal may hold any
|
|
463
|
-
// character, so masking is what lets `=A1&"!"` and escaped operands through
|
|
464
|
-
// while still rejecting a genuinely unsafe expression. A non-safe structure
|
|
465
|
-
// falls through to the paths below rather than erroring.
|
|
466
|
-
if (hasStringConcat && hasQuotedStrings) {
|
|
467
|
-
const result = replaceConcatOperator(expr);
|
|
468
|
-
if (isSafeConcatExpression(result)) {
|
|
469
|
-
return evalValidatedExpression(result);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// Safely evaluate comparison expressions (e.g., 5=6, (5)>(6)). The allowlist
|
|
474
|
-
// (numbers, `= != < > <= >=`, parens, whitespace) gates the evaluation. It is
|
|
475
|
-
// a superset of the arithmetic allowlist, so a plain `1/0` is handled here —
|
|
476
|
-
// evalValidatedExpression turns its Infinity into #DIV/0! (#2359).
|
|
477
|
-
if (isSafeComparison(expr)) {
|
|
478
|
-
return evalValidatedExpression(rewriteComparisonEq(expr));
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Safely evaluate arithmetic expressions (numbers, operators, parens,
|
|
482
|
-
// whitespace, decimal points).
|
|
483
|
-
if (isSafeArithmetic(expr)) {
|
|
484
|
-
return evalValidatedExpression(expr);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// If the final expression is a quoted string literal, unwrap it
|
|
488
|
-
const trimmedExpr = expr.trim();
|
|
489
|
-
if ((trimmedExpr.startsWith('"') && trimmedExpr.endsWith('"')) || (trimmedExpr.startsWith("'") && trimmedExpr.endsWith("'"))) {
|
|
490
|
-
return trimmedExpr.slice(1, -1); // Remove quotes
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
return expr; // Return processed expression (with cell refs replaced, etc.)
|
|
494
|
-
}
|