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,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
- }