@adminforth/import-export 1.6.2 → 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/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";
@@ -7,6 +7,7 @@ import { z } from "zod";
7
7
  const exportCsvBodySchema = z.object({
8
8
  filters: z.any(),
9
9
  sort: z.any(),
10
+ selectedIds: z.array(z.any()).optional(),
10
11
  }).strict();
11
12
 
12
13
  const importCsvBodySchema = z.object({
@@ -20,7 +21,7 @@ export default class ImportExport extends AdminForthPlugin {
20
21
  adminforth: IAdminForth;
21
22
  auditLogPlugin: Record<string, any> | undefined;
22
23
 
23
-
24
+
24
25
  constructor(options: PluginOptions) {
25
26
  super(options, import.meta.url);
26
27
  this.options = options;
@@ -105,10 +106,7 @@ export default class ImportExport extends AdminForthPlugin {
105
106
  }
106
107
  (resourceConfig.options.pageInjections.list.threeDotsDropdownItems as AdminForthComponentDeclaration[]).push({
107
108
  file: this.componentPath('ExportCsv.vue'),
108
- meta: { pluginInstanceId: this.pluginInstanceId, select: 'all' }
109
- }, {
110
- file: this.componentPath('ExportCsv.vue'),
111
- meta: { pluginInstanceId: this.pluginInstanceId, select: 'filtered' }
109
+ meta: { pluginInstanceId: this.pluginInstanceId }
112
110
  }, {
113
111
  file: this.componentPath('ImportCsv.vue'),
114
112
  meta: { pluginInstanceId: this.pluginInstanceId }
@@ -138,9 +136,8 @@ export default class ImportExport extends AdminForthPlugin {
138
136
  method: 'POST',
139
137
  path: `/plugin/${this.pluginInstanceId}/export-csv`,
140
138
  request_schema: exportCsvBodySchema,
141
- handler: async ({ body, adminUser, headers, response }) => {
142
- const payload = body as z.infer<typeof exportCsvBodySchema>;
143
- const { filters, sort } = payload;
139
+ handler: async ({ body, adminUser, headers }) => {
140
+ const { filters, sort, selectedIds } = body as z.infer<typeof exportCsvBodySchema>;
144
141
  if (!filters || !sort) {
145
142
  return { ok: false, error: 'Missing filters or sort in request body' };
146
143
  }
@@ -159,44 +156,21 @@ export default class ImportExport extends AdminForthPlugin {
159
156
  if (rawFilterError) {
160
157
  return rawFilterError;
161
158
  }
162
- const data = await this.adminforth.connectors[this.resourceConfig.dataSource].getData({
163
- resource: this.resourceConfig,
164
- limit: 1e6,
165
- offset: 0,
166
- filters: this.adminforth.connectors[this.resourceConfig.dataSource].validateAndNormalizeInputFilters(filters),
167
- sort,
168
- getTotals: true,
169
- });
170
-
171
- // prepare data for PapaParse unparse
172
- const columns = this.resourceConfig.columns.filter((col) => !col.virtual && !col.backendOnly);
173
-
174
- const columnsToForceQuote = columns.map(col => {
175
- return col.type !== AdminForthDataTypes.FLOAT
176
- && col.type !== AdminForthDataTypes.INTEGER
177
- && col.type !== AdminForthDataTypes.BOOLEAN;
178
- })
179
159
 
180
- const fields = columns.map((col) => col.name);
181
-
182
- const rows = data.data.map((row) => {
183
- return columns.map((col) => {
184
- const value = row[col.name];
185
- if (col.type === AdminForthDataTypes.JSON || col.isArray?.enabled) {
186
- return value == null ? value : JSON.stringify(value);
187
- }
188
- return value;
189
- });
190
- });
191
-
192
- this.tryToAuditLogAction('export', `Export CSV with filters: ${JSON.stringify(filters)} and sort: ${JSON.stringify(sort)}. Total records: ${rows.length}`, adminUser, headers);
160
+ let effectiveFilters = filters;
161
+ if (Array.isArray(selectedIds) && selectedIds.length > 0) {
162
+ const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
163
+ if (!primaryKeyColumn) {
164
+ return { ok: false, error: 'Cannot export selected records: resource has no primary key' };
165
+ }
166
+ effectiveFilters = [{
167
+ field: primaryKeyColumn.name,
168
+ operator: AdminForthFilterOperators.IN,
169
+ value: selectedIds,
170
+ }];
171
+ }
193
172
 
194
- return {
195
- data: { fields, data: rows },
196
- columnsToForceQuote,
197
- exportedCount: data.total,
198
- ok: true
199
- };
173
+ return this.exportCsv(effectiveFilters, sort, { adminUser, headers });
200
174
  }
201
175
  });
202
176
 
@@ -205,8 +179,7 @@ export default class ImportExport extends AdminForthPlugin {
205
179
  path: `/plugin/${this.pluginInstanceId}/import-csv`,
206
180
  request_schema: importCsvBodySchema,
207
181
  handler: async ({ body, adminUser, query, headers, cookies, requestUrl, response }) => {
208
- const payload = body as z.infer<typeof importCsvBodySchema>;
209
- const { data } = payload;
182
+ const { data } = body as z.infer<typeof importCsvBodySchema>;
210
183
  if (!data || typeof data !== 'object') {
211
184
  return { ok: false, error: 'Invalid data format. Expected an object with column names as keys and arrays of values as values.' };
212
185
  }
@@ -221,66 +194,12 @@ export default class ImportExport extends AdminForthPlugin {
221
194
  if (!createEditAccess.ok) {
222
195
  return { ok: false, error: createEditAccess.error };
223
196
  }
224
- const columns = this.getColumnNames(data);
225
- const { errors, resourceColumns } = this.validateColumns(columns);
226
- const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
227
-
228
- if (errors.length > 0) {
229
- return { ok: false, errors };
230
- }
231
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
232
- const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
233
-
234
- console.log('Prepared rows for import:', rows);
235
- this.tryToAuditLogAction('import', `Import CSV with ${Object.keys(data).length} columns`, adminUser, headers);
236
-
237
- let importedCount = 0;
238
- let updatedCount = 0;
239
- const limit = pLimit(100);
240
-
241
- await Promise.all(rows.map((row) => limit(async () => {
242
- try {
243
- const rowErrors = await this.isRowValid(row);
244
- if (rowErrors.length > 0) {
245
- errors.push(...rowErrors);
246
- return;
247
- }
248
- const recordId = primaryKeyColumn ? row[primaryKeyColumn.name] as string : undefined;
249
- if (primaryKeyColumn && recordId) {
250
- const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
251
- .list([Filters.EQ(primaryKeyColumn.name, recordId)]);
252
-
253
- if (existingRecord.length > 0) {
254
- const connector = this.adminforth.connectors[resource.dataSource];
255
- const oldRecord = await connector.getRecordByPrimaryKey(resource, recordId)
256
- if (!oldRecord) {
257
- const primaryKeyColumn = resource.columns.find((col) => col.primaryKey);
258
- return { error: `Record with ${primaryKeyColumn.name} ${recordId} not found` };
259
- }
260
- const { error } = await this.adminforth.updateResourceRecord({
261
- resource, updates: row, adminUser, oldRecord, recordId, response,
262
- extra: { body, query, headers, cookies, requestUrl, response }
263
- });
264
- if (error) {
265
- return { error };
266
- }
267
- updatedCount++;
268
- return;
269
- }
270
- }
271
- await this.adminforth.createResourceRecord({
272
- resource: resource,
273
- record: row,
274
- adminUser: adminUser,
275
- extra: { body, query, headers, cookies, requestUrl, response }
276
- });
277
- importedCount++;
278
- } catch (e) {
279
- errors.push(e.message);
280
- }
281
- })));
282
-
283
- 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
+ });
284
203
  }
285
204
  });
286
205
 
@@ -289,8 +208,7 @@ export default class ImportExport extends AdminForthPlugin {
289
208
  path: `/plugin/${this.pluginInstanceId}/import-csv-new-only`,
290
209
  request_schema: importCsvBodySchema,
291
210
  handler: async ({ body, adminUser, query, headers, cookies, requestUrl, response }) => {
292
- const payload = body as z.infer<typeof importCsvBodySchema>;
293
- const { data } = payload;
211
+ const { data } = body as z.infer<typeof importCsvBodySchema>;
294
212
  if (!data || typeof data !== 'object') {
295
213
  return { ok: false, error: 'Invalid data format. Expected an object with column names as keys and arrays of values as values.' };
296
214
  }
@@ -302,48 +220,11 @@ export default class ImportExport extends AdminForthPlugin {
302
220
  if (!access.ok) {
303
221
  return { ok: false, error: access.error };
304
222
  }
305
- const columns = this.getColumnNames(data);
306
- const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
307
- const { errors, resourceColumns } = this.validateColumns(columns);
308
- if (errors.length > 0) {
309
- return { ok: false, errors };
310
- }
311
-
312
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
313
- const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
314
- this.tryToAuditLogAction('import', `Import CSV (new only) with ${Object.keys(data).length} columns`, adminUser, headers);
315
-
316
- let importedCount = 0;
317
- const limit = pLimit(100);
318
-
319
- await Promise.all(rows.map((row) => limit(async () => {
320
- try {
321
- const rowErrors = await this.isRowValid(row);
322
- if (rowErrors.length > 0) {
323
- errors.push(...rowErrors);
324
- return;
325
- }
326
- if (primaryKeyColumn && row[primaryKeyColumn.name]) {
327
- const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
328
- .list([Filters.EQ(primaryKeyColumn.name, row[primaryKeyColumn.name])]);
329
-
330
- if (existingRecord.length > 0) {
331
- return;
332
- }
333
- }
334
- await this.adminforth.createResourceRecord({
335
- resource: resource,
336
- record: row,
337
- adminUser: adminUser,
338
- extra: { body, query, headers, cookies, requestUrl, response }
339
- });
340
- importedCount++;
341
- } catch (e) {
342
- errors.push(e.message);
343
- }
344
- })));
345
-
346
- return { ok: true, importedCount, errors };
223
+ return this.importCsvNewOnly(data, {
224
+ adminUser,
225
+ headers,
226
+ extra: { body, query, headers, cookies, requestUrl, response },
227
+ });
347
228
  }
348
229
  });
349
230
 
@@ -351,8 +232,8 @@ export default class ImportExport extends AdminForthPlugin {
351
232
  method: 'POST',
352
233
  path: `/plugin/${this.pluginInstanceId}/check-records`,
353
234
  request_schema: importCsvBodySchema,
354
- handler: async ({ body, adminUser, response }) => {
355
- const payload = body as z.infer<typeof importCsvBodySchema>;
235
+ handler: async ({ body, adminUser }) => {
236
+ const { data } = body as z.infer<typeof importCsvBodySchema>;
356
237
  const access = await this.ensureAnyAllowed(
357
238
  adminUser,
358
239
  [
@@ -364,32 +245,240 @@ export default class ImportExport extends AdminForthPlugin {
364
245
  if (!access.ok) {
365
246
  return { ok: false, error: access.error };
366
247
  }
367
- const { data } = payload;
368
- const primaryKeyColumn = this.resourceConfig.columns.find(col => col.primaryKey);
369
- const columns = this.getColumnNames(data);
370
- const rows = this.buildRowsFromData(data, columns, undefined, { coerceTypes: false });
371
-
372
- const primaryKeys = rows
373
- .map(row => primaryKeyColumn ? row[primaryKeyColumn.name] : undefined)
374
- .filter(key => key !== undefined && key !== null && key !== '');
375
-
376
- const existingRecords = await this.adminforth
377
- .resource(this.resourceConfig.resourceId)
378
- .list([{
379
- field: primaryKeyColumn.name,
380
- operator: AdminForthFilterOperators.IN,
381
- value: primaryKeys,
382
- }]);
248
+ return this.checkRecords(data);
249
+ }
250
+ });
251
+ }
383
252
 
384
- return {
385
- ok: true,
386
- total: rows.length,
387
- existingCount: existingRecords.length,
388
- newCount: rows.length - existingRecords.length,
389
- };
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
+ });
390
277
 
391
- }
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;
392
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
+ });
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
+ };
393
482
  }
394
483
 
395
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.6.2",
3
+ "version": "1.8.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",