@rowsncolumns/utils 9.2.35

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 (154) hide show
  1. package/LICENSE.md +100 -0
  2. package/dist/cjs/array.d.ts +3 -0
  3. package/dist/cjs/array.d.ts.map +1 -0
  4. package/dist/cjs/array.js +19 -0
  5. package/dist/cjs/cell-data-helpers.d.ts +19 -0
  6. package/dist/cjs/cell-data-helpers.d.ts.map +1 -0
  7. package/dist/cjs/cell-data-helpers.js +64 -0
  8. package/dist/cjs/color.d.ts +20 -0
  9. package/dist/cjs/color.d.ts.map +1 -0
  10. package/dist/cjs/color.js +168 -0
  11. package/dist/cjs/colors.d.ts +2 -0
  12. package/dist/cjs/colors.d.ts.map +1 -0
  13. package/dist/cjs/colors.js +78 -0
  14. package/dist/cjs/conditional-format.d.ts +49 -0
  15. package/dist/cjs/conditional-format.d.ts.map +1 -0
  16. package/dist/cjs/conditional-format.js +227 -0
  17. package/dist/cjs/content-hash.d.ts +16 -0
  18. package/dist/cjs/content-hash.d.ts.map +1 -0
  19. package/dist/cjs/content-hash.js +72 -0
  20. package/dist/cjs/data-validation.d.ts +54 -0
  21. package/dist/cjs/data-validation.d.ts.map +1 -0
  22. package/dist/cjs/data-validation.js +249 -0
  23. package/dist/cjs/date.d.ts +15 -0
  24. package/dist/cjs/date.d.ts.map +1 -0
  25. package/dist/cjs/date.js +56 -0
  26. package/dist/cjs/dom.d.ts +10 -0
  27. package/dist/cjs/dom.d.ts.map +1 -0
  28. package/dist/cjs/dom.js +97 -0
  29. package/dist/cjs/formatter.d.ts +22 -0
  30. package/dist/cjs/formatter.d.ts.map +1 -0
  31. package/dist/cjs/formatter.js +264 -0
  32. package/dist/cjs/formula-refs.d.ts +7 -0
  33. package/dist/cjs/formula-refs.d.ts.map +1 -0
  34. package/dist/cjs/formula-refs.js +54 -0
  35. package/dist/cjs/formula.d.ts +80 -0
  36. package/dist/cjs/formula.d.ts.map +1 -0
  37. package/dist/cjs/formula.js +401 -0
  38. package/dist/cjs/index.d.ts +24 -0
  39. package/dist/cjs/index.d.ts.map +1 -0
  40. package/dist/cjs/index.js +26 -0
  41. package/dist/cjs/locale.d.ts +34 -0
  42. package/dist/cjs/locale.d.ts.map +1 -0
  43. package/dist/cjs/locale.js +260 -0
  44. package/dist/cjs/log.d.ts +4 -0
  45. package/dist/cjs/log.d.ts.map +1 -0
  46. package/dist/cjs/log.js +28 -0
  47. package/dist/cjs/memoize.d.ts +18 -0
  48. package/dist/cjs/memoize.d.ts.map +1 -0
  49. package/dist/cjs/memoize.js +128 -0
  50. package/dist/cjs/numbers.d.ts +19 -0
  51. package/dist/cjs/numbers.d.ts.map +1 -0
  52. package/dist/cjs/numbers.js +47 -0
  53. package/dist/cjs/selection.d.ts +88 -0
  54. package/dist/cjs/selection.d.ts.map +1 -0
  55. package/dist/cjs/selection.js +368 -0
  56. package/dist/cjs/shared-string-canonical.d.ts +18 -0
  57. package/dist/cjs/shared-string-canonical.d.ts.map +1 -0
  58. package/dist/cjs/shared-string-canonical.js +74 -0
  59. package/dist/cjs/sort.d.ts +15 -0
  60. package/dist/cjs/sort.d.ts.map +1 -0
  61. package/dist/cjs/sort.js +44 -0
  62. package/dist/cjs/string.d.ts +74 -0
  63. package/dist/cjs/string.d.ts.map +1 -0
  64. package/dist/cjs/string.js +237 -0
  65. package/dist/cjs/table-styles.d.ts +56 -0
  66. package/dist/cjs/table-styles.d.ts.map +1 -0
  67. package/dist/cjs/table-styles.js +362 -0
  68. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -0
  69. package/dist/cjs/typeguards.d.ts +4 -0
  70. package/dist/cjs/typeguards.d.ts.map +1 -0
  71. package/dist/cjs/typeguards.js +15 -0
  72. package/dist/cjs/uuid.d.ts +8 -0
  73. package/dist/cjs/uuid.d.ts.map +1 -0
  74. package/dist/cjs/uuid.js +30 -0
  75. package/dist/cjs/vitest.config.d.ts +3 -0
  76. package/dist/cjs/vitest.config.d.ts.map +1 -0
  77. package/dist/cjs/vitest.config.js +9 -0
  78. package/dist/esm/array.d.ts +3 -0
  79. package/dist/esm/array.d.ts.map +1 -0
  80. package/dist/esm/array.js +14 -0
  81. package/dist/esm/cell-data-helpers.d.ts +19 -0
  82. package/dist/esm/cell-data-helpers.d.ts.map +1 -0
  83. package/dist/esm/cell-data-helpers.js +39 -0
  84. package/dist/esm/color.d.ts +20 -0
  85. package/dist/esm/color.d.ts.map +1 -0
  86. package/dist/esm/color.js +152 -0
  87. package/dist/esm/colors.d.ts +2 -0
  88. package/dist/esm/colors.d.ts.map +1 -0
  89. package/dist/esm/colors.js +75 -0
  90. package/dist/esm/conditional-format.d.ts +49 -0
  91. package/dist/esm/conditional-format.d.ts.map +1 -0
  92. package/dist/esm/conditional-format.js +219 -0
  93. package/dist/esm/content-hash.d.ts +16 -0
  94. package/dist/esm/content-hash.d.ts.map +1 -0
  95. package/dist/esm/content-hash.js +68 -0
  96. package/dist/esm/data-validation.d.ts +54 -0
  97. package/dist/esm/data-validation.d.ts.map +1 -0
  98. package/dist/esm/data-validation.js +237 -0
  99. package/dist/esm/date.d.ts +15 -0
  100. package/dist/esm/date.d.ts.map +1 -0
  101. package/dist/esm/date.js +46 -0
  102. package/dist/esm/dom.d.ts +10 -0
  103. package/dist/esm/dom.d.ts.map +1 -0
  104. package/dist/esm/dom.js +90 -0
  105. package/dist/esm/formatter.d.ts +22 -0
  106. package/dist/esm/formatter.d.ts.map +1 -0
  107. package/dist/esm/formatter.js +253 -0
  108. package/dist/esm/formula-refs.d.ts +7 -0
  109. package/dist/esm/formula-refs.d.ts.map +1 -0
  110. package/dist/esm/formula-refs.js +49 -0
  111. package/dist/esm/formula.d.ts +80 -0
  112. package/dist/esm/formula.d.ts.map +1 -0
  113. package/dist/esm/formula.js +298 -0
  114. package/dist/esm/index.d.ts +24 -0
  115. package/dist/esm/index.d.ts.map +1 -0
  116. package/dist/esm/index.js +23 -0
  117. package/dist/esm/locale.d.ts +34 -0
  118. package/dist/esm/locale.d.ts.map +1 -0
  119. package/dist/esm/locale.js +223 -0
  120. package/dist/esm/log.d.ts +4 -0
  121. package/dist/esm/log.d.ts.map +1 -0
  122. package/dist/esm/log.js +3 -0
  123. package/dist/esm/memoize.d.ts +18 -0
  124. package/dist/esm/memoize.d.ts.map +1 -0
  125. package/dist/esm/memoize.js +95 -0
  126. package/dist/esm/numbers.d.ts +19 -0
  127. package/dist/esm/numbers.d.ts.map +1 -0
  128. package/dist/esm/numbers.js +40 -0
  129. package/dist/esm/selection.d.ts +88 -0
  130. package/dist/esm/selection.d.ts.map +1 -0
  131. package/dist/esm/selection.js +311 -0
  132. package/dist/esm/shared-string-canonical.d.ts +18 -0
  133. package/dist/esm/shared-string-canonical.d.ts.map +1 -0
  134. package/dist/esm/shared-string-canonical.js +68 -0
  135. package/dist/esm/sort.d.ts +15 -0
  136. package/dist/esm/sort.d.ts.map +1 -0
  137. package/dist/esm/sort.js +39 -0
  138. package/dist/esm/string.d.ts +74 -0
  139. package/dist/esm/string.d.ts.map +1 -0
  140. package/dist/esm/string.js +213 -0
  141. package/dist/esm/table-styles.d.ts +56 -0
  142. package/dist/esm/table-styles.d.ts.map +1 -0
  143. package/dist/esm/table-styles.js +367 -0
  144. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -0
  145. package/dist/esm/typeguards.d.ts +4 -0
  146. package/dist/esm/typeguards.d.ts.map +1 -0
  147. package/dist/esm/typeguards.js +7 -0
  148. package/dist/esm/uuid.d.ts +8 -0
  149. package/dist/esm/uuid.d.ts.map +1 -0
  150. package/dist/esm/uuid.js +25 -0
  151. package/dist/esm/vitest.config.d.ts +3 -0
  152. package/dist/esm/vitest.config.d.ts.map +1 -0
  153. package/dist/esm/vitest.config.js +7 -0
  154. package/package.json +49 -0
@@ -0,0 +1,219 @@
1
+ import { stripDataValidationTextPrefix } from "./data-validation";
2
+ import { checkIfStringIsReference } from "./selection";
3
+ const FORMULA_PREDICATE_BASE_CELL_REF = "A1";
4
+ const NUMBER_LITERAL_PATTERN = /^-?\d+(?:\.\d+)?$/;
5
+ const BOOLEAN_LITERAL_PATTERN = /^(TRUE|FALSE)$/i;
6
+ const escapeFormulaText = (value) => value.replace(/"/g, '""');
7
+ /**
8
+ * Parse a locale-formatted numeric string (e.g., "6,5" in fr-FR) into a JS
9
+ * number. Uses `Intl.NumberFormat` to detect decimal/group separators.
10
+ * Returns NaN on failure.
11
+ */
12
+ const parseLocaleNumberLoose = (value, locale) => {
13
+ try {
14
+ const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
15
+ const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
16
+ const group = parts.find((part) => part.type === "group")?.value ?? ",";
17
+ const cleaned = value.split(group).join("").replace(decimal, ".");
18
+ return Number(cleaned);
19
+ }
20
+ catch {
21
+ return NaN;
22
+ }
23
+ };
24
+ /**
25
+ * Normalize a condition value for use in a formula expression.
26
+ * Handles locale-specific number formats (e.g., "6,5" -> "6.5").
27
+ */
28
+ const normalizeConditionValue = (raw, allowStringLiteral = false, locale) => {
29
+ if (!raw)
30
+ return "";
31
+ const trimmed = raw.trim();
32
+ if (!trimmed)
33
+ return "";
34
+ if (trimmed.startsWith("=")) {
35
+ return trimmed.slice(1);
36
+ }
37
+ if (checkIfStringIsReference(trimmed)) {
38
+ return trimmed;
39
+ }
40
+ if (NUMBER_LITERAL_PATTERN.test(trimmed) ||
41
+ BOOLEAN_LITERAL_PATTERN.test(trimmed)) {
42
+ return trimmed;
43
+ }
44
+ // Try parsing as locale-aware number
45
+ if (locale) {
46
+ const parsed = parseLocaleNumberLoose(trimmed, locale);
47
+ if (!isNaN(parsed)) {
48
+ return String(parsed);
49
+ }
50
+ }
51
+ if (allowStringLiteral) {
52
+ return `"${escapeFormulaText(trimmed)}"`;
53
+ }
54
+ return trimmed;
55
+ };
56
+ /**
57
+ * Whether a CF rule is evaluated via the calculator (formula path) instead of
58
+ * the synchronous `checkBooleanRule` path used in
59
+ * `apps/spreadsheet/hooks/use-conditional-formatting.ts`.
60
+ *
61
+ * Returns `true` for every condition type that `buildConditionalFormatExpression`
62
+ * can express as a single-cell formula: CUSTOM_FORMULA, all NUMBER_/DATE_/TEXT_
63
+ * comparisons, BETWEEN/NOT_BETWEEN, CONTAINS/NOT_CONTAINS, STARTS_WITH/ENDS_WITH,
64
+ * ONE_OF_LIST/ONE_OF_RANGE, BLANK/NOT_BLANK, BOOLEAN, TEXT_IS_EMAIL/TEXT_IS_URL.
65
+ * The calculator stores the per-cell boolean on
66
+ * `cellData.conditionalFormattingResultById[ruleId]`.
67
+ *
68
+ * Returns `false` — leaving the rule on the sync path — for rules that aren't
69
+ * expressible as a single-cell formula:
70
+ * - Aggregate-over-range: gradient/heatmap, TOP/BOTTOM N, DUPLICATE/UNIQUE,
71
+ * NUMBER_ABOVE_AVERAGE / NUMBER_BELOW_AVERAGE — the result depends on
72
+ * other cells in the range, not just the current cell.
73
+ * - Loop-state: NUMBER_GREATER_THAN_PREVIOUS and the other `*_PREVIOUS`
74
+ * variants — compare against the prior row's value held in iteration state.
75
+ * - Calendar-relative dates: DATE_TODAY / DATE_THIS_WEEK / DATE_THIS_MONTH
76
+ * and the surrounding family, plus DATE_IS_VALID. Could be expressed via
77
+ * TODAY()-based formulas if we ever want to migrate them.
78
+ * - Cell error state: ERROR / NOT_ERROR — read directly from the extended
79
+ * value in `getValidationValue`.
80
+ *
81
+ * The consumers (rule create/update/delete enqueueing in spreadsheet-state,
82
+ * `getConditionalFormatFormula` building the expression, `getValidationValue`
83
+ * choosing where to read the per-cell value, and the `isFormulaRule` branch
84
+ * in `use-conditional-formatting.ts`) MUST stay in lockstep with this
85
+ * predicate — if it returns `true`, the calculator owns the result and the
86
+ * sync `checkBooleanRule` path must not run for that rule. Delegating to
87
+ * `buildConditionalFormatExpression` keeps the predicate and the builder from
88
+ * drifting.
89
+ */
90
+ export const isConditionalFormatFormulaRule = (rule) => {
91
+ return Boolean(buildConditionalFormatExpression(rule, FORMULA_PREDICATE_BASE_CELL_REF));
92
+ };
93
+ export const buildConditionalFormatExpression = (rule, baseCellRef, locale) => {
94
+ const condition = rule?.booleanRule?.condition;
95
+ if (!condition?.type)
96
+ return undefined;
97
+ const values = condition.values ?? [];
98
+ const fromRaw = values[0]?.userEnteredValue;
99
+ const toRaw = values[1]?.userEnteredValue;
100
+ if (condition.type === "CUSTOM_FORMULA") {
101
+ const raw = fromRaw ?? "";
102
+ const trimmed = raw.trim();
103
+ if (!trimmed)
104
+ return undefined;
105
+ return trimmed.startsWith("=") ? trimmed : `=${trimmed}`;
106
+ }
107
+ const fromExpr = normalizeConditionValue(fromRaw, true, locale);
108
+ const toExpr = normalizeConditionValue(toRaw, true, locale);
109
+ if (!fromExpr && !toExpr)
110
+ return undefined;
111
+ const comparisonOperatorMap = {
112
+ NUMBER_EQ: "=",
113
+ NUMBER_NOT_EQ: "<>",
114
+ NUMBER_GREATER: ">",
115
+ NUMBER_GREATER_THAN_EQ: ">=",
116
+ NUMBER_LESS: "<",
117
+ NUMBER_LESS_THAN_EQ: "<=",
118
+ DATE_EQ: "=",
119
+ DATE_NOT_EQ: "<>",
120
+ DATE_BEFORE: "<",
121
+ DATE_AFTER: ">",
122
+ DATE_ON_OR_BEFORE: "<=",
123
+ DATE_ON_OR_AFTER: ">=",
124
+ TEXT_EQ: "=",
125
+ TEXT_NOT_EQ: "<>",
126
+ };
127
+ if (condition.type === "NUMBER_BETWEEN" ||
128
+ condition.type === "DATE_BETWEEN") {
129
+ if (!fromExpr || !toExpr)
130
+ return undefined;
131
+ return `=AND(${baseCellRef}>=${fromExpr},${baseCellRef}<=${toExpr})`;
132
+ }
133
+ if (condition.type === "NUMBER_NOT_BETWEEN" ||
134
+ condition.type === "DATE_NOT_BETWEEN") {
135
+ if (!fromExpr || !toExpr)
136
+ return undefined;
137
+ return `=OR(${baseCellRef}<${fromExpr},${baseCellRef}>${toExpr})`;
138
+ }
139
+ const comparisonOperator = comparisonOperatorMap[condition.type];
140
+ if (comparisonOperator) {
141
+ if (!fromExpr)
142
+ return undefined;
143
+ return `=${baseCellRef}${comparisonOperator}${fromExpr}`;
144
+ }
145
+ if (condition.type === "TEXT_CONTAINS") {
146
+ if (!fromExpr)
147
+ return undefined;
148
+ return `=ISNUMBER(SEARCH(${fromExpr},${baseCellRef}))`;
149
+ }
150
+ if (condition.type === "TEXT_NOT_CONTAINS") {
151
+ if (!fromExpr)
152
+ return undefined;
153
+ return `=NOT(ISNUMBER(SEARCH(${fromExpr},${baseCellRef})))`;
154
+ }
155
+ if (condition.type === "TEXT_STARTS_WITH") {
156
+ if (!fromExpr)
157
+ return undefined;
158
+ return `=LEFT(${baseCellRef},LEN(${fromExpr}))=${fromExpr}`;
159
+ }
160
+ if (condition.type === "TEXT_ENDS_WITH") {
161
+ if (!fromExpr)
162
+ return undefined;
163
+ return `=RIGHT(${baseCellRef},LEN(${fromExpr}))=${fromExpr}`;
164
+ }
165
+ if (condition.type === "TEXT_IS_EMAIL") {
166
+ return `=ISNUMBER(SEARCH("@",${baseCellRef}))`;
167
+ }
168
+ if (condition.type === "TEXT_IS_URL") {
169
+ return `=OR(LEFT(${baseCellRef},7)="http://",LEFT(${baseCellRef},8)="https://")`;
170
+ }
171
+ if (condition.type === "ONE_OF_RANGE") {
172
+ if (!fromExpr)
173
+ return undefined;
174
+ return `=COUNTIF(${fromExpr},${baseCellRef})>0`;
175
+ }
176
+ if (condition.type === "ONE_OF_LIST") {
177
+ const entries = fromRaw
178
+ ? fromRaw.split(",").map((entry) => entry.trim())
179
+ : [];
180
+ // A leading apostrophe is Excel's text prefix — `'000s` is the text
181
+ // "000s" and a bare `'` is empty text — never part of the value. A cell
182
+ // storing the lone text-prefix apostrophe is equivalent to a blank cell,
183
+ // so the bare `'` entry accepts it too.
184
+ const conditions = entries
185
+ .flatMap((entry) => {
186
+ if (entry.startsWith("'")) {
187
+ const stripped = stripDataValidationTextPrefix(entry);
188
+ const textCondition = `${baseCellRef}="${escapeFormulaText(stripped)}"`;
189
+ return stripped === ""
190
+ ? [textCondition, `${baseCellRef}="'"`]
191
+ : [textCondition];
192
+ }
193
+ return entry
194
+ ? [`${baseCellRef}=${normalizeConditionValue(entry, true, locale)}`]
195
+ : [];
196
+ })
197
+ .filter((entry) => Boolean(entry));
198
+ if (!conditions.length)
199
+ return undefined;
200
+ return conditions.length === 1
201
+ ? `=${conditions[0]}`
202
+ : `=OR(${conditions.join(",")})`;
203
+ }
204
+ if (condition.type === "BLANK") {
205
+ return `=${baseCellRef}=""`;
206
+ }
207
+ if (condition.type === "NOT_BLANK") {
208
+ return `=${baseCellRef}<>""`;
209
+ }
210
+ if (condition.type === "BOOLEAN") {
211
+ return `=OR(${baseCellRef}=TRUE,${baseCellRef}=FALSE)`;
212
+ }
213
+ if (condition.type === "TEXT_EQ" || condition.type === "TEXT_NOT_EQ") {
214
+ if (!fromExpr)
215
+ return undefined;
216
+ return `=${baseCellRef}${comparisonOperatorMap[condition.type]}${fromExpr}`;
217
+ }
218
+ return undefined;
219
+ };
@@ -0,0 +1,16 @@
1
+ export declare const CONTENT_HASH_LENGTH = 8;
2
+ /**
3
+ * Stable, deterministic 8-character base62 ID derived from the input string.
4
+ * The same value always produces the same id across sessions, clients, and
5
+ * languages (FNV-1a 64-bit over UTF-8 bytes → low-48 → base62).
6
+ *
7
+ * 62^8 ≈ 2.18e14 ids; birthday-collision risk is negligible for spreadsheet
8
+ * shared-string tables. Callers that need defense against the astronomically
9
+ * rare same-id-different-value case should keep a random-id fallback.
10
+ *
11
+ * IMPORTANT: This algorithm is mirrored in
12
+ * `python/rowsncolumns_spreadsheet/utils.py::content_hash`. Any change to the
13
+ * constants, byte encoding, or base62 alphabet must be applied to both ports.
14
+ */
15
+ export declare const contentHash: (value: string) => string;
16
+ //# sourceMappingURL=content-hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-hash.d.ts","sourceRoot":"","sources":["../../content-hash.ts"],"names":[],"mappings":"AAeA,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAsCrC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,MAAM,KAAG,MAa3C,CAAC"}
@@ -0,0 +1,68 @@
1
+ // BigInt literals (e.g. `0x…n`) need ES2020+; the utils package targets older
2
+ // runtimes, so build them via the BigInt() constructor instead.
3
+ const FNV_OFFSET_BASIS_64 = BigInt("0xcbf29ce484222325");
4
+ const FNV_PRIME_64 = BigInt("0x100000001b3");
5
+ const ONE = BigInt(1);
6
+ const SIXTY_FOUR = BigInt(64);
7
+ const MASK_64 = (ONE << SIXTY_FOUR) - ONE;
8
+ const BASE62_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
9
+ const BASE62 = BigInt(BASE62_ALPHABET.length);
10
+ const TEXT_ENCODER = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
11
+ export const CONTENT_HASH_LENGTH = 8;
12
+ const toUtf8Bytes = (value) => {
13
+ if (TEXT_ENCODER)
14
+ return TEXT_ENCODER.encode(value);
15
+ // Minimal UTF-8 fallback for environments without TextEncoder. Mirrors the
16
+ // standard encoding so the hash stays identical across runtimes.
17
+ const bytes = [];
18
+ for (let i = 0; i < value.length; i += 1) {
19
+ let codePoint = value.charCodeAt(i);
20
+ if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < value.length) {
21
+ const next = value.charCodeAt(i + 1);
22
+ if (next >= 0xdc00 && next <= 0xdfff) {
23
+ codePoint = 0x10000 + ((codePoint - 0xd800) << 10) + (next - 0xdc00);
24
+ i += 1;
25
+ }
26
+ }
27
+ if (codePoint < 0x80) {
28
+ bytes.push(codePoint);
29
+ }
30
+ else if (codePoint < 0x800) {
31
+ bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f));
32
+ }
33
+ else if (codePoint < 0x10000) {
34
+ bytes.push(0xe0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f));
35
+ }
36
+ else {
37
+ bytes.push(0xf0 | (codePoint >> 18), 0x80 | ((codePoint >> 12) & 0x3f), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f));
38
+ }
39
+ }
40
+ return bytes;
41
+ };
42
+ /**
43
+ * Stable, deterministic 8-character base62 ID derived from the input string.
44
+ * The same value always produces the same id across sessions, clients, and
45
+ * languages (FNV-1a 64-bit over UTF-8 bytes → low-48 → base62).
46
+ *
47
+ * 62^8 ≈ 2.18e14 ids; birthday-collision risk is negligible for spreadsheet
48
+ * shared-string tables. Callers that need defense against the astronomically
49
+ * rare same-id-different-value case should keep a random-id fallback.
50
+ *
51
+ * IMPORTANT: This algorithm is mirrored in
52
+ * `python/rowsncolumns_spreadsheet/utils.py::content_hash`. Any change to the
53
+ * constants, byte encoding, or base62 alphabet must be applied to both ports.
54
+ */
55
+ export const contentHash = (value) => {
56
+ let hash = FNV_OFFSET_BASIS_64;
57
+ const bytes = toUtf8Bytes(value);
58
+ for (let i = 0; i < bytes.length; i += 1) {
59
+ hash ^= BigInt(bytes[i]);
60
+ hash = (hash * FNV_PRIME_64) & MASK_64;
61
+ }
62
+ let result = "";
63
+ for (let i = 0; i < CONTENT_HASH_LENGTH; i += 1) {
64
+ result = BASE62_ALPHABET[Number(hash % BASE62)] + result;
65
+ hash = hash / BASE62;
66
+ }
67
+ return result;
68
+ };
@@ -0,0 +1,54 @@
1
+ import type { DataValidationRule } from "@rowsncolumns/common-types";
2
+ /**
3
+ * A leading apostrophe in a data-validation / conditional-format list entry is
4
+ * Excel's text prefix — `'000s` is the text "000s" and a bare `'` is empty
5
+ * text — never part of the value itself.
6
+ */
7
+ export declare const stripDataValidationTextPrefix: (entry: string) => string;
8
+ /**
9
+ * Whether a cell's current text matches a ONE_OF_LIST option, with Excel's
10
+ * text-prefix semantics on BOTH sides: the option strips its leading `'`
11
+ * (`'000s` ≡ "000s", a bare `'` ≡ empty text) and a cell storing the lone
12
+ * text-prefix apostrophe is equivalent to a blank cell. Case-insensitive,
13
+ * mirroring `checkBooleanRule`'s ONE_OF_LIST matching.
14
+ */
15
+ export declare const dataValidationOptionMatchesCellValue: (option: string | undefined | null, cellValue: string | undefined | null) => boolean;
16
+ /**
17
+ * Whether a data validation rule is evaluated via the calculator (formula path)
18
+ * instead of the synchronous `checkBooleanRule` path used in
19
+ * `SheetCell.validate()` (`libs/spreadsheet-state/sheet-cell.ts`).
20
+ *
21
+ * Returns `true` for every condition type that `buildDataValidationExpression`
22
+ * can express as a single-cell formula: CUSTOM_FORMULA, all NUMBER_/DATE_/TEXT_
23
+ * comparisons, BETWEEN/NOT_BETWEEN, CONTAINS/NOT_CONTAINS, STARTS_WITH/ENDS_WITH,
24
+ * ONE_OF_LIST/ONE_OF_RANGE, BLANK/NOT_BLANK, BOOLEAN, TEXT_IS_EMAIL/TEXT_IS_URL.
25
+ * The calculator stores the per-cell boolean on
26
+ * `cellData.dataValidationResultById` and `SheetCell.validate()` reads
27
+ * `this.dataValidationResult` to decide validity.
28
+ *
29
+ * Returns `false` — leaving the rule on the sync path — for rules that aren't
30
+ * expressible as a single-cell formula:
31
+ * - Loop-state: NUMBER_*_PREVIOUS comparisons (compare to prior row).
32
+ * - Calendar-relative dates: DATE_TODAY / DATE_THIS_WEEK / DATE_THIS_MONTH
33
+ * and the surrounding family, plus DATE_IS_VALID.
34
+ * - Cell error state: ERROR / NOT_ERROR.
35
+ *
36
+ * Note (input-gating tradeoff): unlike conditional formatting, data validation
37
+ * decides whether a cell is flagged invalid. The formula path runs through the
38
+ * calculator, so a freshly-typed value spends one calc tick at
39
+ * `dataValidationResult === undefined`, during which `SheetCell.validate()`
40
+ * treats the cell as valid. Anything reading validity synchronously right
41
+ * after a write may see stale-valid for one tick.
42
+ *
43
+ * Consumers that must stay in lockstep with this predicate:
44
+ * - `createDataValidationRule` / update / delete (enqueueing into the calc).
45
+ * - `getDataValidationFormula` (builds the expression for the calculator).
46
+ * - `SheetCell.validate()` (chooses formula-result vs sync `checkBooleanRule`).
47
+ * - `use-on-filter-table` (uses the same predicate to decide its formula path).
48
+ *
49
+ * Delegating to `buildDataValidationExpression` keeps the predicate and the
50
+ * builder from drifting.
51
+ */
52
+ export declare const isDataValidationFormulaRule: (rule: DataValidationRule | undefined) => boolean;
53
+ export declare const buildDataValidationExpression: (rule: DataValidationRule | undefined, baseCellRef: string, locale?: string) => string | undefined;
54
+ //# sourceMappingURL=data-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-validation.d.ts","sourceRoot":"","sources":["../../data-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,kBAAkB,EACnB,MAAM,4BAA4B,CAAC;AAWpC;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAI,OAAO,MAAM,KAAG,MACd,CAAC;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,oCAAoC,GAC/C,QAAQ,MAAM,GAAG,SAAS,GAAG,IAAI,EACjC,WAAW,MAAM,GAAG,SAAS,GAAG,IAAI,KACnC,OAKF,CAAC;AAwDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,2BAA2B,GACtC,MAAM,kBAAkB,GAAG,SAAS,YAKrC,CAAC;AAEF,eAAO,MAAM,6BAA6B,GACxC,MAAM,kBAAkB,GAAG,SAAS,EACpC,aAAa,MAAM,EACnB,SAAS,MAAM,uBA4HhB,CAAC"}
@@ -0,0 +1,237 @@
1
+ import { checkIfStringIsReference } from "./selection";
2
+ const FORMULA_PREDICATE_BASE_CELL_REF = "A1";
3
+ const NUMBER_LITERAL_PATTERN = /^-?\d+(?:\.\d+)?$/;
4
+ const BOOLEAN_LITERAL_PATTERN = /^(TRUE|FALSE)$/i;
5
+ const escapeFormulaText = (value) => value.replace(/"/g, '""');
6
+ /**
7
+ * A leading apostrophe in a data-validation / conditional-format list entry is
8
+ * Excel's text prefix — `'000s` is the text "000s" and a bare `'` is empty
9
+ * text — never part of the value itself.
10
+ */
11
+ export const stripDataValidationTextPrefix = (entry) => entry.startsWith("'") ? entry.slice(1) : entry;
12
+ /**
13
+ * Whether a cell's current text matches a ONE_OF_LIST option, with Excel's
14
+ * text-prefix semantics on BOTH sides: the option strips its leading `'`
15
+ * (`'000s` ≡ "000s", a bare `'` ≡ empty text) and a cell storing the lone
16
+ * text-prefix apostrophe is equivalent to a blank cell. Case-insensitive,
17
+ * mirroring `checkBooleanRule`'s ONE_OF_LIST matching.
18
+ */
19
+ export const dataValidationOptionMatchesCellValue = (option, cellValue) => {
20
+ const normalizedOption = stripDataValidationTextPrefix(option ?? "");
21
+ const rawValue = cellValue ?? "";
22
+ const normalizedValue = rawValue === "'" ? "" : rawValue;
23
+ return normalizedOption.toLowerCase() === normalizedValue.toLowerCase();
24
+ };
25
+ /**
26
+ * Parse a locale-formatted numeric string (e.g., "6,5" in fr-FR) into a JS
27
+ * number. Uses `Intl.NumberFormat` to detect decimal/group separators.
28
+ * Returns NaN on failure.
29
+ */
30
+ const parseLocaleNumberLoose = (value, locale) => {
31
+ try {
32
+ const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
33
+ const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
34
+ const group = parts.find((part) => part.type === "group")?.value ?? ",";
35
+ const cleaned = value.split(group).join("").replace(decimal, ".");
36
+ return Number(cleaned);
37
+ }
38
+ catch {
39
+ return NaN;
40
+ }
41
+ };
42
+ /**
43
+ * Normalize a condition value for use in a formula expression.
44
+ * Handles locale-specific number formats (e.g., "6,5" -> "6.5").
45
+ */
46
+ const normalizeConditionValue = (raw, allowStringLiteral = false, locale) => {
47
+ if (!raw)
48
+ return "";
49
+ const trimmed = raw.trim();
50
+ if (!trimmed)
51
+ return "";
52
+ if (trimmed.startsWith("=")) {
53
+ return trimmed.slice(1);
54
+ }
55
+ if (checkIfStringIsReference(trimmed)) {
56
+ return trimmed;
57
+ }
58
+ if (NUMBER_LITERAL_PATTERN.test(trimmed) ||
59
+ BOOLEAN_LITERAL_PATTERN.test(trimmed)) {
60
+ return trimmed;
61
+ }
62
+ // Try parsing as locale-aware number
63
+ if (locale) {
64
+ const parsed = parseLocaleNumberLoose(trimmed, locale);
65
+ if (!isNaN(parsed)) {
66
+ return String(parsed);
67
+ }
68
+ }
69
+ if (allowStringLiteral) {
70
+ return `"${escapeFormulaText(trimmed)}"`;
71
+ }
72
+ return trimmed;
73
+ };
74
+ /**
75
+ * Whether a data validation rule is evaluated via the calculator (formula path)
76
+ * instead of the synchronous `checkBooleanRule` path used in
77
+ * `SheetCell.validate()` (`libs/spreadsheet-state/sheet-cell.ts`).
78
+ *
79
+ * Returns `true` for every condition type that `buildDataValidationExpression`
80
+ * can express as a single-cell formula: CUSTOM_FORMULA, all NUMBER_/DATE_/TEXT_
81
+ * comparisons, BETWEEN/NOT_BETWEEN, CONTAINS/NOT_CONTAINS, STARTS_WITH/ENDS_WITH,
82
+ * ONE_OF_LIST/ONE_OF_RANGE, BLANK/NOT_BLANK, BOOLEAN, TEXT_IS_EMAIL/TEXT_IS_URL.
83
+ * The calculator stores the per-cell boolean on
84
+ * `cellData.dataValidationResultById` and `SheetCell.validate()` reads
85
+ * `this.dataValidationResult` to decide validity.
86
+ *
87
+ * Returns `false` — leaving the rule on the sync path — for rules that aren't
88
+ * expressible as a single-cell formula:
89
+ * - Loop-state: NUMBER_*_PREVIOUS comparisons (compare to prior row).
90
+ * - Calendar-relative dates: DATE_TODAY / DATE_THIS_WEEK / DATE_THIS_MONTH
91
+ * and the surrounding family, plus DATE_IS_VALID.
92
+ * - Cell error state: ERROR / NOT_ERROR.
93
+ *
94
+ * Note (input-gating tradeoff): unlike conditional formatting, data validation
95
+ * decides whether a cell is flagged invalid. The formula path runs through the
96
+ * calculator, so a freshly-typed value spends one calc tick at
97
+ * `dataValidationResult === undefined`, during which `SheetCell.validate()`
98
+ * treats the cell as valid. Anything reading validity synchronously right
99
+ * after a write may see stale-valid for one tick.
100
+ *
101
+ * Consumers that must stay in lockstep with this predicate:
102
+ * - `createDataValidationRule` / update / delete (enqueueing into the calc).
103
+ * - `getDataValidationFormula` (builds the expression for the calculator).
104
+ * - `SheetCell.validate()` (chooses formula-result vs sync `checkBooleanRule`).
105
+ * - `use-on-filter-table` (uses the same predicate to decide its formula path).
106
+ *
107
+ * Delegating to `buildDataValidationExpression` keeps the predicate and the
108
+ * builder from drifting.
109
+ */
110
+ export const isDataValidationFormulaRule = (rule) => {
111
+ return Boolean(buildDataValidationExpression(rule, FORMULA_PREDICATE_BASE_CELL_REF));
112
+ };
113
+ export const buildDataValidationExpression = (rule, baseCellRef, locale) => {
114
+ const condition = rule?.condition;
115
+ if (!condition?.type)
116
+ return undefined;
117
+ const values = condition.values ?? [];
118
+ const fromRaw = values[0]?.userEnteredValue;
119
+ const toRaw = values[1]?.userEnteredValue;
120
+ if (condition.type === "CUSTOM_FORMULA") {
121
+ const raw = fromRaw ?? "";
122
+ const trimmed = raw.trim();
123
+ if (!trimmed)
124
+ return undefined;
125
+ return trimmed.startsWith("=") ? trimmed : `=${trimmed}`;
126
+ }
127
+ const fromExpr = normalizeConditionValue(fromRaw, true, locale);
128
+ const toExpr = normalizeConditionValue(toRaw, true, locale);
129
+ if (!fromExpr && !toExpr)
130
+ return undefined;
131
+ const comparisonOperatorMap = {
132
+ NUMBER_EQ: "=",
133
+ NUMBER_NOT_EQ: "<>",
134
+ NUMBER_GREATER: ">",
135
+ NUMBER_GREATER_THAN_EQ: ">=",
136
+ NUMBER_LESS: "<",
137
+ NUMBER_LESS_THAN_EQ: "<=",
138
+ DATE_EQ: "=",
139
+ DATE_NOT_EQ: "<>",
140
+ DATE_BEFORE: "<",
141
+ DATE_AFTER: ">",
142
+ DATE_ON_OR_BEFORE: "<=",
143
+ DATE_ON_OR_AFTER: ">=",
144
+ TEXT_EQ: "=",
145
+ TEXT_NOT_EQ: "<>",
146
+ };
147
+ if (condition.type === "NUMBER_BETWEEN" ||
148
+ condition.type === "DATE_BETWEEN") {
149
+ if (!fromExpr || !toExpr)
150
+ return undefined;
151
+ return `=AND(${baseCellRef}>=${fromExpr},${baseCellRef}<=${toExpr})`;
152
+ }
153
+ if (condition.type === "NUMBER_NOT_BETWEEN" ||
154
+ condition.type === "DATE_NOT_BETWEEN") {
155
+ if (!fromExpr || !toExpr)
156
+ return undefined;
157
+ return `=OR(${baseCellRef}<${fromExpr},${baseCellRef}>${toExpr})`;
158
+ }
159
+ const comparisonOperator = comparisonOperatorMap[condition.type];
160
+ if (comparisonOperator) {
161
+ if (!fromExpr)
162
+ return undefined;
163
+ return `=${baseCellRef}${comparisonOperator}${fromExpr}`;
164
+ }
165
+ if (condition.type === "TEXT_CONTAINS") {
166
+ if (!fromExpr)
167
+ return undefined;
168
+ return `=ISNUMBER(SEARCH(${fromExpr},${baseCellRef}))`;
169
+ }
170
+ if (condition.type === "TEXT_NOT_CONTAINS") {
171
+ if (!fromExpr)
172
+ return undefined;
173
+ return `=NOT(ISNUMBER(SEARCH(${fromExpr},${baseCellRef})))`;
174
+ }
175
+ if (condition.type === "TEXT_STARTS_WITH") {
176
+ if (!fromExpr)
177
+ return undefined;
178
+ return `=LEFT(${baseCellRef},LEN(${fromExpr}))=${fromExpr}`;
179
+ }
180
+ if (condition.type === "TEXT_ENDS_WITH") {
181
+ if (!fromExpr)
182
+ return undefined;
183
+ return `=RIGHT(${baseCellRef},LEN(${fromExpr}))=${fromExpr}`;
184
+ }
185
+ if (condition.type === "TEXT_IS_EMAIL") {
186
+ return `=ISNUMBER(SEARCH("@",${baseCellRef}))`;
187
+ }
188
+ if (condition.type === "TEXT_IS_URL") {
189
+ return `=OR(LEFT(${baseCellRef},7)="http://",LEFT(${baseCellRef},8)="https://")`;
190
+ }
191
+ if (condition.type === "ONE_OF_RANGE") {
192
+ if (!fromExpr)
193
+ return undefined;
194
+ if (rule?.allowBlank) {
195
+ return `=IF(${baseCellRef}="",TRUE,COUNTIF(${fromExpr},${baseCellRef})>0)`;
196
+ }
197
+ return `=COUNTIF(${fromExpr},${baseCellRef})>0`;
198
+ }
199
+ if (condition.type === "ONE_OF_LIST") {
200
+ const entries = fromRaw
201
+ ? fromRaw.split(",").map((entry) => entry.trim())
202
+ : [];
203
+ // A leading apostrophe is Excel's text prefix — `'000s` is the text
204
+ // "000s" and a bare `'` is empty text — never part of the value.
205
+ const conditions = entries
206
+ .flatMap((entry) => {
207
+ if (entry.startsWith("'")) {
208
+ const stripped = stripDataValidationTextPrefix(entry);
209
+ const textCondition = `${baseCellRef}="${escapeFormulaText(stripped)}"`;
210
+ // The bare `'` entry means empty text, and a cell storing the lone
211
+ // text-prefix apostrophe is equivalent to a blank cell — accept it too.
212
+ return stripped === ""
213
+ ? [textCondition, `${baseCellRef}="'"`]
214
+ : [textCondition];
215
+ }
216
+ return entry
217
+ ? [`${baseCellRef}=${normalizeConditionValue(entry, true, locale)}`]
218
+ : [];
219
+ })
220
+ .filter((entry) => Boolean(entry));
221
+ if (!conditions.length)
222
+ return undefined;
223
+ return conditions.length === 1
224
+ ? `=${conditions[0]}`
225
+ : `=OR(${conditions.join(",")})`;
226
+ }
227
+ if (condition.type === "BLANK") {
228
+ return `=${baseCellRef}=""`;
229
+ }
230
+ if (condition.type === "NOT_BLANK") {
231
+ return `=${baseCellRef}<>""`;
232
+ }
233
+ if (condition.type === "BOOLEAN") {
234
+ return `=OR(${baseCellRef}=TRUE,${baseCellRef}=FALSE)`;
235
+ }
236
+ return undefined;
237
+ };
@@ -0,0 +1,15 @@
1
+ export type DateSerialMode = "local" | "excel-utc";
2
+ /**
3
+ * Convert date to serial with explicit time zone behavior.
4
+ * - local: interpret date components in local time (UI entry behavior).
5
+ * - excel-utc: treat Date as UTC (formula engine behavior).
6
+ */
7
+ export declare const toSerialWithMode: (date: Date, use1904?: boolean, mode?: DateSerialMode) => number;
8
+ export declare const toSerial: (date: Date, use1904?: boolean) => number;
9
+ /**
10
+ * Convert serial to date
11
+ * @param serial
12
+ * @returns
13
+ */
14
+ export declare const toDate: (serial: number, use1904?: boolean) => Date;
15
+ //# sourceMappingURL=date.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"date.d.ts","sourceRoot":"","sources":["../../date.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,WAAW,CAAC;AAEnD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAC3B,MAAM,IAAI,EACV,iBAAe,EACf,OAAM,cAAwB,WAwB/B,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,MAAM,IAAI,EAAE,iBAAe,WAEnD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,MAAM,GAAI,QAAQ,MAAM,EAAE,iBAAe,SA+BrD,CAAC"}
@@ -0,0 +1,46 @@
1
+ const DAY_TO_MS = 24 * 60 * 60 * 1000;
2
+ const EXCEL_LEAP_BUG_SERIAL = 60;
3
+ /**
4
+ * Convert date to serial with explicit time zone behavior.
5
+ * - local: interpret date components in local time (UI entry behavior).
6
+ * - excel-utc: treat Date as UTC (formula engine behavior).
7
+ */
8
+ export const toSerialWithMode = (date, use1904 = false, mode = "local") => {
9
+ const utcMillis = mode === "excel-utc"
10
+ ? date.getTime()
11
+ : Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
12
+ if (use1904) {
13
+ const baseUtc = Date.UTC(1904, 0, 1);
14
+ return (utcMillis - baseUtc) / DAY_TO_MS;
15
+ }
16
+ const baseUtc = Date.UTC(1899, 11, 31);
17
+ let dayCount = (utcMillis - baseUtc) / DAY_TO_MS;
18
+ if (utcMillis >= Date.UTC(1900, 2, 1)) {
19
+ dayCount += 1;
20
+ }
21
+ return dayCount;
22
+ };
23
+ export const toSerial = (date, use1904 = false) => {
24
+ return toSerialWithMode(date, use1904, "local");
25
+ };
26
+ /**
27
+ * Convert serial to date
28
+ * @param serial
29
+ * @returns
30
+ */
31
+ export const toDate = (serial, use1904 = false) => {
32
+ if (use1904) {
33
+ const baseUtc = Date.UTC(1904, 0, 1);
34
+ const utcMillis = baseUtc + serial * DAY_TO_MS;
35
+ const utcDate = new Date(utcMillis);
36
+ return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate(), utcDate.getUTCHours(), utcDate.getUTCMinutes(), utcDate.getUTCSeconds(), utcDate.getUTCMilliseconds());
37
+ }
38
+ if (serial === EXCEL_LEAP_BUG_SERIAL) {
39
+ return new Date(1900, 1, 28);
40
+ }
41
+ const baseUtc = Date.UTC(1899, 11, 31);
42
+ const dayCount = serial < EXCEL_LEAP_BUG_SERIAL ? serial : serial - 1;
43
+ const utcMillis = baseUtc + dayCount * DAY_TO_MS;
44
+ const utcDate = new Date(utcMillis);
45
+ return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate(), utcDate.getUTCHours(), utcDate.getUTCMinutes(), utcDate.getUTCSeconds(), utcDate.getUTCMilliseconds());
46
+ };