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.
Files changed (86) hide show
  1. package/client/assets/Graph-ChAxkn9o.js +1 -0
  2. package/client/assets/{JsonEditor-FKfzzMkt.js → JsonEditor-COQcmjLQ.js} +1 -1
  3. package/client/assets/dist-Bij44LdC.js +2 -0
  4. package/client/assets/echarts-GV3U9sVf.js +1 -0
  5. package/client/assets/echarts-gl-BXx4On0Y.js +4282 -0
  6. package/client/assets/echarts-wordcloud-_9uQUAfp.js +1 -0
  7. package/client/assets/emscripten-module-Q67P5WYC-BU3Hlttz.js +2160 -0
  8. package/client/assets/ffi-4V2b7xu3.js +1 -0
  9. package/client/assets/{html2canvas-D0m9Y8pD-y1tDAhR3.js → html2canvas-NBfz6Rb9-CIdEBoH2.js} +1 -1
  10. package/client/assets/{index-gnbD2OHU.js → index-D6xzCZhP.js} +605 -604
  11. package/client/assets/index-DzMsXDnj.css +2 -0
  12. package/client/assets/{index.es-HSMDtZ_a-CYtNwG_G.js → index.es-STZX6pYP-BGvt3MI2.js} +3 -3
  13. package/client/assets/install-C1moNcrn.js +15 -0
  14. package/client/assets/japan-CyDc-cz7-BgORDJsl.js +1 -0
  15. package/client/assets/{marp-BVojbGre.js → marp-Du5Sym5b.js} +57 -57
  16. package/client/assets/module-ES6BEMUI-CDTIpMiA.js +6 -0
  17. package/client/assets/purify.es-DkTnAgLw-BZxk_wWO.js +3 -0
  18. package/client/assets/style-BZwVK1dO-BFtilmZh.js +1 -0
  19. package/client/assets/vue-i18n-YREco4q1.js +3 -0
  20. package/client/assets/world-R-tMZ4nZ-B1c4T9X2.js +1 -0
  21. package/client/index.html +6 -4
  22. package/package.json +13 -12
  23. package/server/api/routes/plugins.ts +9 -59
  24. package/server/build/mcp-server.mjs +806 -714
  25. package/server/index.ts +1 -0
  26. package/server/plugins/confined-file-ops.ts +56 -0
  27. package/server/plugins/spreadsheet-builtin.ts +26 -0
  28. package/server/plugins/spreadsheet-save-outcome.ts +7 -0
  29. package/server/utils/files/store-resolvers.ts +1 -1
  30. package/src/lang/de.ts +0 -20
  31. package/src/lang/en.ts +0 -20
  32. package/src/lang/es.ts +0 -20
  33. package/src/lang/fr.ts +0 -20
  34. package/src/lang/ja.ts +0 -20
  35. package/src/lang/ko.ts +0 -20
  36. package/src/lang/pt-BR.ts +0 -20
  37. package/src/lang/zh.ts +0 -20
  38. package/src/plugins/spreadsheet/definition.ts +9 -127
  39. package/src/plugins/spreadsheet/index.ts +7 -4
  40. package/src/plugins/spreadsheet/meta.ts +2 -4
  41. package/client/assets/index-BA0fpWIZ.css +0 -2
  42. package/client/assets/purify.es-BaSjDiWD-Dbm5oOcm.js +0 -3
  43. package/client/assets/style-CCV5GAnU-D55tgtzm.js +0 -1
  44. package/client/assets/vue-i18n-2bgWpywp.js +0 -3
  45. package/server/utils/files/spreadsheet-store.ts +0 -26
  46. package/src/plugins/spreadsheet/Preview.vue +0 -31
  47. package/src/plugins/spreadsheet/View.vue +0 -1007
  48. package/src/plugins/spreadsheet/cellHighlights.ts +0 -78
  49. package/src/plugins/spreadsheet/engine/calculator.ts +0 -427
  50. package/src/plugins/spreadsheet/engine/cellBuilder.ts +0 -89
  51. package/src/plugins/spreadsheet/engine/cellEmpty.ts +0 -26
  52. package/src/plugins/spreadsheet/engine/cellFormatting.ts +0 -59
  53. package/src/plugins/spreadsheet/engine/coerce-boolean.ts +0 -26
  54. package/src/plugins/spreadsheet/engine/condition.ts +0 -182
  55. package/src/plugins/spreadsheet/engine/date-locale.ts +0 -47
  56. package/src/plugins/spreadsheet/engine/date-parser.ts +0 -234
  57. package/src/plugins/spreadsheet/engine/date-utils.ts +0 -73
  58. package/src/plugins/spreadsheet/engine/datedif.ts +0 -93
  59. package/src/plugins/spreadsheet/engine/engine.ts +0 -177
  60. package/src/plugins/spreadsheet/engine/evaluator.ts +0 -494
  61. package/src/plugins/spreadsheet/engine/financial-math.ts +0 -114
  62. package/src/plugins/spreadsheet/engine/formatter.ts +0 -158
  63. package/src/plugins/spreadsheet/engine/formulaError.ts +0 -67
  64. package/src/plugins/spreadsheet/engine/formulaRefs.ts +0 -202
  65. package/src/plugins/spreadsheet/engine/functions/date.ts +0 -212
  66. package/src/plugins/spreadsheet/engine/functions/financial.ts +0 -244
  67. package/src/plugins/spreadsheet/engine/functions/index.ts +0 -16
  68. package/src/plugins/spreadsheet/engine/functions/logical.ts +0 -221
  69. package/src/plugins/spreadsheet/engine/functions/lookup-math.ts +0 -28
  70. package/src/plugins/spreadsheet/engine/functions/lookup.ts +0 -257
  71. package/src/plugins/spreadsheet/engine/functions/mathematical.ts +0 -283
  72. package/src/plugins/spreadsheet/engine/functions/statistical-math.ts +0 -91
  73. package/src/plugins/spreadsheet/engine/functions/statistical.ts +0 -290
  74. package/src/plugins/spreadsheet/engine/functions/text.ts +0 -398
  75. package/src/plugins/spreadsheet/engine/index.ts +0 -34
  76. package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +0 -111
  77. package/src/plugins/spreadsheet/engine/math-ops.ts +0 -98
  78. package/src/plugins/spreadsheet/engine/numericCoercion.ts +0 -61
  79. package/src/plugins/spreadsheet/engine/parser.ts +0 -40
  80. package/src/plugins/spreadsheet/engine/registry.ts +0 -201
  81. package/src/plugins/spreadsheet/engine/responseDecoder.ts +0 -68
  82. package/src/plugins/spreadsheet/engine/spreadsheet-errors.ts +0 -82
  83. package/src/plugins/spreadsheet/engine/textFormat.ts +0 -108
  84. package/src/plugins/spreadsheet/engine/translateFormula.ts +0 -69
  85. package/src/plugins/spreadsheet/engine/types.ts +0 -81
  86. package/src/plugins/spreadsheet/keyboardNav.ts +0 -38
@@ -1,290 +0,0 @@
1
- /**
2
- * Statistical Functions
3
- */
4
-
5
- import {
6
- functionRegistry,
7
- rawRangeReader,
8
- requiredArg,
9
- toNumber,
10
- parseCriteria,
11
- type FunctionContext,
12
- type FunctionHandler,
13
- type RangeGetter,
14
- } from "../registry";
15
- import { computeAverage, computeMedian, computeMode, sampleStdev, sampleVariance } from "./statistical-math";
16
- import { DIV_ZERO_ERROR } from "../spreadsheet-errors";
17
- import { holdsNumber } from "../numericCoercion";
18
- import type { CellValue } from "../types";
19
-
20
- // Excel accepts up to 255 arguments for its aggregate functions.
21
- const MAX_AGGREGATE_ARGS = 255;
22
-
23
- // `A1`, `$A$1`, `AA100`: column letters then row digits, each half optionally
24
- // prefixed by `$`.
25
- const CELL_REFERENCE_PATTERN = /^\$?[A-Z]+\$?\d+$/i;
26
-
27
- const isCellReference = (segment: string): boolean => CELL_REFERENCE_PATTERN.test(segment);
28
-
29
- // Everything after the last `!` — the reference without its sheet name, or the
30
- // whole string when it carries none.
31
- const withoutSheetPrefix = (value: string): string => value.slice(value.lastIndexOf("!") + 1);
32
-
33
- const isRangeReference = (value: string): boolean => {
34
- if (!value) return false;
35
- const [start, end] = withoutSheetPrefix(value).split(":");
36
- if (!start || !end) return false;
37
- return isCellReference(start) && isCellReference(end);
38
- };
39
-
40
- // A bare cell reference (`A1`, `Sheet1!B2`) is read through the RANGE path, not
41
- // evaluated as a scalar: the scalar path coerces a blank or text cell to 0, so
42
- // COUNT(A999) counted an empty cell as a value. The range path yields nothing
43
- // for a cell that holds nothing, which is what the count functions need.
44
- const isReference = (arg: string): boolean => isRangeReference(arg) || isCellReference(withoutSheetPrefix(arg));
45
-
46
- /** One value an argument contributed, tagged by where it came from. A range cell
47
- * was already filtered by the range getter; a scalar is whatever the argument
48
- * evaluated to and may hold no number at all. */
49
- type ArgumentValue = { value: CellValue; isScalar: boolean };
50
-
51
- const collectArgumentValues = (args: string[], context: FunctionContext, readRange: RangeGetter): ArgumentValue[] => {
52
- const collected: ArgumentValue[] = [];
53
-
54
- for (const rawArg of args) {
55
- const arg = rawArg?.trim();
56
- if (!arg) continue;
57
-
58
- if (isReference(arg)) {
59
- readRange(arg).forEach((value) => collected.push({ value, isScalar: false }));
60
- } else {
61
- collected.push({ value: context.evaluateFormula(arg), isScalar: true });
62
- }
63
- }
64
-
65
- return collected;
66
- };
67
-
68
- const collectNumericValues = (args: string[], context: FunctionContext): number[] =>
69
- collectArgumentValues(args, context, context.getRangeValues).map(({ value }) => toNumber(value));
70
-
71
- // Same walk as `collectNumericValues`, but keeping each cell as it is: COUNTA
72
- // counts non-empty cells, so text must survive the trip.
73
- const collectRawValues = (args: string[], context: FunctionContext): CellValue[] =>
74
- collectArgumentValues(args, context, rawRangeReader(context)).map(({ value }) => value);
75
-
76
- const sumHandler: FunctionHandler = (args, context) => {
77
- const values = collectNumericValues(args, context);
78
- return values.reduce((sum: number, value) => sum + value, 0);
79
- };
80
-
81
- // Multi-argument collection (#2360) feeding the empty-range error rule (#2501).
82
- const averageHandler: FunctionHandler = (args, context) => computeAverage(collectNumericValues(args, context));
83
-
84
- const maxHandler: FunctionHandler = (args, context) => {
85
- const values = collectNumericValues(args, context);
86
- return values.length > 0 ? Math.max(...values) : 0;
87
- };
88
-
89
- const minHandler: FunctionHandler = (args, context) => {
90
- const values = collectNumericValues(args, context);
91
- return values.length > 0 ? Math.min(...values) : 0;
92
- };
93
-
94
- // COUNT counts NUMBERS, so it cannot go through the lenient numeric collection
95
- // the other aggregates share: `toNumber("text")` is 0, which made COUNT("text")
96
- // answer 1 where Excel answers 0 (Codex review). A range cell reached the list
97
- // only by being numeric already; a scalar has to be asked.
98
- const countsAsNumber = ({ value, isScalar }: ArgumentValue): boolean => !isScalar || holdsNumber(value);
99
-
100
- const countHandler: FunctionHandler = (args, context) => collectArgumentValues(args, context, context.getRangeValues).filter(countsAsNumber).length;
101
-
102
- const medianHandler: FunctionHandler = (args, context) => computeMedian(collectNumericValues(args, context));
103
-
104
- const modeHandler: FunctionHandler = (args, context) => {
105
- return computeMode(collectNumericValues(args, context));
106
- };
107
-
108
- const stdevHandler: FunctionHandler = (args, context) => {
109
- return sampleStdev(collectNumericValues(args, context));
110
- };
111
-
112
- const varHandler: FunctionHandler = (args, context) => {
113
- return sampleVariance(collectNumericValues(args, context));
114
- };
115
-
116
- const countaHandler: FunctionHandler = (args, context) => {
117
- const values = collectRawValues(args, context);
118
- // Count non-empty cells
119
- return values.filter((value) => value !== null && value !== undefined && value !== "").length;
120
- };
121
-
122
- const countifHandler: FunctionHandler = (args, context) => {
123
- const values = rawRangeReader(context)(requiredArg(context, args, 0));
124
- return values.filter(parseCriteria(requiredArg(context, args, 1).trim())).length;
125
- };
126
-
127
- /** The criteria range, the matcher and the value range SUMIF and AVERAGEIF both
128
- * read. Both value ranges are RAW, not numeric-only: dropping blanks would
129
- * shift the value range out of alignment with the (raw) criteria range, so a
130
- * blank would pull a later row's number into an earlier match (#2358). */
131
- const readConditionalRanges = (args: string[], context: FunctionContext) => {
132
- const criteriaRef = requiredArg(context, args, 0);
133
- const readRaw = rawRangeReader(context);
134
- const valueRef = args.length === 3 ? requiredArg(context, args, 2) : criteriaRef;
135
- return {
136
- criteriaRange: readRaw(criteriaRef),
137
- valueRange: readRaw(valueRef),
138
- matches: parseCriteria(requiredArg(context, args, 1).trim()),
139
- };
140
- };
141
-
142
- /** Sum and count the values whose row in `criteriaRange` matches. The two
143
- * ranges stay row-aligned, so a matched row with no value contributes 0. */
144
- const aggregateMatchedRows = (criteriaRange: CellValue[], valueRange: CellValue[], matches: (value: CellValue) => boolean) =>
145
- criteriaRange.reduce(
146
- (totals, criteriaValue, index) => (matches(criteriaValue) ? { sum: totals.sum + toNumber(valueRange[index] ?? 0), count: totals.count + 1 } : totals),
147
- { sum: 0, count: 0 },
148
- );
149
-
150
- const sumifHandler: FunctionHandler = (args, context) => {
151
- const { criteriaRange, valueRange, matches } = readConditionalRanges(args, context);
152
- return aggregateMatchedRows(criteriaRange, valueRange, matches).sum;
153
- };
154
-
155
- const averageifHandler: FunctionHandler = (args, context) => {
156
- const { criteriaRange, valueRange, matches } = readConditionalRanges(args, context);
157
- const { sum, count } = aggregateMatchedRows(criteriaRange, valueRange, matches);
158
- // Excel returns #DIV/0! when no cell matches (the average of nothing is
159
- // undefined), rather than a silent 0.
160
- return count > 0 ? sum / count : DIV_ZERO_ERROR;
161
- };
162
-
163
- // Register all statistical functions
164
- functionRegistry.register({
165
- name: "SUM",
166
- handler: sumHandler,
167
- minArgs: 1,
168
- maxArgs: MAX_AGGREGATE_ARGS,
169
- description: "Returns the sum of all numbers in a range",
170
- examples: ["SUM(A1:A10)", "SUM(B2:B20)"],
171
- category: "Statistical",
172
- });
173
-
174
- functionRegistry.register({
175
- name: "AVERAGE",
176
- handler: averageHandler,
177
- minArgs: 1,
178
- maxArgs: MAX_AGGREGATE_ARGS,
179
- description: "Returns the average (arithmetic mean) of numbers in a range",
180
- examples: ["AVERAGE(A1:A10)", "AVERAGE(B2:B20)"],
181
- category: "Statistical",
182
- });
183
-
184
- functionRegistry.register({
185
- name: "MAX",
186
- handler: maxHandler,
187
- minArgs: 1,
188
- description: "Returns the largest value in a range",
189
- examples: ["MAX(A1:A10)", "MAX(B2:B20)"],
190
- category: "Statistical",
191
- });
192
-
193
- functionRegistry.register({
194
- name: "MIN",
195
- handler: minHandler,
196
- minArgs: 1,
197
- description: "Returns the smallest value in a range",
198
- examples: ["MIN(A1:A10)", "MIN(B2:B20)"],
199
- category: "Statistical",
200
- });
201
-
202
- functionRegistry.register({
203
- name: "COUNT",
204
- handler: countHandler,
205
- minArgs: 1,
206
- maxArgs: MAX_AGGREGATE_ARGS,
207
- description: "Counts the number of cells in a range",
208
- examples: ["COUNT(A1:A10)", "COUNT(B2:B20)"],
209
- category: "Statistical",
210
- });
211
-
212
- functionRegistry.register({
213
- name: "MEDIAN",
214
- handler: medianHandler,
215
- minArgs: 1,
216
- maxArgs: MAX_AGGREGATE_ARGS,
217
- description: "Returns the median (middle) value in a range",
218
- examples: ["MEDIAN(A1:A10)", "MEDIAN(B2:B20)"],
219
- category: "Statistical",
220
- });
221
-
222
- functionRegistry.register({
223
- name: "MODE",
224
- handler: modeHandler,
225
- minArgs: 1,
226
- maxArgs: MAX_AGGREGATE_ARGS,
227
- description: "Returns the most frequently occurring value in a range",
228
- examples: ["MODE(A1:A10)", "MODE(B2:B20)"],
229
- category: "Statistical",
230
- });
231
-
232
- functionRegistry.register({
233
- name: "STDEV",
234
- handler: stdevHandler,
235
- minArgs: 1,
236
- maxArgs: MAX_AGGREGATE_ARGS,
237
- description: "Returns the standard deviation of numbers in a range",
238
- examples: ["STDEV(A1:A10)", "STDEV(B2:B20)"],
239
- category: "Statistical",
240
- });
241
-
242
- functionRegistry.register({
243
- name: "VAR",
244
- handler: varHandler,
245
- minArgs: 1,
246
- maxArgs: MAX_AGGREGATE_ARGS,
247
- description: "Returns the variance of numbers in a range",
248
- examples: ["VAR(A1:A10)", "VAR(B2:B20)"],
249
- category: "Statistical",
250
- });
251
-
252
- functionRegistry.register({
253
- name: "COUNTA",
254
- handler: countaHandler,
255
- minArgs: 1,
256
- maxArgs: MAX_AGGREGATE_ARGS,
257
- description: "Counts the number of non-empty cells in a range",
258
- examples: ["COUNTA(A1:A10)", "COUNTA(B2:B20)"],
259
- category: "Statistical",
260
- });
261
-
262
- functionRegistry.register({
263
- name: "COUNTIF",
264
- handler: countifHandler,
265
- minArgs: 2,
266
- maxArgs: 2,
267
- description: "Counts cells in a range that match a criteria",
268
- examples: ['COUNTIF(A1:A10, ">5")', 'COUNTIF(B1:B10, "Yes")'],
269
- category: "Statistical",
270
- });
271
-
272
- functionRegistry.register({
273
- name: "SUMIF",
274
- handler: sumifHandler,
275
- minArgs: 2,
276
- maxArgs: 3,
277
- description: "Sums cells in a range that match a criteria",
278
- examples: ['SUMIF(A1:A10, ">5")', 'SUMIF(A1:A10, ">5", B1:B10)'],
279
- category: "Statistical",
280
- });
281
-
282
- functionRegistry.register({
283
- name: "AVERAGEIF",
284
- handler: averageifHandler,
285
- minArgs: 2,
286
- maxArgs: 3,
287
- description: "Averages cells in a range that match a criteria",
288
- examples: ['AVERAGEIF(A1:A10, ">5")', 'AVERAGEIF(A1:A10, ">5", B1:B10)'],
289
- category: "Statistical",
290
- });
@@ -1,398 +0,0 @@
1
- /**
2
- * Text Functions
3
- */
4
-
5
- import { functionRegistry, requiredArg, toString, type FunctionHandler } from "../registry";
6
- import { VALUE_ERROR, isSpreadsheetErrorValue, type SpreadsheetError } from "../spreadsheet-errors";
7
- import { formatWithPattern } from "../textFormat";
8
-
9
- // A letter or a combining mark. A decomposed accented letter (e + U+0301) is two
10
- // code points; counting the mark as part of the word stops PROPER from treating
11
- // the base letter that follows it as a new word (éclair → Éclair, not ÉClair).
12
- const isWordCharacter = (char: string): boolean => /[\p{L}\p{M}]/u.test(char);
13
-
14
- // Excel PROPER capitalises a letter at the start of the text or after any
15
- // non-letter (space, punctuation, digit) and lowercases the rest — so word
16
- // boundaries include "'" and "-", which a space-only split misses.
17
- export const toProperCase = (text: string): string => {
18
- const chars = Array.from(text);
19
- const cased = chars.map((char, index) => {
20
- const previous = chars[index - 1];
21
- return previous === undefined || !isWordCharacter(previous) ? char.toUpperCase() : char.toLowerCase();
22
- });
23
- return cased.join("");
24
- };
25
-
26
- // Excel LEFT/RIGHT reject a negative count with #VALUE!; 0 and over-length
27
- // counts keep substring's clamping.
28
- // Excel truncates a fractional count toward zero and rejects a non-finite or
29
- // negative one with #VALUE! (LEFT/RIGHT with "x" or -1). Normalising once keeps
30
- // LEFT and RIGHT consistent instead of each feeding a raw Number() to substring.
31
- const normalizeCharCount = (count: number): number | SpreadsheetError => {
32
- // Test the sign before truncating: Math.trunc(-0.5) is -0, which is not < 0.
33
- if (!Number.isFinite(count) || count < 0) return VALUE_ERROR;
34
- return Math.trunc(count);
35
- };
36
-
37
- export const takeLeft = (text: string, count: number): string | SpreadsheetError => {
38
- const chars = normalizeCharCount(count);
39
- return isSpreadsheetErrorValue(chars) ? chars : text.substring(0, chars);
40
- };
41
-
42
- export const takeRight = (text: string, count: number): string | SpreadsheetError => {
43
- const chars = normalizeCharCount(count);
44
- return isSpreadsheetErrorValue(chars) ? chars : text.substring(text.length - chars);
45
- };
46
-
47
- // Replace the nth (1-based) occurrence; split/join keeps matches non-overlapping,
48
- // matching the replace-all path.
49
- const replaceNthOccurrence = (text: string, oldText: string, newText: string, nth: number): string => {
50
- const parts = text.split(oldText);
51
- if (nth > parts.length - 1) return text;
52
- return parts.slice(0, nth).join(oldText) + newText + parts.slice(nth).join(oldText);
53
- };
54
-
55
- // Excel SUBSTITUTE: empty old_text returns the text unchanged (never inserts
56
- // between characters); a supplied instance ≤ 0 or non-finite is a #VALUE! error.
57
- export const substituteText = (text: string, oldText: string, newText: string, instance?: number): string | SpreadsheetError => {
58
- if (oldText === "") return text;
59
- if (instance === undefined) return text.split(oldText).join(newText);
60
- const nth = Math.trunc(instance);
61
- if (!Number.isFinite(nth) || nth <= 0) return VALUE_ERROR;
62
- return replaceNthOccurrence(text, oldText, newText, nth);
63
- };
64
-
65
- const concatenateHandler: FunctionHandler = (args, context) => {
66
- return args
67
- .map((arg) => {
68
- const value = context.evaluateFormula(arg.trim());
69
- return toString(value);
70
- })
71
- .join("");
72
- };
73
-
74
- const concatHandler: FunctionHandler = concatenateHandler; // Alias
75
-
76
- const leftHandler: FunctionHandler = (args, context) => {
77
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
78
- const numChars = args.length === 2 ? Number(context.evaluateFormula(requiredArg(context, args, 1))) : 1;
79
-
80
- return takeLeft(text, numChars);
81
- };
82
-
83
- const rightHandler: FunctionHandler = (args, context) => {
84
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
85
- const numChars = args.length === 2 ? Number(context.evaluateFormula(requiredArg(context, args, 1))) : 1;
86
-
87
- return takeRight(text, numChars);
88
- };
89
-
90
- /** Excel MID: 1-based start, count of characters. `substring` SWAPS its bounds
91
- * when they are reversed, so a negative count read backwards from `start` and
92
- * returned earlier characters — `MID("Hello",3,-1)` gave "e" instead of an
93
- * error. Both arguments are validated here instead. */
94
- export const takeMid = (text: string, start: number, count: number): string | SpreadsheetError => {
95
- const chars = normalizeCharCount(count);
96
- if (isSpreadsheetErrorValue(chars)) return chars;
97
- if (!Number.isFinite(start) || start < 1) return VALUE_ERROR;
98
- const from = Math.trunc(start) - 1;
99
- return text.substring(from, from + chars);
100
- };
101
-
102
- const midHandler: FunctionHandler = (args, context) => {
103
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
104
- const start = Number(context.evaluateFormula(requiredArg(context, args, 1)));
105
- const numChars = Number(context.evaluateFormula(requiredArg(context, args, 2)));
106
-
107
- return takeMid(text, start, numChars);
108
- };
109
-
110
- const lenHandler: FunctionHandler = (args, context) => {
111
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
112
- return text.length;
113
- };
114
-
115
- const upperHandler: FunctionHandler = (args, context) => {
116
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
117
- return text.toUpperCase();
118
- };
119
-
120
- const lowerHandler: FunctionHandler = (args, context) => {
121
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
122
- return text.toLowerCase();
123
- };
124
-
125
- const properHandler: FunctionHandler = (args, context) => {
126
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
127
- return toProperCase(text);
128
- };
129
-
130
- const trimHandler: FunctionHandler = (args, context) => {
131
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
132
- // Trim leading/trailing spaces and replace multiple spaces with single space
133
- return text.trim().replace(/\s+/g, " ");
134
- };
135
-
136
- const substituteHandler: FunctionHandler = (args, context) => {
137
- const text = toString(context.evaluateFormula(requiredArg(context, args, 0)));
138
- const oldText = toString(context.evaluateFormula(requiredArg(context, args, 1)));
139
- const newText = toString(context.evaluateFormula(requiredArg(context, args, 2)));
140
- const instance = args.length === 4 ? Number(context.evaluateFormula(requiredArg(context, args, 3))) : undefined;
141
-
142
- return substituteText(text, oldText, newText, instance);
143
- };
144
-
145
- const replaceHandler: FunctionHandler = (args, context) => {
146
- const oldText = toString(context.evaluateFormula(requiredArg(context, args, 0)));
147
- const startPos = Number(context.evaluateFormula(requiredArg(context, args, 1))) - 1; // 1-indexed to 0-indexed
148
- const numChars = Number(context.evaluateFormula(requiredArg(context, args, 2)));
149
- const newText = toString(context.evaluateFormula(requiredArg(context, args, 3)));
150
-
151
- return oldText.substring(0, startPos) + newText + oldText.substring(startPos + numChars);
152
- };
153
-
154
- // FIND and SEARCH share everything but case sensitivity: same 0-based start,
155
- // same #VALUE! on no match, same 1-based result. SEARCH folds case first.
156
- export const locateSubstring = (find: string, within: string, start: number, options: { caseInsensitive: boolean }): number | SpreadsheetError => {
157
- const needle = options.caseInsensitive ? find.toLowerCase() : find;
158
- const haystack = options.caseInsensitive ? within.toLowerCase() : within;
159
- const index = haystack.indexOf(needle, start);
160
- return index === -1 ? VALUE_ERROR : index + 1; // 1-indexed position
161
- };
162
-
163
- const findHandler: FunctionHandler = (args, context) => {
164
- const findText = toString(context.evaluateFormula(requiredArg(context, args, 0)));
165
- const withinText = toString(context.evaluateFormula(requiredArg(context, args, 1)));
166
- const startPos = args.length === 3 ? Number(context.evaluateFormula(requiredArg(context, args, 2))) - 1 : 0;
167
-
168
- return locateSubstring(findText, withinText, startPos, { caseInsensitive: false });
169
- };
170
-
171
- const searchHandler: FunctionHandler = (args, context) => {
172
- const findText = toString(context.evaluateFormula(requiredArg(context, args, 0)));
173
- const withinText = toString(context.evaluateFormula(requiredArg(context, args, 1)));
174
- const startPos = args.length === 3 ? Number(context.evaluateFormula(requiredArg(context, args, 2))) - 1 : 0;
175
-
176
- return locateSubstring(findText, withinText, startPos, { caseInsensitive: true });
177
- };
178
-
179
- // A format code written as a literal still carries its quotes when it reaches
180
- // the handler.
181
- const stripSurroundingQuotes = (text: string): string => text.replace(/^["']/, "").replace(/["']$/, "");
182
-
183
- const textHandler: FunctionHandler = (args, context) => {
184
- const value = context.evaluateFormula(requiredArg(context, args, 0));
185
- const format = stripSurroundingQuotes(toString(context.evaluateFormula(requiredArg(context, args, 1))));
186
- if (typeof value !== "number") return toString(value);
187
- return formatWithPattern(value, format) ?? toString(value);
188
- };
189
-
190
- /** Read a numeric string in full, or null. `Number` rather than `parseFloat`:
191
- * `parseFloat` stops at the first character it cannot read, so `VALUE("12abc")`
192
- * came back 12 where Excel reports #VALUE!. An empty string is not a number
193
- * here even though `Number("")` is 0. */
194
- // Decimal or scientific notation only. `Number` alone also accepts JS-only
195
- // spellings a spreadsheet never should — `0x10` → 16, `0b10` → 2, `Infinity` —
196
- // so the shape is checked before converting.
197
- const DECIMAL_NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
198
-
199
- const wholeNumberOrNull = (text: string): number | null => {
200
- if (!DECIMAL_NUMBER.test(text)) return null;
201
- const parsed = Number(text);
202
- // The pattern admits an exponent that overflows to Infinity (`1e999`), which
203
- // is no more a spreadsheet number than the literal spelling is.
204
- return Number.isFinite(parsed) ? parsed : null;
205
- };
206
-
207
- /** Excel VALUE: the WHOLE string must be a number once its currency symbols and
208
- * thousands separators are stripped; trailing text is an error, not a prefix to
209
- * salvage. */
210
- export const parseValueText = (raw: string): number | SpreadsheetError => {
211
- const cleaned = raw.replace(/[$,]/g, "").trim();
212
- if (cleaned.endsWith("%")) {
213
- const percent = wholeNumberOrNull(cleaned.slice(0, -1).trim());
214
- return percent === null ? VALUE_ERROR : percent / 100;
215
- }
216
- const parsed = wholeNumberOrNull(cleaned);
217
- return parsed === null ? VALUE_ERROR : parsed;
218
- };
219
-
220
- const valueHandler: FunctionHandler = (args, context) => {
221
- return parseValueText(toString(context.evaluateFormula(requiredArg(context, args, 0))));
222
- };
223
-
224
- const exactHandler: FunctionHandler = (args, context) => {
225
- const text1 = toString(context.evaluateFormula(requiredArg(context, args, 0)));
226
- const text2 = toString(context.evaluateFormula(requiredArg(context, args, 1)));
227
-
228
- return text1 === text2;
229
- };
230
-
231
- // Register all text functions
232
- functionRegistry.register({
233
- name: "CONCATENATE",
234
- handler: concatenateHandler,
235
- minArgs: 1,
236
- description: "Joins several text strings into one string",
237
- examples: ['CONCATENATE("Hello", " ", "World")', "CONCATENATE(A1, B1)"],
238
- category: "Text",
239
- });
240
-
241
- functionRegistry.register({
242
- name: "CONCAT",
243
- handler: concatHandler,
244
- minArgs: 1,
245
- description: "Joins several text strings into one string (same as CONCATENATE)",
246
- examples: ['CONCAT("Hello", " ", "World")', "CONCAT(A1, B1)"],
247
- category: "Text",
248
- });
249
-
250
- functionRegistry.register({
251
- name: "LEFT",
252
- handler: leftHandler,
253
- minArgs: 1,
254
- maxArgs: 2,
255
- description: "Returns the leftmost characters from a text string",
256
- examples: ['LEFT("Hello", 2)', "LEFT(A1, 3)"],
257
- category: "Text",
258
- });
259
-
260
- functionRegistry.register({
261
- name: "RIGHT",
262
- handler: rightHandler,
263
- minArgs: 1,
264
- maxArgs: 2,
265
- description: "Returns the rightmost characters from a text string",
266
- examples: ['RIGHT("Hello", 2)', "RIGHT(A1, 3)"],
267
- category: "Text",
268
- });
269
-
270
- functionRegistry.register({
271
- name: "MID",
272
- handler: midHandler,
273
- minArgs: 3,
274
- maxArgs: 3,
275
- description: "Returns characters from the middle of a text string",
276
- examples: ['MID("Hello", 2, 3)', "MID(A1, 1, 5)"],
277
- category: "Text",
278
- });
279
-
280
- functionRegistry.register({
281
- name: "LEN",
282
- handler: lenHandler,
283
- minArgs: 1,
284
- maxArgs: 1,
285
- description: "Returns the number of characters in a text string",
286
- examples: ['LEN("Hello")', "LEN(A1)"],
287
- category: "Text",
288
- });
289
-
290
- functionRegistry.register({
291
- name: "UPPER",
292
- handler: upperHandler,
293
- minArgs: 1,
294
- maxArgs: 1,
295
- description: "Converts text to uppercase",
296
- examples: ['UPPER("hello")', "UPPER(A1)"],
297
- category: "Text",
298
- });
299
-
300
- functionRegistry.register({
301
- name: "LOWER",
302
- handler: lowerHandler,
303
- minArgs: 1,
304
- maxArgs: 1,
305
- description: "Converts text to lowercase",
306
- examples: ['LOWER("HELLO")', "LOWER(A1)"],
307
- category: "Text",
308
- });
309
-
310
- functionRegistry.register({
311
- name: "PROPER",
312
- handler: properHandler,
313
- minArgs: 1,
314
- maxArgs: 1,
315
- description: "Capitalizes the first letter of each word",
316
- examples: ['PROPER("hello world")', "PROPER(A1)"],
317
- category: "Text",
318
- });
319
-
320
- functionRegistry.register({
321
- name: "TRIM",
322
- handler: trimHandler,
323
- minArgs: 1,
324
- maxArgs: 1,
325
- description: "Removes extra spaces from text",
326
- examples: ['TRIM(" hello world ")', "TRIM(A1)"],
327
- category: "Text",
328
- });
329
-
330
- functionRegistry.register({
331
- name: "SUBSTITUTE",
332
- handler: substituteHandler,
333
- minArgs: 3,
334
- maxArgs: 4,
335
- description: "Replaces old text with new text in a string",
336
- examples: ['SUBSTITUTE("Hello World", "World", "Earth")', 'SUBSTITUTE(A1, "old", "new", 1)'],
337
- category: "Text",
338
- });
339
-
340
- functionRegistry.register({
341
- name: "REPLACE",
342
- handler: replaceHandler,
343
- minArgs: 4,
344
- maxArgs: 4,
345
- description: "Replaces part of a text string with a different text string",
346
- examples: ['REPLACE("Hello World", 7, 5, "Earth")', 'REPLACE(A1, 1, 3, "New")'],
347
- category: "Text",
348
- });
349
-
350
- functionRegistry.register({
351
- name: "FIND",
352
- handler: findHandler,
353
- minArgs: 2,
354
- maxArgs: 3,
355
- description: "Finds one text string within another (case-sensitive)",
356
- examples: ['FIND("o", "Hello")', 'FIND("World", A1)'],
357
- category: "Text",
358
- });
359
-
360
- functionRegistry.register({
361
- name: "SEARCH",
362
- handler: searchHandler,
363
- minArgs: 2,
364
- maxArgs: 3,
365
- description: "Finds one text string within another (case-insensitive)",
366
- examples: ['SEARCH("O", "Hello")', 'SEARCH("world", A1)'],
367
- category: "Text",
368
- });
369
-
370
- functionRegistry.register({
371
- name: "TEXT",
372
- handler: textHandler,
373
- minArgs: 2,
374
- maxArgs: 2,
375
- description: "Formats a number and converts it to text",
376
- examples: ['TEXT(1234.5, "$#,##0.00")', 'TEXT(0.5, "0%")'],
377
- category: "Text",
378
- });
379
-
380
- functionRegistry.register({
381
- name: "VALUE",
382
- handler: valueHandler,
383
- minArgs: 1,
384
- maxArgs: 1,
385
- description: "Converts a text string to a number",
386
- examples: ['VALUE("123")', 'VALUE("$1,234.56")'],
387
- category: "Text",
388
- });
389
-
390
- functionRegistry.register({
391
- name: "EXACT",
392
- handler: exactHandler,
393
- minArgs: 2,
394
- maxArgs: 2,
395
- description: "Checks if two text strings are exactly the same",
396
- examples: ['EXACT("Hello", "hello")', "EXACT(A1, B1)"],
397
- category: "Text",
398
- });