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,26 +0,0 @@
|
|
|
1
|
-
import type { CellValue } from "./types";
|
|
2
|
-
import { isSpreadsheetErrorValue } from "./spreadsheet-errors";
|
|
3
|
-
|
|
4
|
-
/** Excel-style truthiness, shared by IF and AND/OR/NOT so the same value cannot
|
|
5
|
-
* read as true in one function and false in another. A number is false only
|
|
6
|
-
* when 0; blank and empty text are false; the words `true`/`false` are their
|
|
7
|
-
* logical values (case-insensitively); a numeric string follows its number
|
|
8
|
-
* (`"0"` → false); any other non-empty text is true. */
|
|
9
|
-
export function coerceToBoolean(value: CellValue | null | undefined): boolean {
|
|
10
|
-
if (typeof value === "boolean") return value;
|
|
11
|
-
if (value === null || value === undefined) return false;
|
|
12
|
-
if (typeof value === "number") return value !== 0;
|
|
13
|
-
// Pinned: an error reads as non-empty text, i.e. true — the same answer the
|
|
14
|
-
// error strings gave before they became values.
|
|
15
|
-
if (isSpreadsheetErrorValue(value)) return true;
|
|
16
|
-
|
|
17
|
-
const text = value.trim();
|
|
18
|
-
if (text === "") return false;
|
|
19
|
-
|
|
20
|
-
const lowered = text.toLowerCase();
|
|
21
|
-
if (lowered === "true") return true;
|
|
22
|
-
if (lowered === "false") return false;
|
|
23
|
-
|
|
24
|
-
const asNumber = Number(text);
|
|
25
|
-
return Number.isNaN(asNumber) ? true : asNumber !== 0;
|
|
26
|
-
}
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Evaluating a spreadsheet condition without running it as code.
|
|
3
|
-
*
|
|
4
|
-
* A condition is one comparison, or a bare value tested for truthiness. That is
|
|
5
|
-
* the whole grammar — small enough to read directly, which is the point: the
|
|
6
|
-
* previous implementation handed the substituted text to `eval`, so a cell
|
|
7
|
-
* containing `globalThis.x = 1` executed when any IFS referenced it.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { CellValue } from "./types";
|
|
11
|
-
import { isSpreadsheetErrorValue } from "./spreadsheet-errors";
|
|
12
|
-
|
|
13
|
-
export type ComparisonOperator = ">=" | "<=" | "<>" | "!=" | "==" | "=" | ">" | "<";
|
|
14
|
-
|
|
15
|
-
// Longest first: `>=` must win over `>`, and `<>` / `<=` over `<`.
|
|
16
|
-
const OPERATORS: readonly ComparisonOperator[] = [">=", "<=", "<>", "!=", "==", "=", ">", "<"];
|
|
17
|
-
|
|
18
|
-
export interface Comparison {
|
|
19
|
-
left: string;
|
|
20
|
-
operator: ComparisonOperator;
|
|
21
|
-
right: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Remove parentheses that wrap the WHOLE expression, repeatedly. `(1>0)` is
|
|
25
|
-
* the same condition as `1>0`, but `(A)=(B)` is not `A)=(B` — the leading `(`
|
|
26
|
-
* closes before the end, so it wraps only its own operand and must stay.
|
|
27
|
-
* Unbalanced input is left untouched rather than guessed at. */
|
|
28
|
-
export function stripOuterParens(condition: string): string {
|
|
29
|
-
let text = condition.trim();
|
|
30
|
-
while (text.startsWith("(") && text.endsWith(")")) {
|
|
31
|
-
let depth = 0;
|
|
32
|
-
let quote: string | null = null;
|
|
33
|
-
let wrapsAll = true;
|
|
34
|
-
for (let index = 0; index < text.length; index++) {
|
|
35
|
-
const char = text[index];
|
|
36
|
-
if (quote !== null) {
|
|
37
|
-
if (char === "\\")
|
|
38
|
-
index++; // skip the escaped character
|
|
39
|
-
else if (char === quote) quote = null;
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (char === '"' || char === "'") {
|
|
43
|
-
quote = char;
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (char === "(") depth++;
|
|
47
|
-
else if (char === ")") {
|
|
48
|
-
depth--;
|
|
49
|
-
// Back to zero before the end means this `(` closed early.
|
|
50
|
-
if (depth === 0 && index < text.length - 1) {
|
|
51
|
-
wrapsAll = false;
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
if (depth < 0) return text; // unbalanced
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (!wrapsAll || depth !== 0) return text;
|
|
58
|
-
text = text.slice(1, -1).trim();
|
|
59
|
-
}
|
|
60
|
-
return text;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Split a condition into its two sides, or null when it holds no comparison.
|
|
64
|
-
* Only the FIRST top-level operator counts — `a>b>c` is not a chain here, and
|
|
65
|
-
* treating it as one is what let `1=1=1` reach a JS parser before.
|
|
66
|
-
*
|
|
67
|
-
* "Top-level" means outside quotes: a cell holding `a>b` substitutes into the
|
|
68
|
-
* condition as `"a>b"`, and splitting on that `>` would compare two fragments
|
|
69
|
-
* of one string literal. */
|
|
70
|
-
export function splitComparison(condition: string): Comparison | null {
|
|
71
|
-
const text = stripOuterParens(condition);
|
|
72
|
-
let quote: string | null = null;
|
|
73
|
-
for (let index = 0; index < text.length; index++) {
|
|
74
|
-
const char = text[index];
|
|
75
|
-
if (quote !== null) {
|
|
76
|
-
if (char === "\\")
|
|
77
|
-
index++; // skip the escaped character
|
|
78
|
-
else if (char === quote) quote = null;
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
if (char === '"' || char === "'") {
|
|
82
|
-
quote = char;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
for (const operator of OPERATORS) {
|
|
86
|
-
if (!text.startsWith(operator, index)) continue;
|
|
87
|
-
return { left: text.slice(0, index).trim(), operator, right: text.slice(index + operator.length).trim() };
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Strip one matching pair of surrounding quotes, and undo the `\"` / `\\`
|
|
94
|
-
* escaping that `renderConditionOperand` applies when it quotes a cell value. */
|
|
95
|
-
function unquote(text: string): { value: string; quoted: boolean } {
|
|
96
|
-
const isQuoted = text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")));
|
|
97
|
-
if (!isQuoted) return { value: text, quoted: false };
|
|
98
|
-
const inner = text.slice(1, -1).replace(/\\(["'\\])/g, "$1");
|
|
99
|
-
return { value: inner, quoted: true };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Read an operand as the value it denotes: a quoted string stays text, a
|
|
103
|
-
* numeric literal becomes a number, `TRUE`/`FALSE` become booleans, and
|
|
104
|
-
* anything else stays the text it already is. */
|
|
105
|
-
export function readOperand(raw: string): CellValue {
|
|
106
|
-
const { value, quoted } = unquote(raw.trim());
|
|
107
|
-
if (quoted) return value;
|
|
108
|
-
if (value === "") return "";
|
|
109
|
-
const upper = value.toUpperCase();
|
|
110
|
-
if (upper === "TRUE") return true;
|
|
111
|
-
if (upper === "FALSE") return false;
|
|
112
|
-
// `Number` rather than `parseFloat`: it rejects trailing garbage, so "12abc"
|
|
113
|
-
// stays text instead of becoming 12.
|
|
114
|
-
const numeric = Number(value);
|
|
115
|
-
return Number.isNaN(numeric) ? value : numeric;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function compareValues(left: CellValue, right: CellValue): number | null {
|
|
119
|
-
if (typeof left === "number" && typeof right === "number") return left - right;
|
|
120
|
-
if (typeof left === "boolean" || typeof right === "boolean") return null;
|
|
121
|
-
return String(left).localeCompare(String(right));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function applyOperator(operator: ComparisonOperator, left: CellValue, right: CellValue): boolean {
|
|
125
|
-
// Equality does not need an ordering, so it works for every type pair —
|
|
126
|
-
// including the boolean combinations `compareValues` refuses to order.
|
|
127
|
-
if (operator === "=" || operator === "==") return left === right;
|
|
128
|
-
if (operator === "<>" || operator === "!=") return left !== right;
|
|
129
|
-
const ordering = compareValues(left, right);
|
|
130
|
-
if (ordering === null) return false;
|
|
131
|
-
if (operator === ">") return ordering > 0;
|
|
132
|
-
if (operator === ">=") return ordering >= 0;
|
|
133
|
-
if (operator === "<") return ordering < 0;
|
|
134
|
-
return ordering <= 0;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Whether a resolved condition value counts as satisfied. Mirrors the
|
|
138
|
-
* spreadsheet convention rather than JavaScript's: 0 and an empty string are
|
|
139
|
-
* false, every other value is true. */
|
|
140
|
-
function valueIsTruthy(value: CellValue): boolean {
|
|
141
|
-
if (typeof value === "boolean") return value;
|
|
142
|
-
if (typeof value === "number") return value !== 0;
|
|
143
|
-
return value !== "";
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** True when a bare (non-comparison) condition counts as satisfied. */
|
|
147
|
-
export function isTruthyCondition(raw: string): boolean {
|
|
148
|
-
return valueIsTruthy(readOperand(stripOuterParens(raw)));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Evaluate a condition — one comparison, or a value tested for truthiness.
|
|
152
|
-
* Never executes its input. */
|
|
153
|
-
export function evaluateCondition(condition: string): boolean {
|
|
154
|
-
const comparison = splitComparison(condition);
|
|
155
|
-
if (!comparison) return isTruthyCondition(condition);
|
|
156
|
-
return applyOperator(comparison.operator, readOperand(comparison.left), readOperand(comparison.right));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Like `evaluateCondition`, but each operand is resolved by `evaluate` — so a
|
|
160
|
-
* caller holding the engine can compute arithmetic and sub-expressions
|
|
161
|
-
* (`5+1>10`) instead of reading each side as a bare string. It still never runs
|
|
162
|
-
* the condition as code: it only splits on the top-level comparison and
|
|
163
|
-
* compares the two resolved values. */
|
|
164
|
-
export function evaluateConditionValues(condition: string, evaluate: (operand: string) => CellValue): boolean {
|
|
165
|
-
const comparison = splitComparison(condition);
|
|
166
|
-
if (!comparison) return valueIsTruthy(evaluate(stripOuterParens(condition)));
|
|
167
|
-
return applyOperator(comparison.operator, evaluate(comparison.left), evaluate(comparison.right));
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Render a cell's value as an operand for a condition string. A string is
|
|
171
|
-
* quoted, with its own quotes and backslashes escaped, so its contents cannot
|
|
172
|
-
* be re-parsed as operators — `evaluateCondition` then unquotes it back to the
|
|
173
|
-
* original text. A missing value becomes an empty string; numbers and booleans
|
|
174
|
-
* render as themselves. */
|
|
175
|
-
export function renderConditionOperand(value: CellValue | null | undefined): string {
|
|
176
|
-
if (value === null || value === undefined) return '""';
|
|
177
|
-
// A formula error renders as its quoted code, so a condition compares it as
|
|
178
|
-
// the text a cell shows rather than as a bare `#NUM!` token.
|
|
179
|
-
const text = isSpreadsheetErrorValue(value) ? value.code : value;
|
|
180
|
-
if (typeof text === "string") return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
181
|
-
return text.toString();
|
|
182
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which order an ambiguous slash date is written in, per locale.
|
|
3
|
-
*
|
|
4
|
-
* Only dates whose two leading numbers are BOTH 12 or under are ambiguous —
|
|
5
|
-
* `13/04/2025` can only be day-first, and `04/13/2025` can only be month-first,
|
|
6
|
-
* so those decide themselves. This is purely about `03/04/2025`.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
// Day-first locales among the ones the app ships. The rest are month-first
|
|
10
|
-
// here: `en` because the app cannot see the region (`en-GB` is folded to `en`
|
|
11
|
-
// before it reaches any plugin), and ja / zh / ko because their conventional
|
|
12
|
-
// order is year-month-day, which puts the month before the day in a two-part
|
|
13
|
-
// date just as US order does.
|
|
14
|
-
const DAY_FIRST_LANGUAGES = new Set(["es", "pt", "fr", "de", "it", "nl", "ru", "pl", "tr", "id", "vi", "th"]);
|
|
15
|
-
|
|
16
|
-
// English regions that write month-first. Everywhere else that speaks English
|
|
17
|
-
// writes day-first.
|
|
18
|
-
const MONTH_FIRST_EN_REGIONS = new Set(["us", "ca", "ph"]);
|
|
19
|
-
|
|
20
|
-
/** The region subtag, or "" when the tag carries none. Scanning rather than
|
|
21
|
-
* taking position 1: a script subtag (`en-Latn-US`) sits between the language
|
|
22
|
-
* and the region, and reading it as the region flips month-first English
|
|
23
|
-
* locales to day-first. A region is two letters or three digits; a
|
|
24
|
-
* single-character subtag starts an extension, so nothing past it is one. */
|
|
25
|
-
function regionOf(subtags: readonly string[]): string {
|
|
26
|
-
for (const subtag of subtags) {
|
|
27
|
-
if (subtag.length === 1) break;
|
|
28
|
-
if (/^[a-z]{2}$/.test(subtag) || /^[0-9]{3}$/.test(subtag)) return subtag;
|
|
29
|
-
}
|
|
30
|
-
return "";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** True when an ambiguous `A/B/YYYY` should read as day-first. Accepts a full
|
|
34
|
-
* BCP 47 tag or a bare language subtag; the region is used when present so a
|
|
35
|
-
* caller that can supply `en-GB` gets the right answer even though the app's
|
|
36
|
-
* own locale resolution discards it. */
|
|
37
|
-
export function prefersDayFirst(locale: string | undefined | null): boolean {
|
|
38
|
-
if (!locale) return false;
|
|
39
|
-
const [language = "", ...rest] = locale.toLowerCase().split(/[-_]/);
|
|
40
|
-
// English splits on region rather than language. A bare `en` carries no
|
|
41
|
-
// region to split on, so it keeps the US default.
|
|
42
|
-
if (language === "en") {
|
|
43
|
-
const region = regionOf(rest);
|
|
44
|
-
return region !== "" && !MONTH_FIRST_EN_REGIONS.has(region);
|
|
45
|
-
}
|
|
46
|
-
return DAY_FIRST_LANGUAGES.has(language);
|
|
47
|
-
}
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Date Parsing Utilities
|
|
3
|
-
*
|
|
4
|
-
* Parse various date string formats into Excel serial numbers.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { dateToSerial, MONTH_NAMES_SHORT, MONTH_NAMES_FULL } from "./date-utils";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Check if a string looks like a date
|
|
11
|
-
*
|
|
12
|
-
* @param str - String to check
|
|
13
|
-
* @returns true if string matches common date patterns
|
|
14
|
-
*/
|
|
15
|
-
export function isDateLike(str: string): boolean {
|
|
16
|
-
if (typeof str !== "string") return false;
|
|
17
|
-
if (str.length < 6 || str.length > 30) return false; // Reasonable length for dates
|
|
18
|
-
|
|
19
|
-
// Common date patterns:
|
|
20
|
-
// MM/DD/YYYY, DD/MM/YYYY, M/D/YYYY
|
|
21
|
-
// YYYY-MM-DD, YYYY/MM/DD
|
|
22
|
-
// DD-MMM-YYYY, D-MMM-YYYY
|
|
23
|
-
// MMM D, YYYY, MMMM D, YYYY
|
|
24
|
-
|
|
25
|
-
// Pattern 1: Contains digits and separators (/, -, space)
|
|
26
|
-
const hasDigits = /\d/.test(str);
|
|
27
|
-
const hasSeparator = /[/\-\s,]/.test(str);
|
|
28
|
-
|
|
29
|
-
if (!hasDigits || !hasSeparator) return false;
|
|
30
|
-
|
|
31
|
-
// Pattern 2: Matches common date formats
|
|
32
|
-
const datePatterns = [
|
|
33
|
-
/^\d{1,2}\/\d{1,2}\/\d{2,4}$/, // MM/DD/YYYY or DD/MM/YYYY
|
|
34
|
-
/^\d{4}-\d{1,2}-\d{1,2}$/, // YYYY-MM-DD
|
|
35
|
-
/^\d{4}\/\d{1,2}\/\d{1,2}$/, // YYYY/MM/DD
|
|
36
|
-
/^\d{1,2}-[A-Za-z]{3}-\d{2,4}$/, // DD-MMM-YYYY
|
|
37
|
-
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // MMM D, YYYY or MMMM D, YYYY
|
|
38
|
-
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // D MMM YYYY
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
return datePatterns.some((pattern) => pattern.test(str.trim()));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Build the Excel serial number for a year/month/day triple, or null when the
|
|
46
|
-
* triple is not a valid calendar date. Every dated branch of `parseDate` ends in
|
|
47
|
-
* this same validate → Date.UTC → dateToSerial step.
|
|
48
|
-
*/
|
|
49
|
-
export function serialFromParts(year: number, month: number, day: number): number | null {
|
|
50
|
-
if (!isValidDate(year, month, day)) return null;
|
|
51
|
-
return dateToSerial(new Date(Date.UTC(year, month - 1, day)));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The three capture groups of a date pattern, as a tuple the caller can
|
|
56
|
-
* destructure. `null` when the text does not match — or when a group did not
|
|
57
|
-
* participate, which every pattern here makes impossible, so it lands on the
|
|
58
|
-
* same "not a date" answer rather than reading an absent group as text.
|
|
59
|
-
*/
|
|
60
|
-
function matchDateParts(text: string, pattern: RegExp): [string, string, string] | null {
|
|
61
|
-
const [, first, second, third] = text.match(pattern) ?? [];
|
|
62
|
-
if (first === undefined || second === undefined || third === undefined) return null;
|
|
63
|
-
return [first, second, third];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const SLASH_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/;
|
|
67
|
-
const MAX_MONTH = 12;
|
|
68
|
-
|
|
69
|
-
/** Whether `A/B/YYYY` reads day-first. A number no month can hold decides on its
|
|
70
|
-
* own; an ambiguous pair follows the reading preference. Shared with
|
|
71
|
-
* `getDefaultDateFormat` so a slash date renders in the order it was READ. */
|
|
72
|
-
const readsDayFirst = (first: number, second: number, preferDDMMYYYY: boolean): boolean =>
|
|
73
|
-
first > MAX_MONTH || (second <= MAX_MONTH && first <= MAX_MONTH && preferDDMMYYYY);
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Parse a month name to month number (1-12)
|
|
77
|
-
*/
|
|
78
|
-
function parseMonthName(monthStr: string): number | null {
|
|
79
|
-
const month = monthStr.toLowerCase();
|
|
80
|
-
|
|
81
|
-
// Try short names
|
|
82
|
-
const shortIndex = MONTH_NAMES_SHORT.findIndex((m) => m.toLowerCase() === month);
|
|
83
|
-
if (shortIndex !== -1) return shortIndex + 1;
|
|
84
|
-
|
|
85
|
-
// Try full names
|
|
86
|
-
const fullIndex = MONTH_NAMES_FULL.findIndex((m) => m.toLowerCase() === month);
|
|
87
|
-
if (fullIndex !== -1) return fullIndex + 1;
|
|
88
|
-
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Parse a date string into Excel serial number
|
|
94
|
-
*
|
|
95
|
-
* Supports formats:
|
|
96
|
-
* - MM/DD/YYYY, M/D/YYYY
|
|
97
|
-
* - DD/MM/YYYY (when day > 12)
|
|
98
|
-
* - YYYY-MM-DD, YYYY/MM/DD (ISO format)
|
|
99
|
-
* - DD-MMM-YYYY, D-MMM-YYYY
|
|
100
|
-
* - MMM D, YYYY, MMMM D, YYYY
|
|
101
|
-
*
|
|
102
|
-
* @param dateStr - String that might contain a date
|
|
103
|
-
* @param preferDDMMYYYY - Prefer DD/MM/YYYY over MM/DD/YYYY for ambiguous dates (default: false)
|
|
104
|
-
* @returns Serial number or null if not a valid date
|
|
105
|
-
*/
|
|
106
|
-
export function parseDate(dateStr: string, preferDDMMYYYY: boolean = false): number | null {
|
|
107
|
-
if (!isDateLike(dateStr)) return null;
|
|
108
|
-
|
|
109
|
-
const trimmed = dateStr.trim();
|
|
110
|
-
|
|
111
|
-
// Try YYYY-MM-DD or YYYY/MM/DD (ISO format)
|
|
112
|
-
const isoParts = matchDateParts(trimmed, /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
|
|
113
|
-
if (isoParts) {
|
|
114
|
-
const [year, month, day] = isoParts;
|
|
115
|
-
return serialFromParts(parseInt(year), parseInt(month), parseInt(day));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Try DD-MMM-YYYY or D-MMM-YYYY
|
|
119
|
-
const dmmyParts = matchDateParts(trimmed, /^(\d{1,2})-([A-Za-z]{3})-(\d{2,4})$/);
|
|
120
|
-
if (dmmyParts) {
|
|
121
|
-
const [day, monthName, year] = dmmyParts;
|
|
122
|
-
return serialFromNamedMonth(expandTwoDigitYear(parseInt(year)), monthName, parseInt(day));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Try MMM D, YYYY or MMMM D, YYYY
|
|
126
|
-
const mmmParts = matchDateParts(trimmed, /^([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{4})$/);
|
|
127
|
-
if (mmmParts) {
|
|
128
|
-
const [monthName, day, year] = mmmParts;
|
|
129
|
-
return serialFromNamedMonth(parseInt(year), monthName, parseInt(day));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Try D MMM YYYY
|
|
133
|
-
const dMmmParts = matchDateParts(trimmed, /^(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})$/);
|
|
134
|
-
if (dMmmParts) {
|
|
135
|
-
const [day, monthName, year] = dMmmParts;
|
|
136
|
-
return serialFromNamedMonth(parseInt(year), monthName, parseInt(day));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Try MM/DD/YYYY or DD/MM/YYYY
|
|
140
|
-
const slashParts = matchDateParts(trimmed, SLASH_DATE_PATTERN);
|
|
141
|
-
if (slashParts) {
|
|
142
|
-
const [first, second, year] = slashParts;
|
|
143
|
-
// If first > 12, it must be DD/MM; if second > 12, it must be MM/DD.
|
|
144
|
-
// Otherwise use preference (default to MM/DD for US format).
|
|
145
|
-
const dayFirst = readsDayFirst(parseInt(first), parseInt(second), preferDDMMYYYY);
|
|
146
|
-
const month = dayFirst ? parseInt(second) : parseInt(first);
|
|
147
|
-
const day = dayFirst ? parseInt(first) : parseInt(second);
|
|
148
|
-
return serialFromParts(expandTwoDigitYear(parseInt(year)), month, day);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** A two-digit year reads as this century up to 29, the previous one after. */
|
|
155
|
-
const TWO_DIGIT_YEAR_LIMIT = 100;
|
|
156
|
-
const CENTURY_PIVOT = 30;
|
|
157
|
-
|
|
158
|
-
function expandTwoDigitYear(year: number): number {
|
|
159
|
-
if (year >= TWO_DIGIT_YEAR_LIMIT) return year;
|
|
160
|
-
return year < CENTURY_PIVOT ? 2000 + year : 1900 + year;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function serialFromNamedMonth(year: number, monthName: string, day: number): number | null {
|
|
164
|
-
const month = parseMonthName(monthName);
|
|
165
|
-
if (month === null) return null;
|
|
166
|
-
return serialFromParts(year, month, day);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Validate that a date is valid
|
|
171
|
-
*/
|
|
172
|
-
function isValidDate(year: number, month: number, day: number): boolean {
|
|
173
|
-
// Check basic ranges
|
|
174
|
-
if (year < 1900 || year > 2100) return false;
|
|
175
|
-
if (month < 1 || month > 12) return false;
|
|
176
|
-
if (day < 1 || day > 31) return false;
|
|
177
|
-
|
|
178
|
-
// Check if day is valid for the given month
|
|
179
|
-
const date = new Date(Date.UTC(year, month - 1, day));
|
|
180
|
-
|
|
181
|
-
// If the date rolls over to the next month, it's invalid
|
|
182
|
-
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Get default date format based on parsed date
|
|
187
|
-
*
|
|
188
|
-
* @param originalStr - Original date string
|
|
189
|
-
* @returns Appropriate format code
|
|
190
|
-
*/
|
|
191
|
-
export function getDefaultDateFormat(originalStr: string, preferDDMMYYYY: boolean = false): string {
|
|
192
|
-
const trimmed = originalStr.trim();
|
|
193
|
-
|
|
194
|
-
// YYYY-MM-DD → use same format
|
|
195
|
-
if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(trimmed)) {
|
|
196
|
-
return "YYYY-MM-DD";
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// YYYY/MM/DD parses as ISO, so it must keep a year-first label. Without this
|
|
200
|
-
// branch it fell through to the slash default and re-rendered as MM/DD or
|
|
201
|
-
// DD/MM — the same digits in a different order, which reads as a different
|
|
202
|
-
// date (Codex review).
|
|
203
|
-
if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(trimmed)) {
|
|
204
|
-
return "YYYY/MM/DD";
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// DD-MMM-YYYY → use same format
|
|
208
|
-
if (/^\d{1,2}-[A-Za-z]{3}-\d{2,4}$/.test(trimmed)) {
|
|
209
|
-
return "DD-MMM-YYYY";
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// MMM D, YYYY → use same format
|
|
213
|
-
if (/^[A-Za-z]{3}\s+\d{1,2},?\s+\d{4}$/.test(trimmed)) {
|
|
214
|
-
return "MMM D, YYYY";
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// MMMM D, YYYY → use same format
|
|
218
|
-
if (/^[A-Za-z]{4,9}\s+\d{1,2},?\s+\d{4}$/.test(trimmed)) {
|
|
219
|
-
return "MMMM D, YYYY";
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// A slash date must render in the order it was READ, or the cell shows the
|
|
223
|
-
// user's own input with its two halves swapped. `parseDate` takes the first
|
|
224
|
-
// number as the day whenever it cannot be a month, whatever the preference
|
|
225
|
-
// says, so that case is decided here the same way.
|
|
226
|
-
const slashParts = matchDateParts(trimmed, SLASH_DATE_PATTERN);
|
|
227
|
-
if (slashParts) {
|
|
228
|
-
const [first, second] = slashParts;
|
|
229
|
-
return readsDayFirst(parseInt(first), parseInt(second), preferDDMMYYYY) ? "DD/MM/YYYY" : "MM/DD/YYYY";
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Anything unrecognised keeps the reading order's default.
|
|
233
|
-
return preferDDMMYYYY ? "DD/MM/YYYY" : "MM/DD/YYYY";
|
|
234
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Date Utility Functions
|
|
3
|
-
*
|
|
4
|
-
* Shared utilities for converting between JavaScript Date objects and Excel serial numbers.
|
|
5
|
-
* Excel stores dates as sequential serial numbers so they can be used in calculations.
|
|
6
|
-
* January 1, 1900 is serial number 1.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
// Constants
|
|
10
|
-
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
11
|
-
// Excel base date: Dec 30, 1899 (to handle the 1900 leap year bug correctly)
|
|
12
|
-
const EXCEL_BASE_DATE = new Date(Date.UTC(1899, 11, 30));
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Convert JavaScript Date to Excel Serial Number
|
|
16
|
-
*
|
|
17
|
-
* @param date - JavaScript Date object
|
|
18
|
-
* @returns Excel serial number (days since Dec 30, 1899)
|
|
19
|
-
*/
|
|
20
|
-
export const dateToSerial = (date: Date): number => {
|
|
21
|
-
const diff = date.getTime() - EXCEL_BASE_DATE.getTime();
|
|
22
|
-
return diff / MS_PER_DAY;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Convert Excel Serial Number to JavaScript Date
|
|
27
|
-
*
|
|
28
|
-
* @param serial - Excel serial number
|
|
29
|
-
* @returns JavaScript Date object
|
|
30
|
-
*/
|
|
31
|
-
export const serialToDate = (serial: number): Date => {
|
|
32
|
-
const days = Math.floor(serial);
|
|
33
|
-
const timePart = serial - days;
|
|
34
|
-
|
|
35
|
-
const date = new Date(EXCEL_BASE_DATE.getTime() + days * MS_PER_DAY);
|
|
36
|
-
|
|
37
|
-
// Add time component
|
|
38
|
-
const totalSeconds = Math.round(timePart * 24 * 60 * 60);
|
|
39
|
-
date.setSeconds(date.getSeconds() + totalSeconds);
|
|
40
|
-
|
|
41
|
-
return date;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** A name list that always has a first entry, so a formatter can fall back to it
|
|
45
|
-
* when a date is unreadable and its month/weekday index comes out NaN. */
|
|
46
|
-
export type NameList = readonly [string, ...string[]];
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Month names for formatting
|
|
50
|
-
*/
|
|
51
|
-
export const MONTH_NAMES_SHORT: NameList = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
52
|
-
|
|
53
|
-
export const MONTH_NAMES_FULL: NameList = [
|
|
54
|
-
"January",
|
|
55
|
-
"February",
|
|
56
|
-
"March",
|
|
57
|
-
"April",
|
|
58
|
-
"May",
|
|
59
|
-
"June",
|
|
60
|
-
"July",
|
|
61
|
-
"August",
|
|
62
|
-
"September",
|
|
63
|
-
"October",
|
|
64
|
-
"November",
|
|
65
|
-
"December",
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Day names for formatting
|
|
70
|
-
*/
|
|
71
|
-
export const DAY_NAMES_SHORT: NameList = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
72
|
-
|
|
73
|
-
export const DAY_NAMES_FULL: NameList = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DATEDIF — complete elapsed time between two dates, in a chosen unit.
|
|
3
|
-
*
|
|
4
|
-
* Pure: two Excel serials and a unit in, a number (or a formula error value)
|
|
5
|
-
* out. The unit branches each have their own boundary handling (month-end
|
|
6
|
-
* borrowing, year wraparound), which is exactly what makes them worth testing
|
|
7
|
-
* apart from the handler that reads the arguments.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { serialToDate } from "./date-utils";
|
|
11
|
-
import { NUM_ERROR, type SpreadsheetError } from "./spreadsheet-errors";
|
|
12
|
-
|
|
13
|
-
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
14
|
-
const MONTHS_PER_YEAR = 12;
|
|
15
|
-
|
|
16
|
-
/** `date` advanced by `months`, clamping the day to the target month's length so
|
|
17
|
-
* adding a month to Jan 30 lands on the last day of a shorter month instead of
|
|
18
|
-
* overflowing into the next one. */
|
|
19
|
-
function addMonthsClamped(date: Date, months: number): Date {
|
|
20
|
-
const monthIndex = date.getUTCMonth() + months;
|
|
21
|
-
const year = date.getUTCFullYear() + Math.floor(monthIndex / MONTHS_PER_YEAR);
|
|
22
|
-
const month = ((monthIndex % MONTHS_PER_YEAR) + MONTHS_PER_YEAR) % MONTHS_PER_YEAR;
|
|
23
|
-
const lastDayOfMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
24
|
-
const day = Math.min(date.getUTCDate(), lastDayOfMonth);
|
|
25
|
-
return new Date(Date.UTC(year, month, day));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Complete `unit`s between two dates, or `#NUM!` when start is after end or
|
|
29
|
-
* the unit is not one of Y / M / D / MD / YM / YD. `unit` is matched
|
|
30
|
-
* case-insensitively. */
|
|
31
|
-
export function computeDatedif(startSerial: number, endSerial: number, unit: string): number | SpreadsheetError {
|
|
32
|
-
if (startSerial > endSerial) return NUM_ERROR;
|
|
33
|
-
|
|
34
|
-
const startDate = serialToDate(startSerial);
|
|
35
|
-
const endDate = serialToDate(endSerial);
|
|
36
|
-
|
|
37
|
-
const yearDiff = endDate.getUTCFullYear() - startDate.getUTCFullYear();
|
|
38
|
-
const monthDiff = endDate.getUTCMonth() - startDate.getUTCMonth();
|
|
39
|
-
const dayDiff = endDate.getUTCDate() - startDate.getUTCDate();
|
|
40
|
-
|
|
41
|
-
switch (unit.toUpperCase()) {
|
|
42
|
-
case "Y": {
|
|
43
|
-
// Complete years: back off one if the end has not yet reached the
|
|
44
|
-
// start's month-and-day within its year.
|
|
45
|
-
const years = yearDiff;
|
|
46
|
-
return monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? years - 1 : years;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
case "M": {
|
|
50
|
-
// Complete months, backing off one when the day-of-month has not been reached.
|
|
51
|
-
const months = yearDiff * MONTHS_PER_YEAR + monthDiff;
|
|
52
|
-
return dayDiff < 0 ? months - 1 : months;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
case "D":
|
|
56
|
-
return Math.floor(endSerial - startSerial);
|
|
57
|
-
|
|
58
|
-
case "MD": {
|
|
59
|
-
// Days left after the complete months "M" counts. Anchoring on start plus
|
|
60
|
-
// those months keeps the result non-negative and self-consistent (start +
|
|
61
|
-
// M months + MD days == end). Subtracting the calendar month before `end`
|
|
62
|
-
// instead goes negative when the start day outruns that month's length —
|
|
63
|
-
// Jan 30 → Mar 1 borrowed Feb's 28 days and returned -1.
|
|
64
|
-
const completeMonths = yearDiff * MONTHS_PER_YEAR + monthDiff - (dayDiff < 0 ? 1 : 0);
|
|
65
|
-
const anchor = addMonthsClamped(startDate, completeMonths);
|
|
66
|
-
// Compare whole days only. `end` may carry a time-of-day (a datetime
|
|
67
|
-
// serial); anchor is already UTC midnight, so strip end's time too or the
|
|
68
|
-
// remainder would swing with the clock (Codex review).
|
|
69
|
-
const endMidnight = Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate());
|
|
70
|
-
return Math.round((endMidnight - anchor.getTime()) / MS_PER_DAY);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
case "YM": {
|
|
74
|
-
// Month difference, ignoring years — wraps into 0..11.
|
|
75
|
-
const ym = dayDiff < 0 ? monthDiff - 1 : monthDiff;
|
|
76
|
-
return ym < 0 ? ym + MONTHS_PER_YEAR : ym;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
case "YD": {
|
|
80
|
-
// Day difference, ignoring years: move the start into the end's year,
|
|
81
|
-
// stepping back a year if that would put it after the end.
|
|
82
|
-
const startInEndYear = new Date(startDate);
|
|
83
|
-
startInEndYear.setUTCFullYear(endDate.getUTCFullYear());
|
|
84
|
-
if (startInEndYear.getTime() - endDate.getTime() > 0) {
|
|
85
|
-
startInEndYear.setUTCFullYear(endDate.getUTCFullYear() - 1);
|
|
86
|
-
}
|
|
87
|
-
return Math.floor((endDate.getTime() - startInEndYear.getTime()) / MS_PER_DAY);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
default:
|
|
91
|
-
return NUM_ERROR;
|
|
92
|
-
}
|
|
93
|
-
}
|