@adminforth/import-export 1.7.0 → 1.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AdminForthPlugin } from "adminforth";
2
- import type { IAdminForth, IHttpServer, AdminForthResourceColumn, AdminForthResource } from "adminforth";
2
+ import type { IAdminForth, IHttpServer, AdminForthResourceColumn, AdminForthResource, AdminUser, HttpExtra, IAdminForthHttpResponse } from "adminforth";
3
3
  import type { PluginOptions } from './types.js';
4
4
  export default class ImportExport extends AdminForthPlugin {
5
5
  options: PluginOptions;
@@ -15,6 +15,61 @@ export default class ImportExport extends AdminForthPlugin {
15
15
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource): void;
16
16
  instanceUniqueRepresentation(pluginOptions: any): string;
17
17
  setupEndpoints(server: IHttpServer): void;
18
+ /**
19
+ * Export resource records as CSV-ready data.
20
+ * Can be called programmatically, e.g. `this.exportCsv(filters, sort)`.
21
+ */
22
+ exportCsv(filters: any, sort: any, options?: {
23
+ adminUser?: AdminUser;
24
+ headers?: Record<string, string>;
25
+ }): Promise<{
26
+ ok: true;
27
+ data: {
28
+ fields: string[];
29
+ data: unknown[][];
30
+ };
31
+ columnsToForceQuote: boolean[];
32
+ exportedCount: number;
33
+ }>;
34
+ /**
35
+ * Import records from column-oriented data, creating new records and updating
36
+ * existing ones (matched by primary key).
37
+ * Can be called programmatically, e.g. `this.importCsv(data, { adminUser })`.
38
+ */
39
+ importCsv(data: Record<string, unknown[]>, options?: {
40
+ adminUser?: AdminUser;
41
+ headers?: Record<string, string>;
42
+ extra?: HttpExtra;
43
+ response?: IAdminForthHttpResponse;
44
+ }): Promise<{
45
+ ok: boolean;
46
+ importedCount?: number;
47
+ updatedCount?: number;
48
+ errors: string[];
49
+ }>;
50
+ /**
51
+ * Import only records that do not already exist (matched by primary key).
52
+ * Can be called programmatically, e.g. `this.importCsvNewOnly(data, { adminUser })`.
53
+ */
54
+ importCsvNewOnly(data: Record<string, unknown[]>, options?: {
55
+ adminUser?: AdminUser;
56
+ headers?: Record<string, string>;
57
+ extra?: HttpExtra;
58
+ }): Promise<{
59
+ ok: boolean;
60
+ importedCount?: number;
61
+ errors: string[];
62
+ }>;
63
+ /**
64
+ * Check how many of the given records already exist (matched by primary key).
65
+ * Can be called programmatically, e.g. `this.checkRecords(data)`.
66
+ */
67
+ checkRecords(data: Record<string, unknown[]>): Promise<{
68
+ ok: true;
69
+ total: number;
70
+ existingCount: number;
71
+ newCount: number;
72
+ }>;
18
73
  private getColumnNames;
19
74
  private validateColumns;
20
75
  private buildRowsFromData;
package/dist/index.js CHANGED
@@ -120,9 +120,8 @@ export default class ImportExport extends AdminForthPlugin {
120
120
  method: 'POST',
121
121
  path: `/plugin/${this.pluginInstanceId}/export-csv`,
122
122
  request_schema: exportCsvBodySchema,
123
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, response }) {
124
- const payload = body;
125
- const { filters, sort, selectedIds } = payload;
123
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers }) {
124
+ const { filters, sort, selectedIds } = body;
126
125
  if (!filters || !sort) {
127
126
  return { ok: false, error: 'Missing filters or sort in request body' };
128
127
  }
@@ -149,39 +148,7 @@ export default class ImportExport extends AdminForthPlugin {
149
148
  value: selectedIds,
150
149
  }];
151
150
  }
152
- const data = yield this.adminforth.connectors[this.resourceConfig.dataSource].getData({
153
- resource: this.resourceConfig,
154
- limit: 1e6,
155
- offset: 0,
156
- filters: this.adminforth.connectors[this.resourceConfig.dataSource].validateAndNormalizeInputFilters(effectiveFilters),
157
- sort,
158
- getTotals: true,
159
- });
160
- // prepare data for PapaParse unparse
161
- const columns = this.resourceConfig.columns.filter((col) => !col.virtual && !col.backendOnly);
162
- const columnsToForceQuote = columns.map(col => {
163
- return col.type !== AdminForthDataTypes.FLOAT
164
- && col.type !== AdminForthDataTypes.INTEGER
165
- && col.type !== AdminForthDataTypes.BOOLEAN;
166
- });
167
- const fields = columns.map((col) => col.name);
168
- const rows = data.data.map((row) => {
169
- return columns.map((col) => {
170
- var _a;
171
- const value = row[col.name];
172
- if (col.type === AdminForthDataTypes.JSON || ((_a = col.isArray) === null || _a === void 0 ? void 0 : _a.enabled)) {
173
- return value == null ? value : JSON.stringify(value);
174
- }
175
- return value;
176
- });
177
- });
178
- this.tryToAuditLogAction('export', `Export CSV with filters: ${JSON.stringify(effectiveFilters)} and sort: ${JSON.stringify(sort)}. Total records: ${rows.length}`, adminUser, headers);
179
- return {
180
- data: { fields, data: rows },
181
- columnsToForceQuote,
182
- exportedCount: data.total,
183
- ok: true
184
- };
151
+ return this.exportCsv(effectiveFilters, sort, { adminUser, headers });
185
152
  })
186
153
  });
187
154
  server.endpoint({
@@ -189,8 +156,7 @@ export default class ImportExport extends AdminForthPlugin {
189
156
  path: `/plugin/${this.pluginInstanceId}/import-csv`,
190
157
  request_schema: importCsvBodySchema,
191
158
  handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, query, headers, cookies, requestUrl, response }) {
192
- const payload = body;
193
- const { data } = payload;
159
+ const { data } = body;
194
160
  if (!data || typeof data !== 'object') {
195
161
  return { ok: false, error: 'Invalid data format. Expected an object with column names as keys and arrays of values as values.' };
196
162
  }
@@ -201,61 +167,12 @@ export default class ImportExport extends AdminForthPlugin {
201
167
  if (!createEditAccess.ok) {
202
168
  return { ok: false, error: createEditAccess.error };
203
169
  }
204
- const columns = this.getColumnNames(data);
205
- const { errors, resourceColumns } = this.validateColumns(columns);
206
- const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
207
- if (errors.length > 0) {
208
- return { ok: false, errors };
209
- }
210
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
211
- const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
212
- console.log('Prepared rows for import:', rows);
213
- this.tryToAuditLogAction('import', `Import CSV with ${Object.keys(data).length} columns`, adminUser, headers);
214
- let importedCount = 0;
215
- let updatedCount = 0;
216
- const limit = pLimit(100);
217
- yield Promise.all(rows.map((row) => limit(() => __awaiter(this, void 0, void 0, function* () {
218
- try {
219
- const rowErrors = yield this.isRowValid(row);
220
- if (rowErrors.length > 0) {
221
- errors.push(...rowErrors);
222
- return;
223
- }
224
- const recordId = primaryKeyColumn ? row[primaryKeyColumn.name] : undefined;
225
- if (primaryKeyColumn && recordId) {
226
- const existingRecord = yield this.adminforth.resource(this.resourceConfig.resourceId)
227
- .list([Filters.EQ(primaryKeyColumn.name, recordId)]);
228
- if (existingRecord.length > 0) {
229
- const connector = this.adminforth.connectors[resource.dataSource];
230
- const oldRecord = yield connector.getRecordByPrimaryKey(resource, recordId);
231
- if (!oldRecord) {
232
- const primaryKeyColumn = resource.columns.find((col) => col.primaryKey);
233
- return { error: `Record with ${primaryKeyColumn.name} ${recordId} not found` };
234
- }
235
- const { error } = yield this.adminforth.updateResourceRecord({
236
- resource, updates: row, adminUser, oldRecord, recordId, response,
237
- extra: { body, query, headers, cookies, requestUrl, response }
238
- });
239
- if (error) {
240
- return { error };
241
- }
242
- updatedCount++;
243
- return;
244
- }
245
- }
246
- yield this.adminforth.createResourceRecord({
247
- resource: resource,
248
- record: row,
249
- adminUser: adminUser,
250
- extra: { body, query, headers, cookies, requestUrl, response }
251
- });
252
- importedCount++;
253
- }
254
- catch (e) {
255
- errors.push(e.message);
256
- }
257
- }))));
258
- return { ok: true, importedCount, updatedCount, errors };
170
+ return this.importCsv(data, {
171
+ adminUser,
172
+ headers,
173
+ response,
174
+ extra: { body, query, headers, cookies, requestUrl, response },
175
+ });
259
176
  })
260
177
  });
261
178
  server.endpoint({
@@ -263,8 +180,7 @@ export default class ImportExport extends AdminForthPlugin {
263
180
  path: `/plugin/${this.pluginInstanceId}/import-csv-new-only`,
264
181
  request_schema: importCsvBodySchema,
265
182
  handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, query, headers, cookies, requestUrl, response }) {
266
- const payload = body;
267
- const { data } = payload;
183
+ const { data } = body;
268
184
  if (!data || typeof data !== 'object') {
269
185
  return { ok: false, error: 'Invalid data format. Expected an object with column names as keys and arrays of values as values.' };
270
186
  }
@@ -272,52 +188,19 @@ export default class ImportExport extends AdminForthPlugin {
272
188
  if (!access.ok) {
273
189
  return { ok: false, error: access.error };
274
190
  }
275
- const columns = this.getColumnNames(data);
276
- const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
277
- const { errors, resourceColumns } = this.validateColumns(columns);
278
- if (errors.length > 0) {
279
- return { ok: false, errors };
280
- }
281
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
282
- const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
283
- this.tryToAuditLogAction('import', `Import CSV (new only) with ${Object.keys(data).length} columns`, adminUser, headers);
284
- let importedCount = 0;
285
- const limit = pLimit(100);
286
- yield Promise.all(rows.map((row) => limit(() => __awaiter(this, void 0, void 0, function* () {
287
- try {
288
- const rowErrors = yield this.isRowValid(row);
289
- if (rowErrors.length > 0) {
290
- errors.push(...rowErrors);
291
- return;
292
- }
293
- if (primaryKeyColumn && row[primaryKeyColumn.name]) {
294
- const existingRecord = yield this.adminforth.resource(this.resourceConfig.resourceId)
295
- .list([Filters.EQ(primaryKeyColumn.name, row[primaryKeyColumn.name])]);
296
- if (existingRecord.length > 0) {
297
- return;
298
- }
299
- }
300
- yield this.adminforth.createResourceRecord({
301
- resource: resource,
302
- record: row,
303
- adminUser: adminUser,
304
- extra: { body, query, headers, cookies, requestUrl, response }
305
- });
306
- importedCount++;
307
- }
308
- catch (e) {
309
- errors.push(e.message);
310
- }
311
- }))));
312
- return { ok: true, importedCount, errors };
191
+ return this.importCsvNewOnly(data, {
192
+ adminUser,
193
+ headers,
194
+ extra: { body, query, headers, cookies, requestUrl, response },
195
+ });
313
196
  })
314
197
  });
315
198
  server.endpoint({
316
199
  method: 'POST',
317
200
  path: `/plugin/${this.pluginInstanceId}/check-records`,
318
201
  request_schema: importCsvBodySchema,
319
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, response }) {
320
- const payload = body;
202
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser }) {
203
+ const { data } = body;
321
204
  const access = yield this.ensureAnyAllowed(adminUser, [
322
205
  { source: ActionCheckSource.ListRequest, action: AllowedActionsEnum.list },
323
206
  { source: ActionCheckSource.ShowRequest, action: AllowedActionsEnum.show },
@@ -325,29 +208,198 @@ export default class ImportExport extends AdminForthPlugin {
325
208
  if (!access.ok) {
326
209
  return { ok: false, error: access.error };
327
210
  }
328
- const { data } = payload;
329
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
330
- const columns = this.getColumnNames(data);
331
- const rows = this.buildRowsFromData(data, columns, undefined, { coerceTypes: false });
332
- const primaryKeys = rows
333
- .map(row => primaryKeyColumn ? row[primaryKeyColumn.name] : undefined)
334
- .filter(key => key !== undefined && key !== null && key !== '');
335
- const existingRecords = yield this.adminforth
336
- .resource(this.resourceConfig.resourceId)
337
- .list([{
338
- field: primaryKeyColumn.name,
339
- operator: AdminForthFilterOperators.IN,
340
- value: primaryKeys,
341
- }]);
342
- return {
343
- ok: true,
344
- total: rows.length,
345
- existingCount: existingRecords.length,
346
- newCount: rows.length - existingRecords.length,
347
- };
211
+ return this.checkRecords(data);
348
212
  })
349
213
  });
350
214
  }
215
+ /**
216
+ * Export resource records as CSV-ready data.
217
+ * Can be called programmatically, e.g. `this.exportCsv(filters, sort)`.
218
+ */
219
+ exportCsv(filters_1, sort_1) {
220
+ return __awaiter(this, arguments, void 0, function* (filters, sort, options = {}) {
221
+ const { adminUser, headers } = options;
222
+ const connector = this.adminforth.connectors[this.resourceConfig.dataSource];
223
+ const data = yield connector.getData({
224
+ resource: this.resourceConfig,
225
+ limit: 1e6,
226
+ offset: 0,
227
+ filters: connector.validateAndNormalizeInputFilters(filters),
228
+ sort,
229
+ getTotals: true,
230
+ });
231
+ // prepare data for PapaParse unparse
232
+ const columns = this.resourceConfig.columns.filter((col) => !col.virtual && !col.backendOnly);
233
+ const columnsToForceQuote = columns.map(col => {
234
+ return col.type !== AdminForthDataTypes.FLOAT
235
+ && col.type !== AdminForthDataTypes.INTEGER
236
+ && col.type !== AdminForthDataTypes.BOOLEAN;
237
+ });
238
+ const fields = columns.map((col) => col.name);
239
+ const rows = data.data.map((row) => {
240
+ return columns.map((col) => {
241
+ var _a;
242
+ const value = row[col.name];
243
+ if (col.type === AdminForthDataTypes.JSON || ((_a = col.isArray) === null || _a === void 0 ? void 0 : _a.enabled)) {
244
+ return value == null ? value : JSON.stringify(value);
245
+ }
246
+ return value;
247
+ });
248
+ });
249
+ if (adminUser) {
250
+ this.tryToAuditLogAction('export', `Export CSV with filters: ${JSON.stringify(filters)} and sort: ${JSON.stringify(sort)}. Total records: ${rows.length}`, adminUser, headers);
251
+ }
252
+ return {
253
+ ok: true,
254
+ data: { fields, data: rows },
255
+ columnsToForceQuote,
256
+ exportedCount: data.total,
257
+ };
258
+ });
259
+ }
260
+ /**
261
+ * Import records from column-oriented data, creating new records and updating
262
+ * existing ones (matched by primary key).
263
+ * Can be called programmatically, e.g. `this.importCsv(data, { adminUser })`.
264
+ */
265
+ importCsv(data_1) {
266
+ return __awaiter(this, arguments, void 0, function* (data, options = {}) {
267
+ const { adminUser, headers, extra, response } = options;
268
+ const columns = this.getColumnNames(data);
269
+ const { errors, resourceColumns } = this.validateColumns(columns);
270
+ const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
271
+ if (errors.length > 0) {
272
+ return { ok: false, errors };
273
+ }
274
+ const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
275
+ const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
276
+ if (adminUser) {
277
+ this.tryToAuditLogAction('import', `Import CSV with ${Object.keys(data).length} columns`, adminUser, headers);
278
+ }
279
+ let importedCount = 0;
280
+ let updatedCount = 0;
281
+ const limit = pLimit(100);
282
+ yield Promise.all(rows.map((row) => limit(() => __awaiter(this, void 0, void 0, function* () {
283
+ try {
284
+ const rowErrors = yield this.isRowValid(row);
285
+ if (rowErrors.length > 0) {
286
+ errors.push(...rowErrors);
287
+ return;
288
+ }
289
+ const recordId = primaryKeyColumn ? row[primaryKeyColumn.name] : undefined;
290
+ if (primaryKeyColumn && recordId) {
291
+ const existingRecord = yield this.adminforth.resource(this.resourceConfig.resourceId)
292
+ .list([Filters.EQ(primaryKeyColumn.name, recordId)]);
293
+ if (existingRecord.length > 0) {
294
+ const connector = this.adminforth.connectors[resource.dataSource];
295
+ const oldRecord = yield connector.getRecordByPrimaryKey(resource, recordId);
296
+ if (!oldRecord) {
297
+ errors.push(`Record with ${primaryKeyColumn.name} ${recordId} not found`);
298
+ return;
299
+ }
300
+ const { error } = yield this.adminforth.updateResourceRecord({
301
+ resource, updates: row, adminUser, oldRecord, recordId, response,
302
+ extra,
303
+ });
304
+ if (error) {
305
+ errors.push(error);
306
+ return;
307
+ }
308
+ updatedCount++;
309
+ return;
310
+ }
311
+ }
312
+ yield this.adminforth.createResourceRecord({
313
+ resource: resource,
314
+ record: row,
315
+ adminUser: adminUser,
316
+ extra,
317
+ });
318
+ importedCount++;
319
+ }
320
+ catch (e) {
321
+ errors.push(e.message);
322
+ }
323
+ }))));
324
+ return { ok: true, importedCount, updatedCount, errors };
325
+ });
326
+ }
327
+ /**
328
+ * Import only records that do not already exist (matched by primary key).
329
+ * Can be called programmatically, e.g. `this.importCsvNewOnly(data, { adminUser })`.
330
+ */
331
+ importCsvNewOnly(data_1) {
332
+ return __awaiter(this, arguments, void 0, function* (data, options = {}) {
333
+ const { adminUser, headers, extra } = options;
334
+ const columns = this.getColumnNames(data);
335
+ const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
336
+ const { errors, resourceColumns } = this.validateColumns(columns);
337
+ if (errors.length > 0) {
338
+ return { ok: false, errors };
339
+ }
340
+ const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
341
+ const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
342
+ if (adminUser) {
343
+ this.tryToAuditLogAction('import', `Import CSV (new only) with ${Object.keys(data).length} columns`, adminUser, headers);
344
+ }
345
+ let importedCount = 0;
346
+ const limit = pLimit(100);
347
+ yield Promise.all(rows.map((row) => limit(() => __awaiter(this, void 0, void 0, function* () {
348
+ try {
349
+ const rowErrors = yield this.isRowValid(row);
350
+ if (rowErrors.length > 0) {
351
+ errors.push(...rowErrors);
352
+ return;
353
+ }
354
+ if (primaryKeyColumn && row[primaryKeyColumn.name]) {
355
+ const existingRecord = yield this.adminforth.resource(this.resourceConfig.resourceId)
356
+ .list([Filters.EQ(primaryKeyColumn.name, row[primaryKeyColumn.name])]);
357
+ if (existingRecord.length > 0) {
358
+ return;
359
+ }
360
+ }
361
+ yield this.adminforth.createResourceRecord({
362
+ resource: resource,
363
+ record: row,
364
+ adminUser: adminUser,
365
+ extra,
366
+ });
367
+ importedCount++;
368
+ }
369
+ catch (e) {
370
+ errors.push(e.message);
371
+ }
372
+ }))));
373
+ return { ok: true, importedCount, errors };
374
+ });
375
+ }
376
+ /**
377
+ * Check how many of the given records already exist (matched by primary key).
378
+ * Can be called programmatically, e.g. `this.checkRecords(data)`.
379
+ */
380
+ checkRecords(data) {
381
+ return __awaiter(this, void 0, void 0, function* () {
382
+ const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
383
+ const columns = this.getColumnNames(data);
384
+ const rows = this.buildRowsFromData(data, columns, undefined, { coerceTypes: false });
385
+ const primaryKeys = rows
386
+ .map(row => primaryKeyColumn ? row[primaryKeyColumn.name] : undefined)
387
+ .filter(key => key !== undefined && key !== null && key !== '');
388
+ const existingRecords = yield this.adminforth
389
+ .resource(this.resourceConfig.resourceId)
390
+ .list([{
391
+ field: primaryKeyColumn.name,
392
+ operator: AdminForthFilterOperators.IN,
393
+ value: primaryKeys,
394
+ }]);
395
+ return {
396
+ ok: true,
397
+ total: rows.length,
398
+ existingCount: existingRecords.length,
399
+ newCount: rows.length - existingRecords.length,
400
+ };
401
+ });
402
+ }
351
403
  getColumnNames(data) {
352
404
  return Object.keys(data !== null && data !== void 0 ? data : {});
353
405
  }
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AdminForthPlugin, suggestIfTypo, AdminForthFilterOperators, Filters, AdminForthDataTypes, rejectApiRawFilters, interpretResource, ActionCheckSource, AllowedActionsEnum } from "adminforth";
2
- import type { IAdminForth, IHttpServer, AdminForthResourceColumn, AdminForthComponentDeclaration, AdminForthResource, AdminUser } from "adminforth";
2
+ import type { IAdminForth, IHttpServer, AdminForthResourceColumn, AdminForthComponentDeclaration, AdminForthResource, AdminUser, HttpExtra, IAdminForthHttpResponse } from "adminforth";
3
3
  import type { PluginOptions } from './types.js';
4
4
  import pLimit from 'p-limit';
5
5
  import { z } from "zod";
@@ -21,7 +21,7 @@ export default class ImportExport extends AdminForthPlugin {
21
21
  adminforth: IAdminForth;
22
22
  auditLogPlugin: Record<string, any> | undefined;
23
23
 
24
-
24
+
25
25
  constructor(options: PluginOptions) {
26
26
  super(options, import.meta.url);
27
27
  this.options = options;
@@ -136,9 +136,8 @@ export default class ImportExport extends AdminForthPlugin {
136
136
  method: 'POST',
137
137
  path: `/plugin/${this.pluginInstanceId}/export-csv`,
138
138
  request_schema: exportCsvBodySchema,
139
- handler: async ({ body, adminUser, headers, response }) => {
140
- const payload = body as z.infer<typeof exportCsvBodySchema>;
141
- const { filters, sort, selectedIds } = payload;
139
+ handler: async ({ body, adminUser, headers }) => {
140
+ const { filters, sort, selectedIds } = body as z.infer<typeof exportCsvBodySchema>;
142
141
  if (!filters || !sort) {
143
142
  return { ok: false, error: 'Missing filters or sort in request body' };
144
143
  }
@@ -171,44 +170,7 @@ export default class ImportExport extends AdminForthPlugin {
171
170
  }];
172
171
  }
173
172
 
174
- const data = await this.adminforth.connectors[this.resourceConfig.dataSource].getData({
175
- resource: this.resourceConfig,
176
- limit: 1e6,
177
- offset: 0,
178
- filters: this.adminforth.connectors[this.resourceConfig.dataSource].validateAndNormalizeInputFilters(effectiveFilters),
179
- sort,
180
- getTotals: true,
181
- });
182
-
183
- // prepare data for PapaParse unparse
184
- const columns = this.resourceConfig.columns.filter((col) => !col.virtual && !col.backendOnly);
185
-
186
- const columnsToForceQuote = columns.map(col => {
187
- return col.type !== AdminForthDataTypes.FLOAT
188
- && col.type !== AdminForthDataTypes.INTEGER
189
- && col.type !== AdminForthDataTypes.BOOLEAN;
190
- })
191
-
192
- const fields = columns.map((col) => col.name);
193
-
194
- const rows = data.data.map((row) => {
195
- return columns.map((col) => {
196
- const value = row[col.name];
197
- if (col.type === AdminForthDataTypes.JSON || col.isArray?.enabled) {
198
- return value == null ? value : JSON.stringify(value);
199
- }
200
- return value;
201
- });
202
- });
203
-
204
- this.tryToAuditLogAction('export', `Export CSV with filters: ${JSON.stringify(effectiveFilters)} and sort: ${JSON.stringify(sort)}. Total records: ${rows.length}`, adminUser, headers);
205
-
206
- return {
207
- data: { fields, data: rows },
208
- columnsToForceQuote,
209
- exportedCount: data.total,
210
- ok: true
211
- };
173
+ return this.exportCsv(effectiveFilters, sort, { adminUser, headers });
212
174
  }
213
175
  });
214
176
 
@@ -217,8 +179,7 @@ export default class ImportExport extends AdminForthPlugin {
217
179
  path: `/plugin/${this.pluginInstanceId}/import-csv`,
218
180
  request_schema: importCsvBodySchema,
219
181
  handler: async ({ body, adminUser, query, headers, cookies, requestUrl, response }) => {
220
- const payload = body as z.infer<typeof importCsvBodySchema>;
221
- const { data } = payload;
182
+ const { data } = body as z.infer<typeof importCsvBodySchema>;
222
183
  if (!data || typeof data !== 'object') {
223
184
  return { ok: false, error: 'Invalid data format. Expected an object with column names as keys and arrays of values as values.' };
224
185
  }
@@ -233,66 +194,12 @@ export default class ImportExport extends AdminForthPlugin {
233
194
  if (!createEditAccess.ok) {
234
195
  return { ok: false, error: createEditAccess.error };
235
196
  }
236
- const columns = this.getColumnNames(data);
237
- const { errors, resourceColumns } = this.validateColumns(columns);
238
- const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
239
-
240
- if (errors.length > 0) {
241
- return { ok: false, errors };
242
- }
243
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
244
- const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
245
-
246
- console.log('Prepared rows for import:', rows);
247
- this.tryToAuditLogAction('import', `Import CSV with ${Object.keys(data).length} columns`, adminUser, headers);
248
-
249
- let importedCount = 0;
250
- let updatedCount = 0;
251
- const limit = pLimit(100);
252
-
253
- await Promise.all(rows.map((row) => limit(async () => {
254
- try {
255
- const rowErrors = await this.isRowValid(row);
256
- if (rowErrors.length > 0) {
257
- errors.push(...rowErrors);
258
- return;
259
- }
260
- const recordId = primaryKeyColumn ? row[primaryKeyColumn.name] as string : undefined;
261
- if (primaryKeyColumn && recordId) {
262
- const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
263
- .list([Filters.EQ(primaryKeyColumn.name, recordId)]);
264
-
265
- if (existingRecord.length > 0) {
266
- const connector = this.adminforth.connectors[resource.dataSource];
267
- const oldRecord = await connector.getRecordByPrimaryKey(resource, recordId)
268
- if (!oldRecord) {
269
- const primaryKeyColumn = resource.columns.find((col) => col.primaryKey);
270
- return { error: `Record with ${primaryKeyColumn.name} ${recordId} not found` };
271
- }
272
- const { error } = await this.adminforth.updateResourceRecord({
273
- resource, updates: row, adminUser, oldRecord, recordId, response,
274
- extra: { body, query, headers, cookies, requestUrl, response }
275
- });
276
- if (error) {
277
- return { error };
278
- }
279
- updatedCount++;
280
- return;
281
- }
282
- }
283
- await this.adminforth.createResourceRecord({
284
- resource: resource,
285
- record: row,
286
- adminUser: adminUser,
287
- extra: { body, query, headers, cookies, requestUrl, response }
288
- });
289
- importedCount++;
290
- } catch (e) {
291
- errors.push(e.message);
292
- }
293
- })));
294
-
295
- return { ok: true, importedCount, updatedCount, errors };
197
+ return this.importCsv(data, {
198
+ adminUser,
199
+ headers,
200
+ response,
201
+ extra: { body, query, headers, cookies, requestUrl, response },
202
+ });
296
203
  }
297
204
  });
298
205
 
@@ -301,8 +208,7 @@ export default class ImportExport extends AdminForthPlugin {
301
208
  path: `/plugin/${this.pluginInstanceId}/import-csv-new-only`,
302
209
  request_schema: importCsvBodySchema,
303
210
  handler: async ({ body, adminUser, query, headers, cookies, requestUrl, response }) => {
304
- const payload = body as z.infer<typeof importCsvBodySchema>;
305
- const { data } = payload;
211
+ const { data } = body as z.infer<typeof importCsvBodySchema>;
306
212
  if (!data || typeof data !== 'object') {
307
213
  return { ok: false, error: 'Invalid data format. Expected an object with column names as keys and arrays of values as values.' };
308
214
  }
@@ -314,48 +220,11 @@ export default class ImportExport extends AdminForthPlugin {
314
220
  if (!access.ok) {
315
221
  return { ok: false, error: access.error };
316
222
  }
317
- const columns = this.getColumnNames(data);
318
- const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
319
- const { errors, resourceColumns } = this.validateColumns(columns);
320
- if (errors.length > 0) {
321
- return { ok: false, errors };
322
- }
323
-
324
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
325
- const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
326
- this.tryToAuditLogAction('import', `Import CSV (new only) with ${Object.keys(data).length} columns`, adminUser, headers);
327
-
328
- let importedCount = 0;
329
- const limit = pLimit(100);
330
-
331
- await Promise.all(rows.map((row) => limit(async () => {
332
- try {
333
- const rowErrors = await this.isRowValid(row);
334
- if (rowErrors.length > 0) {
335
- errors.push(...rowErrors);
336
- return;
337
- }
338
- if (primaryKeyColumn && row[primaryKeyColumn.name]) {
339
- const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
340
- .list([Filters.EQ(primaryKeyColumn.name, row[primaryKeyColumn.name])]);
341
-
342
- if (existingRecord.length > 0) {
343
- return;
344
- }
345
- }
346
- await this.adminforth.createResourceRecord({
347
- resource: resource,
348
- record: row,
349
- adminUser: adminUser,
350
- extra: { body, query, headers, cookies, requestUrl, response }
351
- });
352
- importedCount++;
353
- } catch (e) {
354
- errors.push(e.message);
355
- }
356
- })));
357
-
358
- return { ok: true, importedCount, errors };
223
+ return this.importCsvNewOnly(data, {
224
+ adminUser,
225
+ headers,
226
+ extra: { body, query, headers, cookies, requestUrl, response },
227
+ });
359
228
  }
360
229
  });
361
230
 
@@ -363,8 +232,8 @@ export default class ImportExport extends AdminForthPlugin {
363
232
  method: 'POST',
364
233
  path: `/plugin/${this.pluginInstanceId}/check-records`,
365
234
  request_schema: importCsvBodySchema,
366
- handler: async ({ body, adminUser, response }) => {
367
- const payload = body as z.infer<typeof importCsvBodySchema>;
235
+ handler: async ({ body, adminUser }) => {
236
+ const { data } = body as z.infer<typeof importCsvBodySchema>;
368
237
  const access = await this.ensureAnyAllowed(
369
238
  adminUser,
370
239
  [
@@ -376,32 +245,240 @@ export default class ImportExport extends AdminForthPlugin {
376
245
  if (!access.ok) {
377
246
  return { ok: false, error: access.error };
378
247
  }
379
- const { data } = payload;
380
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
381
- const columns = this.getColumnNames(data);
382
- const rows = this.buildRowsFromData(data, columns, undefined, { coerceTypes: false });
383
-
384
- const primaryKeys = rows
385
- .map(row => primaryKeyColumn ? row[primaryKeyColumn.name] : undefined)
386
- .filter(key => key !== undefined && key !== null && key !== '');
387
-
388
- const existingRecords = await this.adminforth
389
- .resource(this.resourceConfig.resourceId)
390
- .list([{
391
- field: primaryKeyColumn.name,
392
- operator: AdminForthFilterOperators.IN,
393
- value: primaryKeys,
394
- }]);
248
+ return this.checkRecords(data);
249
+ }
250
+ });
251
+ }
395
252
 
396
- return {
397
- ok: true,
398
- total: rows.length,
399
- existingCount: existingRecords.length,
400
- newCount: rows.length - existingRecords.length,
401
- };
253
+ /**
254
+ * Export resource records as CSV-ready data.
255
+ * Can be called programmatically, e.g. `this.exportCsv(filters, sort)`.
256
+ */
257
+ public async exportCsv(
258
+ filters: any,
259
+ sort: any,
260
+ options: { adminUser?: AdminUser; headers?: Record<string, string> } = {}
261
+ ): Promise<{
262
+ ok: true;
263
+ data: { fields: string[]; data: unknown[][] };
264
+ columnsToForceQuote: boolean[];
265
+ exportedCount: number;
266
+ }> {
267
+ const { adminUser, headers } = options;
268
+ const connector = this.adminforth.connectors[this.resourceConfig.dataSource];
269
+ const data = await connector.getData({
270
+ resource: this.resourceConfig,
271
+ limit: 1e6,
272
+ offset: 0,
273
+ filters: connector.validateAndNormalizeInputFilters(filters),
274
+ sort,
275
+ getTotals: true,
276
+ });
402
277
 
403
- }
278
+ // prepare data for PapaParse unparse
279
+ const columns = this.resourceConfig.columns.filter((col) => !col.virtual && !col.backendOnly);
280
+
281
+ const columnsToForceQuote = columns.map(col => {
282
+ return col.type !== AdminForthDataTypes.FLOAT
283
+ && col.type !== AdminForthDataTypes.INTEGER
284
+ && col.type !== AdminForthDataTypes.BOOLEAN;
285
+ });
286
+
287
+ const fields = columns.map((col) => col.name);
288
+
289
+ const rows = data.data.map((row) => {
290
+ return columns.map((col) => {
291
+ const value = row[col.name];
292
+ if (col.type === AdminForthDataTypes.JSON || col.isArray?.enabled) {
293
+ return value == null ? value : JSON.stringify(value);
294
+ }
295
+ return value;
296
+ });
404
297
  });
298
+
299
+ if (adminUser) {
300
+ this.tryToAuditLogAction('export', `Export CSV with filters: ${JSON.stringify(filters)} and sort: ${JSON.stringify(sort)}. Total records: ${rows.length}`, adminUser, headers);
301
+ }
302
+
303
+ return {
304
+ ok: true,
305
+ data: { fields, data: rows },
306
+ columnsToForceQuote,
307
+ exportedCount: data.total,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Import records from column-oriented data, creating new records and updating
313
+ * existing ones (matched by primary key).
314
+ * Can be called programmatically, e.g. `this.importCsv(data, { adminUser })`.
315
+ */
316
+ public async importCsv(
317
+ data: Record<string, unknown[]>,
318
+ options: {
319
+ adminUser?: AdminUser;
320
+ headers?: Record<string, string>;
321
+ extra?: HttpExtra;
322
+ response?: IAdminForthHttpResponse;
323
+ } = {}
324
+ ): Promise<{ ok: boolean; importedCount?: number; updatedCount?: number; errors: string[] }> {
325
+ const { adminUser, headers, extra, response } = options;
326
+ const columns = this.getColumnNames(data);
327
+ const { errors, resourceColumns } = this.validateColumns(columns);
328
+ const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
329
+
330
+ if (errors.length > 0) {
331
+ return { ok: false, errors };
332
+ }
333
+ const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
334
+ const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
335
+
336
+ if (adminUser) {
337
+ this.tryToAuditLogAction('import', `Import CSV with ${Object.keys(data).length} columns`, adminUser, headers);
338
+ }
339
+
340
+ let importedCount = 0;
341
+ let updatedCount = 0;
342
+ const limit = pLimit(100);
343
+
344
+ await Promise.all(rows.map((row) => limit(async () => {
345
+ try {
346
+ const rowErrors = await this.isRowValid(row);
347
+ if (rowErrors.length > 0) {
348
+ errors.push(...rowErrors);
349
+ return;
350
+ }
351
+ const recordId = primaryKeyColumn ? row[primaryKeyColumn.name] as string : undefined;
352
+ if (primaryKeyColumn && recordId) {
353
+ const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
354
+ .list([Filters.EQ(primaryKeyColumn.name, recordId)]);
355
+
356
+ if (existingRecord.length > 0) {
357
+ const connector = this.adminforth.connectors[resource.dataSource];
358
+ const oldRecord = await connector.getRecordByPrimaryKey(resource, recordId);
359
+ if (!oldRecord) {
360
+ errors.push(`Record with ${primaryKeyColumn.name} ${recordId} not found`);
361
+ return;
362
+ }
363
+ const { error } = await this.adminforth.updateResourceRecord({
364
+ resource, updates: row, adminUser, oldRecord, recordId, response,
365
+ extra,
366
+ });
367
+ if (error) {
368
+ errors.push(error);
369
+ return;
370
+ }
371
+ updatedCount++;
372
+ return;
373
+ }
374
+ }
375
+ await this.adminforth.createResourceRecord({
376
+ resource: resource,
377
+ record: row,
378
+ adminUser: adminUser,
379
+ extra,
380
+ });
381
+ importedCount++;
382
+ } catch (e) {
383
+ errors.push(e.message);
384
+ }
385
+ })));
386
+
387
+ return { ok: true, importedCount, updatedCount, errors };
388
+ }
389
+
390
+ /**
391
+ * Import only records that do not already exist (matched by primary key).
392
+ * Can be called programmatically, e.g. `this.importCsvNewOnly(data, { adminUser })`.
393
+ */
394
+ public async importCsvNewOnly(
395
+ data: Record<string, unknown[]>,
396
+ options: {
397
+ adminUser?: AdminUser;
398
+ headers?: Record<string, string>;
399
+ extra?: HttpExtra;
400
+ } = {}
401
+ ): Promise<{ ok: boolean; importedCount?: number; errors: string[] }> {
402
+ const { adminUser, headers, extra } = options;
403
+ const columns = this.getColumnNames(data);
404
+ const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
405
+ const { errors, resourceColumns } = this.validateColumns(columns);
406
+ if (errors.length > 0) {
407
+ return { ok: false, errors };
408
+ }
409
+
410
+ const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
411
+ const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
412
+
413
+ if (adminUser) {
414
+ this.tryToAuditLogAction('import', `Import CSV (new only) with ${Object.keys(data).length} columns`, adminUser, headers);
415
+ }
416
+
417
+ let importedCount = 0;
418
+ const limit = pLimit(100);
419
+
420
+ await Promise.all(rows.map((row) => limit(async () => {
421
+ try {
422
+ const rowErrors = await this.isRowValid(row);
423
+ if (rowErrors.length > 0) {
424
+ errors.push(...rowErrors);
425
+ return;
426
+ }
427
+ if (primaryKeyColumn && row[primaryKeyColumn.name]) {
428
+ const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
429
+ .list([Filters.EQ(primaryKeyColumn.name, row[primaryKeyColumn.name])]);
430
+
431
+ if (existingRecord.length > 0) {
432
+ return;
433
+ }
434
+ }
435
+ await this.adminforth.createResourceRecord({
436
+ resource: resource,
437
+ record: row,
438
+ adminUser: adminUser,
439
+ extra,
440
+ });
441
+ importedCount++;
442
+ } catch (e) {
443
+ errors.push(e.message);
444
+ }
445
+ })));
446
+
447
+ return { ok: true, importedCount, errors };
448
+ }
449
+
450
+ /**
451
+ * Check how many of the given records already exist (matched by primary key).
452
+ * Can be called programmatically, e.g. `this.checkRecords(data)`.
453
+ */
454
+ public async checkRecords(data: Record<string, unknown[]>): Promise<{
455
+ ok: true;
456
+ total: number;
457
+ existingCount: number;
458
+ newCount: number;
459
+ }> {
460
+ const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
461
+ const columns = this.getColumnNames(data);
462
+ const rows = this.buildRowsFromData(data, columns, undefined, { coerceTypes: false });
463
+
464
+ const primaryKeys = rows
465
+ .map(row => primaryKeyColumn ? row[primaryKeyColumn.name] : undefined)
466
+ .filter(key => key !== undefined && key !== null && key !== '');
467
+
468
+ const existingRecords = await this.adminforth
469
+ .resource(this.resourceConfig.resourceId)
470
+ .list([{
471
+ field: primaryKeyColumn.name,
472
+ operator: AdminForthFilterOperators.IN,
473
+ value: primaryKeys,
474
+ }]);
475
+
476
+ return {
477
+ ok: true,
478
+ total: rows.length,
479
+ existingCount: existingRecords.length,
480
+ newCount: rows.length - existingRecords.length,
481
+ };
405
482
  }
406
483
 
407
484
  private getColumnNames(data: Record<string, unknown[]>): string[] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/import-export",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",