@ganju/utils 0.0.14 → 0.0.16

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 (111) hide show
  1. package/dist/channelCustomer.d.ts +99 -0
  2. package/dist/channelCustomer.d.ts.map +1 -0
  3. package/dist/channelCustomer.js +533 -0
  4. package/dist/channelDebounce.d.ts +7 -0
  5. package/dist/channelDebounce.d.ts.map +1 -1
  6. package/dist/chunkOverlap.d.ts +2 -0
  7. package/dist/chunkOverlap.d.ts.map +1 -0
  8. package/dist/chunkOverlap.js +9 -0
  9. package/dist/chunking.d.ts.map +1 -1
  10. package/dist/chunking.js +24 -4
  11. package/dist/citations.d.ts +28 -0
  12. package/dist/citations.d.ts.map +1 -0
  13. package/dist/citations.js +111 -0
  14. package/dist/constants.d.ts +49 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +202 -17
  17. package/dist/data.d.ts +9 -0
  18. package/dist/data.d.ts.map +1 -0
  19. package/dist/data.js +78 -0
  20. package/dist/dataApi.d.ts +511 -0
  21. package/dist/dataApi.d.ts.map +1 -0
  22. package/dist/dataApi.js +355 -0
  23. package/dist/dataCompiler.d.ts +68 -0
  24. package/dist/dataCompiler.d.ts.map +1 -0
  25. package/dist/dataCompiler.js +726 -0
  26. package/dist/dataDdl.d.ts +29 -0
  27. package/dist/dataDdl.d.ts.map +1 -0
  28. package/dist/dataDdl.js +354 -0
  29. package/dist/dataEngine.d.ts +143 -0
  30. package/dist/dataEngine.d.ts.map +1 -0
  31. package/dist/dataEngine.js +2022 -0
  32. package/dist/dataIdentifiers.d.ts +8 -0
  33. package/dist/dataIdentifiers.d.ts.map +1 -0
  34. package/dist/dataIdentifiers.js +55 -0
  35. package/dist/dataImport.d.ts +77 -0
  36. package/dist/dataImport.d.ts.map +1 -0
  37. package/dist/dataImport.js +450 -0
  38. package/dist/dataRpc.d.ts +131 -0
  39. package/dist/dataRpc.d.ts.map +1 -0
  40. package/dist/dataRpc.js +2 -0
  41. package/dist/dataTypes.d.ts +157 -0
  42. package/dist/dataTypes.d.ts.map +1 -0
  43. package/dist/dataTypes.js +160 -0
  44. package/dist/index.d.ts +570 -36
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +72 -0
  47. package/dist/jsonSchemaToZodShape.d.ts +5 -1
  48. package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
  49. package/dist/jsonSchemaToZodShape.js +21 -9
  50. package/dist/oauth.d.ts +14 -0
  51. package/dist/oauth.d.ts.map +1 -1
  52. package/dist/oauth.js +42 -2
  53. package/dist/pagination.d.ts +47 -0
  54. package/dist/pagination.d.ts.map +1 -0
  55. package/dist/pagination.js +160 -0
  56. package/dist/processQueueBatch.d.ts +6 -1
  57. package/dist/processQueueBatch.d.ts.map +1 -1
  58. package/dist/processQueueBatch.js +23 -10
  59. package/dist/queueFailure.d.ts +14 -0
  60. package/dist/queueFailure.d.ts.map +1 -0
  61. package/dist/queueFailure.js +43 -0
  62. package/dist/resourceRerank.d.ts +3 -2
  63. package/dist/resourceRerank.d.ts.map +1 -1
  64. package/dist/resourceRerank.js +8 -3
  65. package/dist/schema.d.ts +374 -29
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +290 -6
  68. package/dist/ssrf.d.ts +1 -0
  69. package/dist/ssrf.d.ts.map +1 -1
  70. package/dist/ssrf.js +48 -12
  71. package/dist/toolConfirmation.d.ts +48 -0
  72. package/dist/toolConfirmation.d.ts.map +1 -1
  73. package/dist/toolConfirmation.js +75 -1
  74. package/dist/toolCount.d.ts +13 -0
  75. package/dist/toolCount.d.ts.map +1 -0
  76. package/dist/toolCount.js +45 -0
  77. package/dist/toolLint.d.ts +1 -1
  78. package/dist/toolLint.d.ts.map +1 -1
  79. package/dist/toolLint.js +20 -0
  80. package/dist/untrustedContent.d.ts +135 -0
  81. package/dist/untrustedContent.d.ts.map +1 -0
  82. package/dist/untrustedContent.js +402 -0
  83. package/package.json +17 -1
  84. package/src/channelCustomer.ts +604 -0
  85. package/src/channelDebounce.ts +17 -0
  86. package/src/chunkOverlap.ts +6 -0
  87. package/src/chunking.ts +25 -4
  88. package/src/citations.ts +127 -0
  89. package/src/constants.ts +219 -17
  90. package/src/data.ts +74 -0
  91. package/src/dataApi.ts +428 -0
  92. package/src/dataCompiler.ts +948 -0
  93. package/src/dataDdl.ts +419 -0
  94. package/src/dataEngine.ts +2753 -0
  95. package/src/dataIdentifiers.ts +63 -0
  96. package/src/dataImport.ts +554 -0
  97. package/src/dataRpc.ts +121 -0
  98. package/src/dataTypes.ts +312 -0
  99. package/src/index.ts +156 -2
  100. package/src/jsonSchemaToZodShape.ts +31 -13
  101. package/src/oauth.ts +42 -1
  102. package/src/pagination.ts +193 -0
  103. package/src/processQueueBatch.ts +42 -12
  104. package/src/queueFailure.ts +63 -0
  105. package/src/resourceRerank.ts +10 -3
  106. package/src/schema.ts +346 -4
  107. package/src/ssrf.ts +40 -11
  108. package/src/toolConfirmation.ts +123 -0
  109. package/src/toolCount.ts +53 -0
  110. package/src/toolLint.ts +27 -0
  111. package/src/untrustedContent.ts +476 -0
@@ -0,0 +1,63 @@
1
+ // Table and column names an owner may use. Every name reaches SQL quoted, so
2
+ // SQL keywords are fine ("order" is a natural column); what is refused is
3
+ // what would collide with SQLite's own names or ours: a table name is also the
4
+ // prefix of its search table, and `__` is kept for those.
5
+
6
+ export const DATA_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
7
+
8
+ // Columns every table has, set by the database, never by a caller.
9
+ export const DATA_SYSTEM_COLUMNS = ['id', 'created_at', 'updated_at'] as const;
10
+ // Kept on every row and never shown to the assistant.
11
+ export const DATA_HIDDEN_COLUMNS = [
12
+ 'version',
13
+ 'created_by',
14
+ 'updated_by',
15
+ 'participant_id'
16
+ ] as const;
17
+
18
+ // Names SQLite gives a meaning of its own.
19
+ const SQLITE_ALIASES = new Set(['rowid', 'oid', '_rowid_']);
20
+
21
+ export type IdentifierKind = 'table' | 'column';
22
+
23
+ // Why `name` can't be used, or null when it can.
24
+ export const identifierIssue = (
25
+ name: unknown,
26
+ kind: IdentifierKind
27
+ ): string | null => {
28
+ if (typeof name !== 'string' || name.length === 0) {
29
+ return `A ${kind} needs a name.`;
30
+ }
31
+ if (!DATA_IDENTIFIER_PATTERN.test(name)) {
32
+ return `"${name}" isn't a valid ${kind} name: use lowercase letters, digits and _, starting with a letter, up to 63 characters.`;
33
+ }
34
+ if (name.includes('__')) {
35
+ return `"${name}": two underscores in a row are reserved.`;
36
+ }
37
+ if (name.startsWith('sqlite_') || name.startsWith('_ganju_')) {
38
+ return `"${name}" is a reserved name.`;
39
+ }
40
+ // Tables share one namespace with indexes and triggers, which we name
41
+ // ix_<table>_<column>, ux_… and tr_….
42
+ if (kind === 'table' && /^(ix|ux|tr)_\d/.test(name)) {
43
+ return `"${name}" looks like one of the database's own index names; pick another table name.`;
44
+ }
45
+ if (SQLITE_ALIASES.has(name)) {
46
+ return `"${name}" is reserved by SQLite; pick another ${kind} name (e.g. "${name}_ref").`;
47
+ }
48
+ if (
49
+ kind === 'column' &&
50
+ ((DATA_SYSTEM_COLUMNS as readonly string[]).includes(name) ||
51
+ (DATA_HIDDEN_COLUMNS as readonly string[]).includes(name))
52
+ ) {
53
+ return `"${name}" is a column every table already has.`;
54
+ }
55
+ return null;
56
+ };
57
+
58
+ export const isValidIdentifier = (name: unknown, kind: IdentifierKind) =>
59
+ identifierIssue(name, kind) === null;
60
+
61
+ // Only ever called with a name that passed identifierIssue, or with one of
62
+ // our own (`_ganju_*`, system columns); the quotes keep keywords inert.
63
+ export const quoteIdentifier = (name: string): string => `"${name}"`;
@@ -0,0 +1,554 @@
1
+ // Reading a spreadsheet into a table: the column types guessed for a new
2
+ // table, the headers matched to an existing one's columns, and each cell
3
+ // checked the way the database will check it. The dashboard's import and
4
+ // `ganju data import` both use these, so a file lands the same way from
5
+ // either. Entry point `@ganju/utils/dataImport`.
6
+ import { toStored } from './dataCompiler';
7
+ import {
8
+ DATA_HIDDEN_COLUMNS,
9
+ DATA_SYSTEM_COLUMNS,
10
+ identifierIssue
11
+ } from './dataIdentifiers';
12
+ import {
13
+ DATA_MAX_COLUMNS,
14
+ DATA_MAX_ENUM_VALUES,
15
+ type DataColumn,
16
+ type DataColumnInput,
17
+ type DataColumnType,
18
+ type DataTable
19
+ } from './dataTypes';
20
+
21
+ export { identifierIssue, isValidIdentifier } from './dataIdentifiers';
22
+ // The CLI reads tables through this entry point too, and writes them back
23
+ // out the way an export's schema.json does.
24
+ export { toTableInput } from './dataTypes';
25
+
26
+ // A cell as a parser hands it over. Dates are expected as ISO strings: the
27
+ // caller converts its parser's own date objects first.
28
+ export type ImportCell = string | number | boolean | null | undefined;
29
+ export type ImportRow = ImportCell[];
30
+
31
+ // How many rows the type guess reads.
32
+ export const DATA_IMPORT_SAMPLE_ROWS = 200;
33
+ // Rows sent per request, the API's own limit.
34
+ export const DATA_IMPORT_BATCH = 500;
35
+
36
+ // Up to this many distinct values, each seen at least twice on average, a
37
+ // text column reads as a list of options.
38
+ const ENUM_MAX_DISTINCT = 12;
39
+ const LONG_TEXT_LENGTH = 200;
40
+
41
+ const TRUE_WORDS = new Set(['true', 'yes', 'y', 'si', 'sí', 'verdadero', '1']);
42
+ const FALSE_WORDS = new Set(['false', 'no', 'n', 'falso', '0']);
43
+
44
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
45
+ const DATETIME =
46
+ /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
47
+ const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
48
+ const URL = /^https?:\/\/\S+$/i;
49
+ // Digits with the usual separators, long enough not to be a quantity.
50
+ const PHONE = /^\+?[\d\s().-]{7,25}$/;
51
+ // A header that names a phone column: its numbers are phones even when the
52
+ // sheet stored them as plain numbers.
53
+ const PHONE_HEADER = /phone|tel|telefono|celular|movil|mobile|whatsapp/;
54
+ // Digits with "." or "," between them, nothing else.
55
+ const NUMBER_TEXT = /^-?\d+([.,]\d+)*$/;
56
+ // A code, not a quantity: "05001" loses its zero as a number.
57
+ const LEADING_ZERO = /^-?0\d/;
58
+
59
+ const isBlank = (cell: ImportCell) =>
60
+ cell === null || cell === undefined || String(cell).trim() === '';
61
+
62
+ const text = (cell: ImportCell) => String(cell ?? '').trim();
63
+
64
+ // The character a file writes before a number's fraction: "1.234,50" in
65
+ // Colombia and most of Europe, "1,234.50" in the US.
66
+ export type DecimalMark = '.' | ',';
67
+
68
+ const otherMark = (mark: DecimalMark): DecimalMark => (mark === '.' ? ',' : '.');
69
+
70
+ // What one value says about the file's decimal mark on its own, or null when
71
+ // it could be either: "28.000" is 28 thousand or 28.
72
+ const markOf = (value: string): DecimalMark | null => {
73
+ if (!NUMBER_TEXT.test(value)) return null;
74
+ const dot = value.lastIndexOf('.');
75
+ const comma = value.lastIndexOf(',');
76
+ if (dot >= 0 && comma >= 0) return dot > comma ? '.' : ',';
77
+ const mark: DecimalMark = dot >= 0 ? '.' : ',';
78
+ const parts = value.replace(/^-/, '').split(mark);
79
+ if (parts.length === 1) return null;
80
+ // Used twice, it can only group thousands.
81
+ if (parts.length > 2) return otherMark(mark);
82
+ const [whole, fraction] = parts;
83
+ if (fraction.length !== 3 || whole.length > 3 || /^0+$/.test(whole)) return mark;
84
+ return null;
85
+ };
86
+
87
+ /**
88
+ * The decimal mark a file's numbers use, from the values that can only be
89
+ * read one way ("12,5", "1.234.567", "1,234.50"). Undefined when none of
90
+ * them say, or they disagree evenly: each "28.000" is then flagged.
91
+ */
92
+ export const detectDecimalMark = (
93
+ rows: ImportRow[]
94
+ ): DecimalMark | undefined => {
95
+ let dot = 0;
96
+ let comma = 0;
97
+ for (const row of rows) {
98
+ for (const cell of row) {
99
+ if (typeof cell !== 'string') continue;
100
+ const mark = markOf(cell.trim());
101
+ if (mark === '.') dot++;
102
+ else if (mark === ',') comma++;
103
+ }
104
+ }
105
+ if (dot === comma) return undefined;
106
+ return dot > comma ? '.' : ',';
107
+ };
108
+
109
+ export interface ParsedNumber {
110
+ value: number;
111
+ // Read with a guess: one separator before exactly three digits, and no
112
+ // decimal mark to say which it is. Taken as thousands.
113
+ ambiguous: boolean;
114
+ }
115
+
116
+ /**
117
+ * A number as a sheet writes it, with its thousands separators:
118
+ * "28.000" → 28000 and "1.234,50" → 1234.5 with a "," mark. A value is
119
+ * thousands-separated when the same separator repeats, when both appear (the
120
+ * last is the decimal), or when it has three digits after its only one and
121
+ * the file's mark is the other character. Null when it isn't a number.
122
+ */
123
+ export const parseNumber = (
124
+ value: string,
125
+ mark?: DecimalMark
126
+ ): ParsedNumber | null => {
127
+ const raw = value.trim();
128
+ if (!NUMBER_TEXT.test(raw)) return null;
129
+ const negative = raw.startsWith('-');
130
+ const digits = negative ? raw.slice(1) : raw;
131
+ const dot = digits.lastIndexOf('.');
132
+ const comma = digits.lastIndexOf(',');
133
+ let decimal: DecimalMark | null;
134
+ let ambiguous = false;
135
+ if (dot < 0 && comma < 0) {
136
+ decimal = null;
137
+ } else if (dot >= 0 && comma >= 0) {
138
+ decimal = dot > comma ? '.' : ',';
139
+ } else {
140
+ const sep: DecimalMark = dot >= 0 ? '.' : ',';
141
+ const own = markOf(digits);
142
+ if (own !== null) {
143
+ decimal = own === sep ? sep : null;
144
+ } else if (mark) {
145
+ decimal = mark === sep ? sep : null;
146
+ } else {
147
+ decimal = null;
148
+ ambiguous = true;
149
+ }
150
+ }
151
+ const [whole, fraction = '', ...rest] =
152
+ decimal === null ? [digits] : digits.split(decimal);
153
+ if (rest.length) return null;
154
+ const groups = whole.split(/[.,]/);
155
+ // Thousands come in threes after a first group of one to three digits.
156
+ if (
157
+ groups.length > 1 &&
158
+ (groups[0].length > 3 ||
159
+ groups.slice(1).some(g => g.length !== 3) ||
160
+ new Set(whole.replace(/\d/g, '')).size > 1)
161
+ ) {
162
+ return null;
163
+ }
164
+ if (fraction && /[.,]/.test(fraction)) return null;
165
+ const n = Number(`${negative ? '-' : ''}${groups.join('')}${fraction ? `.${fraction}` : ''}`);
166
+ return Number.isFinite(n) ? { value: n, ambiguous } : null;
167
+ };
168
+
169
+ // Lowercase, no accents, nothing but letters and digits: how a header is
170
+ // compared with a column's name or label.
171
+ export const normaliseHeader = (header: string) =>
172
+ header
173
+ .normalize('NFKD')
174
+ .replace(/[̀-ͯ]/g, '')
175
+ .toLowerCase()
176
+ .replace(/[^a-z0-9]/g, '');
177
+
178
+ // A header as a column name the database accepts: "Precio (COP)" →
179
+ // "precio_cop". A name every table already has gets a prefix, so a sheet's
180
+ // own "id" column is kept as "source_id".
181
+ export const columnNameFromHeader = (header: string, index: number) => {
182
+ let name = header
183
+ .normalize('NFKD')
184
+ .replace(/[̀-ͯ]/g, '')
185
+ .toLowerCase()
186
+ .replace(/[^a-z0-9]+/g, '_')
187
+ .replace(/^_+|_+$/g, '')
188
+ .replace(/_{2,}/g, '_')
189
+ .slice(0, 63);
190
+ if (!name) name = `column_${index + 1}`;
191
+ if (!/^[a-z]/.test(name)) name = `c_${name}`.slice(0, 63);
192
+ if (
193
+ (DATA_SYSTEM_COLUMNS as readonly string[]).includes(name) ||
194
+ (DATA_HIDDEN_COLUMNS as readonly string[]).includes(name) ||
195
+ identifierIssue(name, 'column') !== null
196
+ ) {
197
+ name = `source_${name}`.slice(0, 63);
198
+ }
199
+ return identifierIssue(name, 'column') === null ? name : `column_${index + 1}`;
200
+ };
201
+
202
+ // How many values in a sample may disagree with a column's type and still
203
+ // have it guessed: a typo in one cell shouldn't turn a price column into
204
+ // text. Those rows are flagged in the preview instead.
205
+ const tolerated = (count: number) =>
206
+ count < 3 ? 0 : Math.max(1, Math.floor(count * 0.1));
207
+
208
+ export interface GuessOptions {
209
+ // The file's decimal mark, from detectDecimalMark.
210
+ decimalMark?: DecimalMark;
211
+ // The column's header, which can say what its numbers are.
212
+ header?: string;
213
+ }
214
+
215
+ // The type the values in one column read as, from the first sample rows.
216
+ export const guessColumnType = (
217
+ values: ImportCell[],
218
+ options: GuessOptions = {}
219
+ ): {
220
+ type: DataColumnType;
221
+ values?: string[];
222
+ } => {
223
+ const cells = values
224
+ .slice(0, DATA_IMPORT_SAMPLE_ROWS)
225
+ .filter(cell => !isBlank(cell));
226
+ if (cells.length === 0) return { type: 'text' };
227
+ const count = (test: (cell: ImportCell) => boolean) =>
228
+ cells.filter(test).length;
229
+ const mostly = (matches: number) =>
230
+ matches > 0 && cells.length - matches <= tolerated(cells.length);
231
+
232
+ if (mostly(count(cell => typeof cell === 'boolean'))) return { type: 'boolean' };
233
+ if (
234
+ options.header &&
235
+ PHONE_HEADER.test(normaliseHeader(options.header)) &&
236
+ mostly(
237
+ count(cell => {
238
+ const value = text(cell);
239
+ return PHONE.test(value) && value.replace(/\D/g, '').length >= 7;
240
+ })
241
+ )
242
+ ) {
243
+ return { type: 'phone' };
244
+ }
245
+ // Codes with a leading zero stay text, so "05001" keeps its zero.
246
+ if (count(cell => typeof cell === 'string' && LEADING_ZERO.test(cell.trim()))) {
247
+ return { type: 'text' };
248
+ }
249
+ const numberOf = (cell: ImportCell) =>
250
+ typeof cell === 'number'
251
+ ? cell
252
+ : parseNumber(text(cell), options.decimalMark)?.value;
253
+ const integers = count(cell => {
254
+ const n = numberOf(cell);
255
+ return n !== undefined && Number.isSafeInteger(n);
256
+ });
257
+ const decimals = count(cell => {
258
+ const n = numberOf(cell);
259
+ return n !== undefined && Number.isFinite(n);
260
+ });
261
+ // 0/1 alone is still a number column: a quantity of 1 is common. A
262
+ // column with any fractions is decimal, so none of them is refused.
263
+ if (decimals > integers && mostly(decimals)) return { type: 'decimal' };
264
+ if (mostly(integers)) return { type: 'integer' };
265
+ if (
266
+ mostly(
267
+ count(cell => {
268
+ const word = text(cell).toLowerCase();
269
+ return TRUE_WORDS.has(word) || FALSE_WORDS.has(word);
270
+ })
271
+ )
272
+ ) {
273
+ return { type: 'boolean' };
274
+ }
275
+ if (mostly(count(cell => DATE.test(text(cell))))) return { type: 'date' };
276
+ if (mostly(count(cell => DATETIME.test(text(cell))))) return { type: 'datetime' };
277
+ if (mostly(count(cell => EMAIL.test(text(cell))))) return { type: 'email' };
278
+ if (mostly(count(cell => URL.test(text(cell))))) return { type: 'url' };
279
+ if (
280
+ mostly(
281
+ count(cell => PHONE.test(text(cell)) && text(cell).replace(/\D/g, '').length >= 7)
282
+ )
283
+ ) {
284
+ return { type: 'phone' };
285
+ }
286
+ if (cells.some(cell => text(cell).length > LONG_TEXT_LENGTH)) {
287
+ return { type: 'long_text' };
288
+ }
289
+ const distinct = new Map<string, string>();
290
+ for (const cell of cells) {
291
+ const value = text(cell);
292
+ if (value.length > 100) {
293
+ distinct.clear();
294
+ break;
295
+ }
296
+ const key = value.toLowerCase();
297
+ if (!distinct.has(key)) distinct.set(key, value);
298
+ }
299
+ if (
300
+ distinct.size > 0 &&
301
+ distinct.size <= ENUM_MAX_DISTINCT &&
302
+ distinct.size <= DATA_MAX_ENUM_VALUES &&
303
+ cells.length >= distinct.size * 2
304
+ ) {
305
+ return { type: 'enum', values: [...distinct.values()] };
306
+ }
307
+ return { type: 'text' };
308
+ };
309
+
310
+ // The columns of a new table made from a file: one per header, named from
311
+ // it, typed from its values.
312
+ export const guessColumns = (
313
+ headers: string[],
314
+ rows: ImportRow[],
315
+ options: { decimalMark?: DecimalMark } = {}
316
+ ): DataColumnInput[] => {
317
+ const used = new Set<string>();
318
+ return headers.slice(0, DATA_MAX_COLUMNS).map((header, index) => {
319
+ let name = columnNameFromHeader(header, index);
320
+ for (let n = 2; used.has(name); n++) {
321
+ name = `${columnNameFromHeader(header, index).slice(0, 60)}_${n}`;
322
+ }
323
+ used.add(name);
324
+ const guess = guessColumnType(
325
+ rows.map(row => row[index]),
326
+ { decimalMark: options.decimalMark, header }
327
+ );
328
+ const label = header.trim();
329
+ return {
330
+ name,
331
+ type: guess.type,
332
+ ...(label && label !== name ? { label: label.slice(0, 100) } : {}),
333
+ ...(guess.values ? { values: guess.values } : {})
334
+ };
335
+ });
336
+ };
337
+
338
+ // Which column each header fills: matched by name or label, ignoring case,
339
+ // accents and punctuation. `null` for a header that matches nothing; the
340
+ // owner maps those by hand.
341
+ export const matchHeaders = (
342
+ headers: string[],
343
+ table: Pick<DataTable, 'columns'>
344
+ ): (string | null)[] => {
345
+ const taken = new Set<string>();
346
+ return headers.map(header => {
347
+ const key = normaliseHeader(header);
348
+ if (!key) return null;
349
+ const column = table.columns.find(
350
+ c =>
351
+ !taken.has(c.name) &&
352
+ (normaliseHeader(c.name) === key ||
353
+ (c.label !== null && normaliseHeader(c.label) === key))
354
+ );
355
+ if (!column) return null;
356
+ taken.add(column.name);
357
+ return column.name;
358
+ });
359
+ };
360
+
361
+ // A relation's cells by the referenced table's display value: "Margherita" →
362
+ // 12. Keys ignore case, accents and surrounding spaces: a sheet says
363
+ // "hawaiana" for the "Hawaiána" row.
364
+ export type RelationLookup = Map<string, number>;
365
+
366
+ export const relationKey = (value: ImportCell) =>
367
+ text(value)
368
+ .normalize('NFKD')
369
+ .replace(/[\u0300-\u036f]/g, '')
370
+ .toLowerCase();
371
+
372
+ // A number read with a guess: "28.000" taken as `read` (28000) when it may
373
+ // have meant `alternative` (28). The page words it in the reader's language.
374
+ export interface NumberGuess {
375
+ column: string;
376
+ text: string;
377
+ read: number;
378
+ alternative: number;
379
+ }
380
+
381
+ // `warning` marks a value read with a guess, for the preview to show.
382
+ export type CellResult =
383
+ | { ok: true; value: unknown; warning?: NumberGuess }
384
+ | { ok: false; error: string };
385
+
386
+ export interface CellOptions {
387
+ relation?: RelationLookup;
388
+ timeZone?: string;
389
+ decimalMark?: DecimalMark;
390
+ }
391
+
392
+ // One cell as the column will store it, or why it won't fit. The check is
393
+ // the database's own, so a row that passes here is refused only for what
394
+ // needs the stored rows (a duplicate in a unique column).
395
+ export const importCell = (
396
+ table: Pick<DataTable, 'name'>,
397
+ column: DataColumn,
398
+ cell: ImportCell,
399
+ options: CellOptions = {}
400
+ ): CellResult => {
401
+ if (isBlank(cell)) {
402
+ return column.required
403
+ ? { ok: false, error: `${column.name} is required.` }
404
+ : { ok: true, value: null };
405
+ }
406
+ let value: unknown = typeof cell === 'string' ? cell.trim() : cell;
407
+ let warning: NumberGuess | undefined;
408
+ if (
409
+ (column.type === 'integer' || column.type === 'decimal') &&
410
+ typeof value === 'string'
411
+ ) {
412
+ const parsed = parseNumber(value, options.decimalMark);
413
+ if (parsed) {
414
+ if (parsed.ambiguous) {
415
+ warning = {
416
+ column: column.name,
417
+ text: value,
418
+ read: parsed.value,
419
+ alternative: Number(value.replace(',', '.'))
420
+ };
421
+ }
422
+ value = parsed.value;
423
+ }
424
+ } else if (column.type === 'relation') {
425
+ const asId = typeof value === 'number' ? value : Number(text(cell));
426
+ const found = options.relation?.get(relationKey(cell));
427
+ if (found !== undefined) value = found;
428
+ else if (Number.isSafeInteger(asId) && asId > 0) value = asId;
429
+ else {
430
+ return {
431
+ ok: false,
432
+ error: `${column.name}: "${text(cell)}" matches no row in ${column.table}.`
433
+ };
434
+ }
435
+ } else if (column.type === 'boolean' && typeof value === 'string') {
436
+ const word = value.toLowerCase();
437
+ if (TRUE_WORDS.has(word)) value = true;
438
+ else if (FALSE_WORDS.has(word)) value = false;
439
+ } else if (column.type === 'json' && typeof value === 'string') {
440
+ try {
441
+ value = JSON.parse(value);
442
+ } catch {
443
+ return { ok: false, error: `${column.name} is not valid JSON.` };
444
+ }
445
+ } else if (column.type === 'date' && typeof value === 'string') {
446
+ // A spreadsheet's date with a midnight time.
447
+ value = value.replace(/[T ]00:00(:00(\.0+)?)?Z?$/, '');
448
+ }
449
+ try {
450
+ const stored = toStored(table as DataTable, column, value, options.timeZone);
451
+ // The database refuses these too, but only once a whole batch is sent.
452
+ if (
453
+ column.min !== null &&
454
+ column.min !== undefined &&
455
+ typeof stored === 'number' &&
456
+ stored < column.min
457
+ ) {
458
+ return {
459
+ ok: false,
460
+ error: `${table.name}.${column.name} can't go below ${column.min}.`
461
+ };
462
+ }
463
+ if (column.type === 'boolean') return { ok: true, value: stored === 1 };
464
+ if (column.type === 'json') return { ok: true, value };
465
+ return warning
466
+ ? { ok: true, value: stored, warning }
467
+ : { ok: true, value: stored };
468
+ } catch (error) {
469
+ return {
470
+ ok: false,
471
+ error: error instanceof Error ? error.message : String(error)
472
+ };
473
+ }
474
+ };
475
+
476
+ export interface ImportedRow {
477
+ // The row's place in `rows`.
478
+ index: number;
479
+ // The row's line in the file, 1-based, counting the header.
480
+ line: number;
481
+ values: Record<string, unknown>;
482
+ errors: string[];
483
+ // Values read with a guess; the row is still imported.
484
+ warnings: NumberGuess[];
485
+ }
486
+
487
+ // Every row of the file as the values it will insert, with each problem
488
+ // found. `mapping[i]` is the column header i fills, or null to skip it.
489
+ // `lines[i]` is row i's line in the file, when blank rows were left out
490
+ // before; otherwise rows follow the header one per line.
491
+ export const importRows = (
492
+ table: Pick<DataTable, 'name' | 'columns'>,
493
+ mapping: (string | null)[],
494
+ rows: ImportRow[],
495
+ options: {
496
+ relations?: Record<string, RelationLookup>;
497
+ timeZone?: string;
498
+ decimalMark?: DecimalMark;
499
+ lines?: number[];
500
+ } = {}
501
+ ): ImportedRow[] => {
502
+ const columns = new Map(table.columns.map(c => [c.name, c]));
503
+ const mapped = new Set(mapping.filter((m): m is string => m !== null));
504
+ const missing = table.columns.filter(
505
+ c => c.required && c.default === undefined && !mapped.has(c.name)
506
+ );
507
+ // A unique column's values seen so far, with the line they came from: the
508
+ // database refuses a file that repeats one, and can't say which row did.
509
+ const seen = new Map<string, Map<string, number>>();
510
+ return rows.map((row, index) => {
511
+ const line = options.lines?.[index] ?? index + 2;
512
+ const values: Record<string, unknown> = {};
513
+ const errors = missing.map(c => `${c.name} is required.`);
514
+ const warnings: NumberGuess[] = [];
515
+ mapping.forEach((name, i) => {
516
+ if (name === null) return;
517
+ const column = columns.get(name);
518
+ if (!column) return;
519
+ const result = importCell(table, column, row[i], {
520
+ relation: options.relations?.[name],
521
+ timeZone: options.timeZone,
522
+ decimalMark: options.decimalMark
523
+ });
524
+ if (!result.ok) {
525
+ errors.push(result.error);
526
+ return;
527
+ }
528
+ if (result.value !== null) values[name] = result.value;
529
+ if (result.warning) warnings.push(result.warning);
530
+ if (column.unique && result.value !== null) {
531
+ const key = JSON.stringify(result.value);
532
+ const lines = seen.get(name) ?? new Map<string, number>();
533
+ seen.set(name, lines);
534
+ const first = lines.get(key);
535
+ if (first === undefined) lines.set(key, line);
536
+ else {
537
+ errors.push(
538
+ `${column.name}: "${text(row[i])}" is already on line ${first}, and this column takes each value once.`
539
+ );
540
+ }
541
+ }
542
+ });
543
+ return { index, line, values, errors, warnings };
544
+ });
545
+ };
546
+
547
+ // A CSV line, quoted where it needs to be: for the file of refused rows.
548
+ export const csvLine = (cells: ImportCell[]) =>
549
+ cells
550
+ .map(cell => {
551
+ const value = cell === null || cell === undefined ? '' : String(cell);
552
+ return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
553
+ })
554
+ .join(',');