mulmoclaude 2.6.0 → 2.8.0
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/{JsonEditor-FKfzzMkt.js → JsonEditor-COQcmjLQ.js} +1 -1
- 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/{html2canvas-D0m9Y8pD-y1tDAhR3.js → html2canvas-NBfz6Rb9-CIdEBoH2.js} +1 -1
- package/client/assets/{index-gnbD2OHU.js → index-D6xzCZhP.js} +605 -604
- package/client/assets/index-DzMsXDnj.css +2 -0
- package/client/assets/{index.es-HSMDtZ_a-CYtNwG_G.js → index.es-STZX6pYP-BGvt3MI2.js} +3 -3
- package/client/assets/install-C1moNcrn.js +15 -0
- package/client/assets/japan-CyDc-cz7-BgORDJsl.js +1 -0
- package/client/assets/{marp-BVojbGre.js → marp-Du5Sym5b.js} +57 -57
- package/client/assets/module-ES6BEMUI-CDTIpMiA.js +6 -0
- package/client/assets/purify.es-DkTnAgLw-BZxk_wWO.js +3 -0
- package/client/assets/style-BZwVK1dO-BFtilmZh.js +1 -0
- package/client/assets/vue-i18n-YREco4q1.js +3 -0
- package/client/assets/world-R-tMZ4nZ-B1c4T9X2.js +1 -0
- package/client/index.html +6 -4
- package/package.json +13 -12
- package/server/api/routes/plugins.ts +9 -59
- package/server/build/mcp-server.mjs +806 -714
- package/server/index.ts +1 -0
- package/server/plugins/confined-file-ops.ts +56 -0
- 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/index-BA0fpWIZ.css +0 -2
- package/client/assets/purify.es-BaSjDiWD-Dbm5oOcm.js +0 -3
- package/client/assets/style-CCV5GAnU-D55tgtzm.js +0 -1
- package/client/assets/vue-i18n-2bgWpywp.js +0 -3
- 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,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DOM helpers for the mini-editor cell-highlight pass. Extracted from
|
|
3
|
-
* the post-flush watch in `src/plugins/spreadsheet/View.vue`, which
|
|
4
|
-
* had a cognitive complexity of 30 driven by four levels of nested
|
|
5
|
-
* optional chaining + loops.
|
|
6
|
-
*
|
|
7
|
-
* These helpers are side-effectful by nature (they add/remove CSS
|
|
8
|
-
* classes on DOM nodes) but each one is small enough that its
|
|
9
|
-
* behaviour is obvious. Unit-testable with a minimal mock DOM; see
|
|
10
|
-
* `test/plugins/spreadsheet/test_cellHighlights.ts`.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Minimal DOM surface the helpers need. Defined here so tests can
|
|
14
|
-
* pass plain objects without pulling in jsdom. */
|
|
15
|
-
export interface HighlightableElement {
|
|
16
|
-
classList: { add: (cls: string) => void; remove: (cls: string) => void };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface HighlightableRow {
|
|
20
|
-
querySelectorAll: (selector: string) => ArrayLike<HighlightableElement>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface HighlightableTable {
|
|
24
|
-
querySelectorAll: (selector: string) => ArrayLike<HighlightableRow>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface HighlightableContainer {
|
|
28
|
-
// Overload: the spreadsheet root container is known to return a
|
|
29
|
-
// table when asked for the table id, so callers can keep the
|
|
30
|
-
// result strongly typed without casting.
|
|
31
|
-
querySelector: ((selector: "#spreadsheet-table") => HighlightableTable | null) & ((selector: string) => HighlightableElement | null);
|
|
32
|
-
querySelectorAll: (selector: string) => ArrayLike<HighlightableElement> & Iterable<HighlightableElement>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface CellCoord {
|
|
36
|
-
row: number;
|
|
37
|
-
col: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const CELL_EDITING = "cell-editing";
|
|
41
|
-
const CELL_REFERENCED = "cell-referenced";
|
|
42
|
-
|
|
43
|
-
/** Remove both kinds of highlight classes from the container. */
|
|
44
|
-
export function clearCellHighlights(container: HighlightableContainer | null | undefined): void {
|
|
45
|
-
if (!container) return;
|
|
46
|
-
container.querySelector(`.${CELL_EDITING}`)?.classList.remove(CELL_EDITING);
|
|
47
|
-
for (const cell of container.querySelectorAll(`.${CELL_REFERENCED}`)) {
|
|
48
|
-
cell.classList.remove(CELL_REFERENCED);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Add `className` to the <td> at (row, col) of the given table.
|
|
53
|
-
* No-op if the row or cell doesn't exist. */
|
|
54
|
-
export function highlightCell(table: HighlightableTable | null | undefined, coord: CellCoord, className: string): void {
|
|
55
|
-
if (!table) return;
|
|
56
|
-
const rows = table.querySelectorAll("tr");
|
|
57
|
-
const row = rows[coord.row];
|
|
58
|
-
if (!row) return;
|
|
59
|
-
const cells = row.querySelectorAll("td");
|
|
60
|
-
const cell = cells[coord.col];
|
|
61
|
-
if (!cell) return;
|
|
62
|
-
cell.classList.add(className);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Apply the editing cell + referenced cells highlights. Looks up
|
|
66
|
-
* the #spreadsheet-table inside the container and no-ops if the
|
|
67
|
-
* table hasn't rendered yet. */
|
|
68
|
-
export function applyCellHighlights(
|
|
69
|
-
container: HighlightableContainer | null | undefined,
|
|
70
|
-
editingCell: CellCoord | null,
|
|
71
|
-
references: readonly CellCoord[],
|
|
72
|
-
): void {
|
|
73
|
-
if (!container) return;
|
|
74
|
-
const table = container.querySelector("#spreadsheet-table");
|
|
75
|
-
if (!table) return;
|
|
76
|
-
if (editingCell) highlightCell(table, editingCell, CELL_EDITING);
|
|
77
|
-
for (const ref of references) highlightCell(table, ref, CELL_REFERENCED);
|
|
78
|
-
}
|
|
@@ -1,427 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Spreadsheet Calculator
|
|
3
|
-
*
|
|
4
|
-
* Core calculation engine with circular reference detection and cross-sheet support
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { formatCellForDisplay } from "./cellFormatting";
|
|
8
|
-
import { evaluateFormula as evaluateFormulaFn } from "./evaluator";
|
|
9
|
-
import { expandRangeOrCell, parseSingleCellRef } from "./formulaRefs";
|
|
10
|
-
import { parseDate, getDefaultDateFormat } from "./date-parser";
|
|
11
|
-
import type { SheetData, CellValue, CalculatedSheet, CalculationError, FormulaInfo, SpreadsheetCell, CalculateOptions } from "./types";
|
|
12
|
-
import { isObj } from "../../../utils/types";
|
|
13
|
-
import { isEmptyCell } from "./cellEmpty";
|
|
14
|
-
import { errorMessage } from "../../../utils/errors";
|
|
15
|
-
import { classifyThrownError, invalidRefError } from "./formulaError";
|
|
16
|
-
import { isSpreadsheetErrorValue, spreadsheetError } from "./spreadsheet-errors";
|
|
17
|
-
|
|
18
|
-
// The grid a reference should read from, plus the sheet-name-stripped ref and
|
|
19
|
-
// whether it points at the sheet currently being calculated (which decides
|
|
20
|
-
// whether recursive formula evaluation is allowed for the cell it lands on).
|
|
21
|
-
type ResolvedSheetRef = { sheetData: (SpreadsheetCell | CellValue)[][]; ref: string; isCurrentSheet: boolean };
|
|
22
|
-
|
|
23
|
-
// Where a cell sits in the grid CURRENTLY being calculated, carrying the row it
|
|
24
|
-
// belongs to so a formula's result is written back without re-indexing. Only a
|
|
25
|
-
// same-sheet cell has one: a cross-sheet read is resolved by that sheet's own
|
|
26
|
-
// calculateSheet pass, so it has no position here to recurse from.
|
|
27
|
-
type CellPosition = { cells: any[]; row: number; col: number };
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Normalize malformed data structures
|
|
31
|
-
* Some models generate flat arrays instead of 2D arrays - fix them
|
|
32
|
-
*
|
|
33
|
-
* @param data - Potentially malformed sheet data
|
|
34
|
-
* @returns Normalized 2D array
|
|
35
|
-
*/
|
|
36
|
-
export function normalizeData(data: any): SpreadsheetCell[][] {
|
|
37
|
-
// Handle null/undefined
|
|
38
|
-
if (!data) {
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// If not an array, wrap in array
|
|
43
|
-
if (!Array.isArray(data)) {
|
|
44
|
-
return [];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Empty array
|
|
48
|
-
if (data.length === 0) {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// If data is already a 2D array, return as-is
|
|
53
|
-
if (Array.isArray(data[0])) {
|
|
54
|
-
return data as SpreadsheetCell[][];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// If data is a flat array of cell objects, convert to 2D by pairing cells
|
|
58
|
-
// Pattern: [cell1, cell2, cell3, cell4] -> [[cell1, cell2], [cell3, cell4]]
|
|
59
|
-
// This handles the case where models output flat arrays instead of rows
|
|
60
|
-
if (isObj(data[0])) {
|
|
61
|
-
const rows: SpreadsheetCell[][] = [];
|
|
62
|
-
for (let i = 0; i < data.length; i += 2) {
|
|
63
|
-
const row = [data[i]];
|
|
64
|
-
if (i + 1 < data.length) {
|
|
65
|
-
row.push(data[i + 1]);
|
|
66
|
-
}
|
|
67
|
-
rows.push(row);
|
|
68
|
-
}
|
|
69
|
-
return rows;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Unknown structure - return empty
|
|
73
|
-
console.warn("Unknown data structure in spreadsheet, returning empty:", data);
|
|
74
|
-
return [];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Pre-process sheet data to parse date strings into serial numbers
|
|
79
|
-
*
|
|
80
|
-
* @param data - Raw sheet data
|
|
81
|
-
* @returns Processed data with dates converted to serial numbers
|
|
82
|
-
*/
|
|
83
|
-
function preprocessDates(data: SpreadsheetCell[][], preferDDMMYYYY: boolean): SpreadsheetCell[][] {
|
|
84
|
-
return data.map((row) =>
|
|
85
|
-
row.map((cell) => {
|
|
86
|
-
// Skip if not a cell object or if it has a formula
|
|
87
|
-
if (!isObj(cell) || !("v" in cell)) {
|
|
88
|
-
return cell;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const value = cell.v;
|
|
92
|
-
|
|
93
|
-
// Only parse strings that aren't formulas
|
|
94
|
-
if (typeof value === "string" && !value.startsWith("=")) {
|
|
95
|
-
const dateSerial = parseDate(value, preferDDMMYYYY);
|
|
96
|
-
|
|
97
|
-
if (dateSerial !== null) {
|
|
98
|
-
// It's a date! Convert to serial number
|
|
99
|
-
return {
|
|
100
|
-
v: dateSerial,
|
|
101
|
-
f: cell.f || getDefaultDateFormat(value, preferDDMMYYYY), // Use existing format or detect from input
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Not a date, return as-is
|
|
107
|
-
return cell;
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// `skipFormatting` is internal, not a public knob: a cross-sheet reference
|
|
113
|
-
// computes its target sheet only to READ values, so the display-formatting pass
|
|
114
|
-
// is skipped there — a date must stay a serial, not become "03/04/2025" that a
|
|
115
|
-
// downstream parseFloat reads as 3 (issue #2332).
|
|
116
|
-
type SheetCalculateOptions = CalculateOptions & { skipFormatting?: boolean };
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Calculate formulas in a single sheet
|
|
120
|
-
*
|
|
121
|
-
* @param sheet - Sheet data to calculate
|
|
122
|
-
* @param allSheets - All sheets for cross-sheet references
|
|
123
|
-
* @returns Calculated sheet with formulas evaluated
|
|
124
|
-
*/
|
|
125
|
-
export function calculateSheet(sheet: SheetData, allSheets?: SheetData[], options: SheetCalculateOptions = {}): CalculatedSheet {
|
|
126
|
-
const preferDDMMYYYY = options.preferDDMMYYYY ?? false;
|
|
127
|
-
const skipFormatting = options.skipFormatting ?? false;
|
|
128
|
-
// Normalize malformed data structures first
|
|
129
|
-
const normalizedData = normalizeData(sheet.data);
|
|
130
|
-
|
|
131
|
-
// Pre-process dates before calculation
|
|
132
|
-
const processedData = preprocessDates(normalizedData, preferDDMMYYYY);
|
|
133
|
-
|
|
134
|
-
// Also preprocess all sheets if provided
|
|
135
|
-
const processedAllSheets = allSheets?.map((s) => ({
|
|
136
|
-
...s,
|
|
137
|
-
data: preprocessDates(normalizeData(s.data), preferDDMMYYYY),
|
|
138
|
-
}));
|
|
139
|
-
|
|
140
|
-
const data = processedData;
|
|
141
|
-
const sheetName = sheet.name;
|
|
142
|
-
// Cache stores either SpreadsheetCell[][] (before calculation) or CellValue[][] (after)
|
|
143
|
-
const sheetsCache = new Map<string, (SpreadsheetCell | CellValue)[][]>();
|
|
144
|
-
const errors: CalculationError[] = [];
|
|
145
|
-
const formulas: FormulaInfo[] = [];
|
|
146
|
-
|
|
147
|
-
// Create a copy of the data with calculated values
|
|
148
|
-
const calculated: any[][] = data.map((row) => [...row]);
|
|
149
|
-
|
|
150
|
-
// Add current sheet to cache to prevent infinite loops
|
|
151
|
-
sheetsCache.set(sheetName, calculated);
|
|
152
|
-
|
|
153
|
-
// Track cells being calculated to detect circular references
|
|
154
|
-
const calculating = new Set<string>();
|
|
155
|
-
// Cells whose result is already stored in `calculated`, of ANY type. A number
|
|
156
|
-
// check alone missed string/error results, so a cell referenced before the
|
|
157
|
-
// top loop reached it was re-evaluated (and, once formulas can throw, would
|
|
158
|
-
// re-emit its error). Membership here means "read the cached value, do not
|
|
159
|
-
// re-run".
|
|
160
|
-
const evaluated = new Set<string>();
|
|
161
|
-
|
|
162
|
-
// Evaluate one formula cell, guarding circular references, caching the result,
|
|
163
|
-
// and turning a thrown failure into a typed errors[] entry plus the Excel
|
|
164
|
-
// error value in the cell — never a swallowed bare string/number (#2359).
|
|
165
|
-
const resolveFormulaCell = (formulaText: string, { cells, row, col }: CellPosition): CellValue => {
|
|
166
|
-
const cellKey = `${row},${col}`;
|
|
167
|
-
if (calculating.has(cellKey)) {
|
|
168
|
-
errors.push({ cell: { row, col }, formula: formulaText, error: "Circular reference detected", type: "circular" });
|
|
169
|
-
return 0;
|
|
170
|
-
}
|
|
171
|
-
if (evaluated.has(cellKey)) return cells[col];
|
|
172
|
-
calculating.add(cellKey);
|
|
173
|
-
try {
|
|
174
|
-
const result = evaluateFormula(formulaText.substring(1)); // drop leading "="
|
|
175
|
-
cells[col] = result;
|
|
176
|
-
return result;
|
|
177
|
-
} catch (error) {
|
|
178
|
-
const { type, display } = classifyThrownError(error);
|
|
179
|
-
errors.push({ cell: { row, col }, formula: formulaText, error: errorMessage(error), type });
|
|
180
|
-
// The error VALUE, not its text: a cell that reads this one must see a
|
|
181
|
-
// real error, and the display pass renders it back to `#DIV/0!`.
|
|
182
|
-
const errorValue = spreadsheetError(display);
|
|
183
|
-
cells[col] = errorValue;
|
|
184
|
-
return errorValue;
|
|
185
|
-
} finally {
|
|
186
|
-
calculating.delete(cellKey);
|
|
187
|
-
evaluated.add(cellKey);
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
// Helper to extract raw value from cell with recursive formula evaluation
|
|
192
|
-
const getRawValue = (cell: any, position?: CellPosition): CellValue => {
|
|
193
|
-
// Handle null/undefined cells - treat as 0
|
|
194
|
-
if (cell === null || cell === undefined) return 0;
|
|
195
|
-
|
|
196
|
-
// An already-calculated cell can hold a formula error; it stays an error.
|
|
197
|
-
if (isSpreadsheetErrorValue(cell)) return cell;
|
|
198
|
-
|
|
199
|
-
if (typeof cell === "number") return cell;
|
|
200
|
-
|
|
201
|
-
// Handle string values (for legacy or calculated cells)
|
|
202
|
-
if (typeof cell === "string") {
|
|
203
|
-
// Handle empty strings as 0
|
|
204
|
-
if (cell.trim() === "") return 0;
|
|
205
|
-
|
|
206
|
-
// Handle percentage strings like "5%" or "0.4167%"
|
|
207
|
-
if (cell.includes("%")) {
|
|
208
|
-
const numericPart = cell.replace("%", "").trim();
|
|
209
|
-
const value = parseFloat(numericPart);
|
|
210
|
-
return isNaN(value) ? 0 : value / 100;
|
|
211
|
-
}
|
|
212
|
-
// Handle currency strings like "$1,000" or "$1,000.00"
|
|
213
|
-
if (cell.includes("$")) {
|
|
214
|
-
const numericPart = cell.replace(/[$,]/g, "").trim();
|
|
215
|
-
const value = parseFloat(numericPart);
|
|
216
|
-
return isNaN(value) ? 0 : value;
|
|
217
|
-
}
|
|
218
|
-
// Handle comma-separated numbers like "1,000"
|
|
219
|
-
if (cell.includes(",")) {
|
|
220
|
-
const numericPart = cell.replace(/,/g, "").trim();
|
|
221
|
-
const value = parseFloat(numericPart);
|
|
222
|
-
return isNaN(value) ? 0 : value;
|
|
223
|
-
}
|
|
224
|
-
// Handle regular numeric strings, but preserve non-numeric strings
|
|
225
|
-
const num = parseFloat(cell);
|
|
226
|
-
return isNaN(num) ? cell : num;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Handle new cell format {v, f}
|
|
230
|
-
if (isObj(cell) && "v" in cell) {
|
|
231
|
-
const value = cell.v;
|
|
232
|
-
// If value is a string starting with "=", it's a formula
|
|
233
|
-
if (typeof value === "string" && value.startsWith("=")) {
|
|
234
|
-
// Only evaluatable when we know the cell's position (for recursion +
|
|
235
|
-
// circular tracking); otherwise treat as 0.
|
|
236
|
-
return position ? resolveFormulaCell(value, position) : 0;
|
|
237
|
-
}
|
|
238
|
-
// Try to parse as number, but preserve original type on failure
|
|
239
|
-
if (typeof value === "number") return value;
|
|
240
|
-
if (typeof value === "boolean") return value;
|
|
241
|
-
if (typeof value === "string") {
|
|
242
|
-
const num = parseFloat(value);
|
|
243
|
-
return isNaN(num) ? value : num;
|
|
244
|
-
}
|
|
245
|
-
return String(value);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Try to parse cell as number, but preserve strings
|
|
249
|
-
const num = parseFloat(cell);
|
|
250
|
-
return isNaN(num) ? cell : num;
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
// Resolve a possibly cross-sheet reference to the grid it reads from, plus the
|
|
254
|
-
// sheet-name-stripped ref. A same-sheet ref returns the current `calculated`
|
|
255
|
-
// grid; a cross-sheet ref computes and caches its target sheet. null means the
|
|
256
|
-
// named sheet does not exist — the caller picks the terminal action (#REF! for
|
|
257
|
-
// a single cell, [] for a range). The two-stage cache seed (raw copy published
|
|
258
|
-
// BEFORE recursing, real result after) is the cross-sheet infinite-loop guard.
|
|
259
|
-
const resolveSheetData = (fullRef: string): ResolvedSheetRef | null => {
|
|
260
|
-
const sheetMatch = fullRef.match(/^(?:'([^']+)'|([^!]+))!(.+)$/);
|
|
261
|
-
if (!sheetMatch) return { sheetData: calculated, ref: fullRef, isCurrentSheet: true };
|
|
262
|
-
|
|
263
|
-
// Exactly one of the two name branches participates; the reference part
|
|
264
|
-
// always does. A shortfall would mean the pattern and this read disagree, so
|
|
265
|
-
// it lands on the caller's "sheet not found" path rather than a guess.
|
|
266
|
-
const [, quotedName, plainName, innerRef] = sheetMatch;
|
|
267
|
-
const targetSheetName = quotedName ?? plainName;
|
|
268
|
-
if (targetSheetName === undefined || innerRef === undefined) return null;
|
|
269
|
-
|
|
270
|
-
// Check cache first to prevent infinite loops
|
|
271
|
-
const cached = sheetsCache.get(targetSheetName);
|
|
272
|
-
if (cached) return { sheetData: cached, ref: innerRef, isCurrentSheet: false };
|
|
273
|
-
|
|
274
|
-
const targetSheet = processedAllSheets?.find((s) => s.name === targetSheetName);
|
|
275
|
-
if (!targetSheet || !targetSheet.data) return null;
|
|
276
|
-
|
|
277
|
-
// Seed the cache with a raw copy BEFORE recursing so a cyclic back-reference
|
|
278
|
-
// finds this sheet mid-flight, then overwrite it with the calculated result.
|
|
279
|
-
// Resolve cross-sheet values RAW (skip display formatting) so a date cell
|
|
280
|
-
// reads as its serial, not the presentation string "03/04/2025".
|
|
281
|
-
const targetCalculated = targetSheet.data.map((row) => [...row]);
|
|
282
|
-
sheetsCache.set(targetSheetName, targetCalculated);
|
|
283
|
-
const targetResult = calculateSheet(targetSheet, processedAllSheets, { preferDDMMYYYY, skipFormatting: true });
|
|
284
|
-
sheetsCache.set(targetSheetName, targetResult.data);
|
|
285
|
-
return { sheetData: targetResult.data, ref: innerRef, isCurrentSheet: false };
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
// Helper to get cell value by reference (e.g., "B2", "$B$2", or "'Sheet1'!B2")
|
|
289
|
-
const getCellValue = (ref: string): CellValue => {
|
|
290
|
-
const resolved = resolveSheetData(ref);
|
|
291
|
-
if (!resolved) throw invalidRefError(ref); // Sheet not found → #REF!
|
|
292
|
-
const { sheetData, ref: cellRef, isCurrentSheet } = resolved;
|
|
293
|
-
|
|
294
|
-
// `$` symbols and the A1 shape are parsed by the shared single-cell reader.
|
|
295
|
-
const coord = parseSingleCellRef(cellRef);
|
|
296
|
-
if (!coord) return 0;
|
|
297
|
-
|
|
298
|
-
const { row, col } = coord;
|
|
299
|
-
const gridRow = row >= 0 ? sheetData[row] : undefined;
|
|
300
|
-
if (!gridRow || col < 0 || col >= gridRow.length) return 0;
|
|
301
|
-
|
|
302
|
-
// Pass the position only if this is the current sheet (for recursive evaluation)
|
|
303
|
-
return getRawValue(gridRow[col], isCurrentSheet ? { cells: gridRow, row, col } : undefined);
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
const collectRangeValues = (range: string, options: { numericOnly: boolean }): CellValue[] => {
|
|
307
|
-
const resolved = resolveSheetData(range);
|
|
308
|
-
if (!resolved) return []; // Sheet not found → empty range
|
|
309
|
-
const { sheetData, ref: rangeRef, isCurrentSheet } = resolved;
|
|
310
|
-
|
|
311
|
-
const coords = expandRangeOrCell(rangeRef);
|
|
312
|
-
if (!coords) return [];
|
|
313
|
-
|
|
314
|
-
const values: CellValue[] = [];
|
|
315
|
-
for (const { row, col } of coords) {
|
|
316
|
-
const gridRow = row >= 0 ? sheetData[row] : undefined;
|
|
317
|
-
if (gridRow && col >= 0 && col < gridRow.length) {
|
|
318
|
-
const cell = gridRow[col];
|
|
319
|
-
// Pass the position only if current sheet (for recursive evaluation)
|
|
320
|
-
const rawValue = getRawValue(cell, isCurrentSheet ? { cells: gridRow, row, col } : undefined);
|
|
321
|
-
|
|
322
|
-
if (options.numericOnly) {
|
|
323
|
-
// A blank cell is not a value. Dropping it from the NUMERIC list keeps
|
|
324
|
-
// SUM unchanged (a blank read as 0) while stopping it from inflating
|
|
325
|
-
// AVERAGE's denominator and COUNT's tally (#2358). The raw list keeps
|
|
326
|
-
// every cell so SUMIF/AVERAGEIF's criteria and value ranges stay
|
|
327
|
-
// row-aligned; dropping there would shift indexes and aggregate the
|
|
328
|
-
// wrong rows (Codex review).
|
|
329
|
-
if (!isEmptyCell(cell) && !isNaN(rawValue as number)) {
|
|
330
|
-
values.push(rawValue);
|
|
331
|
-
}
|
|
332
|
-
} else {
|
|
333
|
-
values.push(rawValue);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return values;
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
// Helper to get numeric-only range values (legacy behavior)
|
|
341
|
-
const getRangeValues = (range: string): CellValue[] => collectRangeValues(range, { numericOnly: true });
|
|
342
|
-
|
|
343
|
-
// Helper to get raw range values including text
|
|
344
|
-
const getRangeValuesRaw = (range: string): CellValue[] => collectRangeValues(range, { numericOnly: false });
|
|
345
|
-
|
|
346
|
-
// Evaluate a formula with context
|
|
347
|
-
const evaluateFormula = (formula: string): CellValue => {
|
|
348
|
-
return evaluateFormulaFn(formula, {
|
|
349
|
-
getCellValue,
|
|
350
|
-
getRangeValues,
|
|
351
|
-
getRangeValuesRaw,
|
|
352
|
-
evaluateFormula,
|
|
353
|
-
preferDDMMYYYY,
|
|
354
|
-
});
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
// Compute one cell into its calculated row. A cell not in {v, f} format is
|
|
358
|
-
// left as-is (it is already a plain value).
|
|
359
|
-
const calculateCell = (originalCell: SpreadsheetCell, position: CellPosition): void => {
|
|
360
|
-
if (!isObj(originalCell) || !("v" in originalCell)) return;
|
|
361
|
-
const value = originalCell.v;
|
|
362
|
-
|
|
363
|
-
// A plain value is copied through, so range evaluation reads it.
|
|
364
|
-
if (typeof value !== "string" || !value.startsWith("=")) {
|
|
365
|
-
position.cells[position.col] = value;
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const info: FormulaInfo = {
|
|
370
|
-
cell: { row: position.row, col: position.col },
|
|
371
|
-
formula: value,
|
|
372
|
-
dependencies: [], // TODO: Extract dependencies from formula
|
|
373
|
-
result: 0, // Will be updated below
|
|
374
|
-
};
|
|
375
|
-
formulas.push(info);
|
|
376
|
-
// Route through the protected path so a thrown failure is classified into
|
|
377
|
-
// errors[] instead of escaping this walk, and a cell already resolved via
|
|
378
|
-
// another formula's recursion is read from cache (#2359).
|
|
379
|
-
info.result = resolveFormulaCell(value, position);
|
|
380
|
-
// Store result as-is (formatting will be applied at the end)
|
|
381
|
-
position.cells[position.col] = info.result;
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
// Walk every cell of the grid. `calculated` is built as a row-for-row copy of
|
|
385
|
-
// `data` and never resized, so a missing row cannot happen; skipping one keeps
|
|
386
|
-
// the walk total rather than asserting the invariant at each cell.
|
|
387
|
-
const forEachCell = (visit: (originalCell: SpreadsheetCell, position: CellPosition) => void): void => {
|
|
388
|
-
data.forEach((dataRow, row) => {
|
|
389
|
-
const cells = calculated[row];
|
|
390
|
-
if (!cells) return;
|
|
391
|
-
dataRow.forEach((originalCell, col) => visit(originalCell, { cells, row, col }));
|
|
392
|
-
});
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
// Process all cells and calculate formulas. A cell already evaluated through
|
|
396
|
-
// another formula's recursion keeps its number; formatting comes at the end.
|
|
397
|
-
forEachCell((originalCell, position) => {
|
|
398
|
-
const alreadyCalculated = typeof position.cells[position.col] === "number" && isObj(originalCell) && "f" in originalCell;
|
|
399
|
-
if (!alreadyCalculated) calculateCell(originalCell, position);
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// Final display-formatting pass: turn raw serials into presentation strings.
|
|
403
|
-
// Skipped when this sheet is computed only to resolve a cross-sheet reference,
|
|
404
|
-
// so the referencing cell reads the underlying value, not a display string.
|
|
405
|
-
if (!skipFormatting) {
|
|
406
|
-
forEachCell((originalCell, { cells, col }) => {
|
|
407
|
-
cells[col] = formatCellForDisplay(originalCell, cells[col], preferDDMMYYYY);
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return {
|
|
412
|
-
name: sheetName,
|
|
413
|
-
data: calculated,
|
|
414
|
-
formulas,
|
|
415
|
-
errors,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Calculate all sheets in a workbook
|
|
421
|
-
*
|
|
422
|
-
* @param sheets - Array of sheets to calculate
|
|
423
|
-
* @returns Array of calculated sheets
|
|
424
|
-
*/
|
|
425
|
-
export function calculateWorkbook(sheets: SheetData[], options: CalculateOptions = {}): CalculatedSheet[] {
|
|
426
|
-
return sheets.map((sheet) => calculateSheet(sheet, sheets, options));
|
|
427
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build a SpreadsheetCell from the raw input captured by the mini
|
|
3
|
-
* editor (type + value / formula / format). Extracted from
|
|
4
|
-
* `saveMiniEditor` in `src/plugins/spreadsheet/View.vue` where it
|
|
5
|
-
* was inlined as ~30 lines of nested if/else that pushed the
|
|
6
|
-
* surrounding function over the cognitive-complexity threshold.
|
|
7
|
-
*
|
|
8
|
-
* Pure — no refs, no DOM, no side effects. Given the same inputs
|
|
9
|
-
* it always returns the same SpreadsheetCell. Tested in
|
|
10
|
-
* `test/plugins/spreadsheet/engine/test_cellBuilder.ts`.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { SpreadsheetCell } from "./types.js";
|
|
14
|
-
import { isCallableName } from "./registry.js";
|
|
15
|
-
// Registers the functions, so isCallableName knows LOG10 before any sheet is calculated.
|
|
16
|
-
import "./functions/index.js";
|
|
17
|
-
|
|
18
|
-
/** Inputs to the cell builder. Mirrors the mini editor refs in the
|
|
19
|
-
* View but as plain values so unit tests don't need a Vue runtime. */
|
|
20
|
-
export interface MiniEditorInput {
|
|
21
|
-
/** "string" → value is stored as-is as a string.
|
|
22
|
-
* Anything else → the `formula` field is parsed (formula / number / raw string). */
|
|
23
|
-
type: string;
|
|
24
|
-
/** Used when type === "string". Coerced to string. */
|
|
25
|
-
value: unknown;
|
|
26
|
-
/** Used when type !== "string". Trimmed before classification. */
|
|
27
|
-
formula?: string;
|
|
28
|
-
/** Optional format code (e.g. "$#,##0.00"). */
|
|
29
|
-
format?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Anchored at the start of the input (after optional unary +/-) so we
|
|
33
|
-
// only treat expressions that clearly begin with a function call as
|
|
34
|
-
// formulas. Unanchored would match "abc FOO(" inside ordinary text.
|
|
35
|
-
const FORMULA_FUNCTION_CALL = /^[-+]?\s*([A-Z][A-Z0-9]*)\s*\(/i;
|
|
36
|
-
|
|
37
|
-
const startsWithFunctionCall = (input: string): boolean => {
|
|
38
|
-
const name = input.match(FORMULA_FUNCTION_CALL)?.[1];
|
|
39
|
-
return name !== undefined && isCallableName(name);
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// `A1 + B2` style — cell reference next to an arithmetic operator.
|
|
43
|
-
const FORMULA_CELL_OP = /[A-Z]+\d+\s*[+\-*/^]/;
|
|
44
|
-
|
|
45
|
-
// `6/100`, `5 * 2` — arithmetic between two literal numbers.
|
|
46
|
-
const FORMULA_NUMERIC_OP = /\d+\s*[+\-*/^]\s*\d+/;
|
|
47
|
-
|
|
48
|
-
// Strict numeric literal. `parseFloat` accepts trailing junk
|
|
49
|
-
// ("42abc" → 42) which silently corrupts user input; this anchor
|
|
50
|
-
// ensures the ENTIRE trimmed string is a number.
|
|
51
|
-
const STRICT_NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Best-effort formula detection. The rules are conservative enough
|
|
55
|
-
* that plain text like "hello world" stays as text, but any input
|
|
56
|
-
* with arithmetic operators or function calls is treated as a
|
|
57
|
-
* formula and gets the "=" prefix the engine expects.
|
|
58
|
-
*/
|
|
59
|
-
export function looksLikeFormula(input: string): boolean {
|
|
60
|
-
return startsWithFunctionCall(input) || FORMULA_CELL_OP.test(input) || FORMULA_NUMERIC_OP.test(input);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Parse the raw (non-string-type) editor input into a cell value.
|
|
65
|
-
* Priority: formula > number > raw string > empty string.
|
|
66
|
-
*/
|
|
67
|
-
export function parseNonStringInput(raw: string): number | string {
|
|
68
|
-
const input = raw.trim();
|
|
69
|
-
if (input === "") return "";
|
|
70
|
-
if (looksLikeFormula(input)) return `=${input}`;
|
|
71
|
-
return STRICT_NUMBER.test(input) ? Number(input) : input;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Build the full SpreadsheetCell from a mini editor input record.
|
|
76
|
-
* String type short-circuits to `{ v: String(value) }`.
|
|
77
|
-
* Everything else goes through parseNonStringInput for formula /
|
|
78
|
-
* number / text classification, then optionally attaches `f`.
|
|
79
|
-
*/
|
|
80
|
-
export function buildCellFromInput(input: MiniEditorInput): SpreadsheetCell {
|
|
81
|
-
if (input.type === "string") {
|
|
82
|
-
return { v: String(input.value) };
|
|
83
|
-
}
|
|
84
|
-
const cell: SpreadsheetCell = { v: parseNonStringInput(input.formula ?? "") };
|
|
85
|
-
if (input.format && input.format.length > 0) {
|
|
86
|
-
cell.f = input.format;
|
|
87
|
-
}
|
|
88
|
-
return cell;
|
|
89
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telling a genuinely empty cell apart from one that holds the number 0.
|
|
3
|
-
*
|
|
4
|
-
* The calculator reads a blank cell as 0 for arithmetic (`=A1+1` on a blank A1
|
|
5
|
-
* is 1, as in Excel). But an aggregate must not: `AVERAGE` divides by the count
|
|
6
|
-
* of real values, and `COUNT` counts numbers — a blank that reads as 0 inflates
|
|
7
|
-
* the denominator and the count. So range collection needs to skip the blanks,
|
|
8
|
-
* which means distinguishing them from a stored 0, which this does.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { isObj } from "../../../utils/types";
|
|
12
|
-
|
|
13
|
-
/** True when a cell holds no value at all — absent, null, or an empty/whitespace
|
|
14
|
-
* string, in either the bare or the `{ v }` form. A cell containing the number
|
|
15
|
-
* 0, `false`, or any non-empty text is NOT empty. */
|
|
16
|
-
export function isEmptyCell(cell: unknown): boolean {
|
|
17
|
-
if (cell === null || cell === undefined) return true;
|
|
18
|
-
if (typeof cell === "string") return cell.trim() === "";
|
|
19
|
-
if (isObj(cell)) {
|
|
20
|
-
if (!("v" in cell)) return true;
|
|
21
|
-
const value = (cell as { v: unknown }).v;
|
|
22
|
-
if (value === null || value === undefined) return true;
|
|
23
|
-
return typeof value === "string" && value.trim() === "";
|
|
24
|
-
}
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cell display formatting
|
|
3
|
-
*
|
|
4
|
-
* Turns a cell's raw calculated value into its display value (currency,
|
|
5
|
-
* percentage, date, ...). Pure — no engine state — so cross-sheet reference
|
|
6
|
-
* resolution can deliberately SKIP it and keep raw serial numbers, while the
|
|
7
|
-
* final output pass applies it for presentation.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { formatNumber } from "./formatter";
|
|
11
|
-
import { isRecord } from "../../../utils/types";
|
|
12
|
-
import { isSpreadsheetErrorValue } from "./spreadsheet-errors";
|
|
13
|
-
import type { CellValue, SpreadsheetCell, StoredCellValue } from "./types";
|
|
14
|
-
|
|
15
|
-
// Integer serials the engine is willing to auto-format as dates without an
|
|
16
|
-
// explicit format code: ~Jul 1998 (36000) through ~Dec 2073 (63499). Narrow on
|
|
17
|
-
// purpose so ordinary sums/averages are not mistaken for dates.
|
|
18
|
-
const DATE_SERIAL_MIN = 36000;
|
|
19
|
-
const DATE_SERIAL_MAX = 63499;
|
|
20
|
-
|
|
21
|
-
const isSpreadsheetCell = (value: unknown): value is SpreadsheetCell => isRecord(value) && "v" in value;
|
|
22
|
-
|
|
23
|
-
/** An integer within the date-serial window — a calculated number the engine
|
|
24
|
-
* should display as a date when the cell carries no explicit format. */
|
|
25
|
-
export const isLikelyDateSerial = (value: CellValue): boolean =>
|
|
26
|
-
typeof value === "number" && Number.isInteger(value) && value >= DATE_SERIAL_MIN && value <= DATE_SERIAL_MAX;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Resolve the display value of one cell from its original definition and its
|
|
30
|
-
* calculated value.
|
|
31
|
-
*
|
|
32
|
-
* - A formula error renders as its code, so the cell still reads `#NUM!`.
|
|
33
|
-
* - Explicit format code wins (currency, percentage, date, ...).
|
|
34
|
-
* - A formula that produced a date serial auto-formats as a date.
|
|
35
|
-
* - Everything else (text, plain numbers, empty) passes through unchanged.
|
|
36
|
-
*
|
|
37
|
-
* The result is always a STORED value: this is the boundary where a computed
|
|
38
|
-
* error becomes the text a cell shows and a workbook serializes.
|
|
39
|
-
*/
|
|
40
|
-
export const formatCellForDisplay = (originalCell: unknown, calculatedValue: CellValue, preferDDMMYYYY: boolean): StoredCellValue => {
|
|
41
|
-
if (isSpreadsheetErrorValue(calculatedValue)) {
|
|
42
|
-
return calculatedValue.code;
|
|
43
|
-
}
|
|
44
|
-
if (!isSpreadsheetCell(originalCell) || typeof calculatedValue !== "number") {
|
|
45
|
-
return calculatedValue;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const explicitFormat = typeof originalCell.f === "string" ? originalCell.f : "";
|
|
49
|
-
if (explicitFormat) {
|
|
50
|
-
return formatNumber(calculatedValue, explicitFormat);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const isFormula = typeof originalCell.v === "string" && originalCell.v.startsWith("=");
|
|
54
|
-
if (isFormula && isLikelyDateSerial(calculatedValue)) {
|
|
55
|
-
return formatNumber(calculatedValue, preferDDMMYYYY ? "DD/MM/YYYY" : "MM/DD/YYYY");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return calculatedValue;
|
|
59
|
-
};
|