@nkhang1902/strapi-plugin-export-import-clsx 1.0.3

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.
@@ -0,0 +1,416 @@
1
+ const XLSX = require('xlsx');
2
+ const fs = require('fs');
3
+
4
+ function toCamel(str) {
5
+ return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
6
+ }
7
+
8
+ const SYSTEM_KEYS = [
9
+ 'documentId',
10
+ 'locale',
11
+ 'createdAt',
12
+ 'updatedAt',
13
+ 'publishedAt',
14
+ 'createdBy',
15
+ 'updatedBy',
16
+ 'localizations',
17
+ 'status'
18
+ ];
19
+
20
+ const SHORTCUT_FIELDS = [
21
+ 'email','businessEmail','name','title','tickerCode',
22
+ ]
23
+ async function importData(file) {
24
+ let result;
25
+ try {
26
+ let importData;
27
+ // Check file extension
28
+ const fileName = file.name || file.originalFilename || 'unknown.json';
29
+ const fileExtension = fileName.split('.').pop().toLowerCase();
30
+ const filePath = file.path || file.filepath;
31
+ if (!filePath) {
32
+ throw new Error('File path not found');
33
+ }
34
+
35
+ if (fileExtension === 'json') {
36
+ const fileContent = fs.readFileSync(filePath, 'utf8');
37
+ importData = JSON.parse(fileContent);
38
+ strapi.log.info('Parsed JSON data:', Object.keys(importData));
39
+ } else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
40
+ importData = transformExcelData(filePath);
41
+ }
42
+ result = await bulkInsertData(importData);
43
+ return result;
44
+ } catch (error) {
45
+ // Clean up uploaded file on error
46
+ const filePath = file && (file.path || file.filepath);
47
+ if (filePath && fs.existsSync(filePath)) {
48
+ fs.unlinkSync(filePath);
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+
54
+
55
+ function transformExcelData(filePath) {
56
+ const workbook = XLSX.readFile(filePath);
57
+ const importData = {};
58
+
59
+ const parseJsonIfNeeded = (value) => {
60
+ if (typeof value !== 'string') return value;
61
+ const trimmed = value.trim();
62
+ if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return value;
63
+
64
+ try {
65
+ return JSON.parse(trimmed);
66
+ } catch {
67
+ return value; // keep as string if invalid JSON
68
+ }
69
+ };
70
+
71
+ const isComponentField = (key) => {
72
+ const parts = key.split('_');
73
+ return parts.length === 2; // exactly one underscore
74
+ };
75
+
76
+ function unflattenRow(rows, targetContentType) {
77
+ const result = [];
78
+ const attr = strapi.contentTypes[targetContentType].attributes;
79
+ for (const row of rows) {
80
+ const rowData = {};
81
+
82
+ for (const [key, value] of Object.entries(row)) {
83
+ if (value === null || value === undefined || value === '') {
84
+ rowData[key] = null
85
+ } else if (attr[key] && attr[key].customField && attr[key].type === 'json' && attr[key].default === '[]') {
86
+ rowData[key] = parseJsonIfNeeded(value).split('|');
87
+ } else if (isComponentField(key)) {
88
+ const [comp, field] = key.split('_');
89
+ if (!rowData[comp]) rowData[comp] = {};
90
+ rowData[comp][field] = parseJsonIfNeeded(value);
91
+ } else {
92
+ rowData[key] = parseJsonIfNeeded(value);
93
+ }
94
+ }
95
+ result.push(rowData);
96
+ }
97
+
98
+ return result;
99
+ };
100
+
101
+ const mapSheetNameToContentType = (sheetName) => {
102
+ return "api::" + sheetName + "." + sheetName;
103
+ };
104
+
105
+ workbook.SheetNames.forEach(sheetName => {
106
+ const worksheet = workbook.Sheets[sheetName];
107
+ const rows = XLSX.utils.sheet_to_json(worksheet);
108
+
109
+ if (!rows.length) return;
110
+
111
+ const contentTypeName = mapSheetNameToContentType(sheetName);
112
+
113
+ strapi.log.info(`Reading sheet "${sheetName}" -> ${rows.length} rows`);
114
+ strapi.log.info(`Mapped sheet to content-type: ${contentTypeName}`);
115
+
116
+ if (contentTypeName.startsWith('api::')) {
117
+ importData[contentTypeName] = unflattenRow(rows, contentTypeName);
118
+ } else {
119
+ strapi.log.error(`Unknown content-type: ${contentTypeName}`);
120
+ return;
121
+ }
122
+ });
123
+
124
+ strapi.log.info('Final import data keys:', Object.keys(importData));
125
+ return importData;
126
+ }
127
+
128
+ function getRelationFields(contentType) {
129
+ const schema = strapi.contentTypes[contentType];
130
+
131
+ if (!schema) {
132
+ strapi.log.warn(`Content type ${contentType} not found`);
133
+ return [];
134
+ }
135
+
136
+ return Object.entries(schema.attributes)
137
+ .filter(([_, attr]) => attr.type === "relation")
138
+ .map(([fieldName, attr]) => ({
139
+ field: toCamel(fieldName),
140
+ target: attr.target, // e.g. "api::category.category"
141
+ relation: attr.relation,
142
+ }));
143
+ }
144
+
145
+ function getComponentFields(contentType) {
146
+ const schema = strapi.contentTypes[contentType];
147
+
148
+ if (!schema) {
149
+ strapi.log.warn(`Content type ${contentType} not found`);
150
+ return [];
151
+ }
152
+
153
+ return Object.entries(schema.attributes)
154
+ .filter(([_, attr]) => attr.type === "component")
155
+ .map(([fieldName, attr]) => toCamel(fieldName));
156
+ }
157
+
158
+ async function handleRelations(entry, contentType) {
159
+ async function resolveRelationValue(field, value, target) {
160
+ const targetAttr = strapi.contentTypes[target].attributes;
161
+ for (const field of SHORTCUT_FIELDS) {
162
+ if (!targetAttr[field]) continue;
163
+ const existing = await strapi.documents(target).findFirst({
164
+ filters: { [field]: { $eq: value } },
165
+ });
166
+ if (existing) return {id: existing.id};
167
+ throw new Error(`Data with ${field} ${value} not found`);
168
+ }
169
+ return null;
170
+ }
171
+
172
+ const relationFields = getRelationFields(contentType);
173
+ if (relationFields.length === 0) return entry;
174
+
175
+ const updatedEntry = { ...entry };
176
+
177
+ for (const rel of relationFields) {
178
+ const { field, target, relation } = rel;
179
+
180
+ let value = entry[field];
181
+ if (!value || value === "") {
182
+ if (relation === "manyToMany" || relation === "oneToMany") {
183
+ updatedEntry[field] = [];
184
+ } else {
185
+ updatedEntry[field] = null;
186
+ }
187
+ continue;
188
+ };
189
+
190
+ // Convert CSV to array
191
+ if (typeof value === "string" && (relation === "manyToMany" || relation === "oneToMany")) {
192
+ value = value.split("|");
193
+ } else if (typeof value === "string" && value.includes("|")) {
194
+ throw new Error(`Invalid value for field ${field}: ${value}, ${field} is not an array`);
195
+ }
196
+
197
+ const values = Array.isArray(value) ? value : [value];
198
+ try {
199
+ const processed = [];
200
+
201
+ for (const v of values) {
202
+ if (!v || v === "") continue;
203
+ const resolved = await resolveRelationValue(field, v, target);
204
+ if (resolved) processed.push(resolved);
205
+ }
206
+
207
+ updatedEntry[field] = Array.isArray(value) ? processed : processed[0];
208
+ } catch (err) {
209
+ throw new Error(
210
+ `Failed processing field ${field} with value ${JSON.stringify(value)}: ${err.message}`
211
+ );
212
+ }
213
+ }
214
+
215
+ return updatedEntry;
216
+ }
217
+
218
+ function handleComponents(data, existing, contentType) {
219
+ // Get the component fields for this content type
220
+ const compFields = getComponentFields(contentType);
221
+
222
+ for (const field of compFields) {
223
+ const newValue = data[field];
224
+ const oldValue = existing?.[field];
225
+
226
+ if (!newValue || !oldValue) continue;
227
+
228
+ //single component
229
+ if (!Array.isArray(newValue)) {
230
+ if (oldValue?.id) {
231
+ data[field].id = oldValue.id;
232
+ }
233
+ for (const key of Object.keys(data[field])) {
234
+ if (Array.isArray(oldValue[key])) {
235
+ data[field][key] = data[field][key].split("|");
236
+ }
237
+ }
238
+ continue;
239
+ }
240
+
241
+ //multiple components
242
+ if (Array.isArray(newValue) && Array.isArray(oldValue)) {
243
+ data[field] = newValue.map((block, i) => {
244
+ const oldBlock = oldValue[i];
245
+ if (oldBlock?.id) {
246
+ return { id: oldBlock.id, ...block };
247
+ }
248
+ for (const key of Object.keys(block)) {
249
+ if (Array.isArray(oldBlock[key])) {
250
+ block[key] = block[key].split("|");
251
+ }
252
+ }
253
+ return block;
254
+ });
255
+ }
256
+ }
257
+
258
+ return data;
259
+ }
260
+
261
+ function hasChanges(existing, incoming) {
262
+ if (!incoming || typeof incoming !== "object") return false;
263
+ if (!existing || typeof existing !== "object") return true;
264
+ for (const key of Object.keys(incoming)) {
265
+ // Skip system keys
266
+ if (SYSTEM_KEYS.includes(key)) continue;
267
+ const newVal = incoming[key];
268
+ const oldVal = existing[key];
269
+
270
+ // If incoming defines a field but existing doesn't → change
271
+ if (oldVal === undefined || newVal === undefined) {
272
+ continue;
273
+ }
274
+
275
+ // Primitive comparison
276
+ if (newVal === null || typeof newVal !== "object") {
277
+ if (oldVal !== newVal) {
278
+ return true;
279
+ }
280
+ continue;
281
+ }
282
+
283
+ // ARRAY comparison
284
+ if (Array.isArray(newVal)) {
285
+ if (!Array.isArray(oldVal)) return true;
286
+ if (newVal.length !== oldVal.length) return true;
287
+ // Compare values shallowly
288
+ for (let i = 0; i < newVal.length; i++) {
289
+ if (typeof newVal[i] === "object" && typeof oldVal[i] === "object" && hasChanges(oldVal[i], newVal[i])) {
290
+ return true;
291
+ } else if (typeof newVal[i] !== "object" && typeof oldVal[i] !== "object" && newVal[i] !== oldVal[i]) {
292
+ return true;
293
+ }
294
+ }
295
+ continue;
296
+ }
297
+
298
+ // OBJECT comparison (recursive, but ONLY fields in incoming object)
299
+ if (typeof newVal === "object" && typeof oldVal === "object") {
300
+ if (hasChanges(oldVal, newVal)) {
301
+ return true;
302
+ }
303
+ continue;
304
+ }
305
+ }
306
+
307
+ return false;
308
+ }
309
+
310
+
311
+ async function bulkInsertData(importData) {
312
+ const results = {
313
+ created: 0,
314
+ updated: 0,
315
+ errors: [],
316
+ };
317
+
318
+ for (const [contentType, entries] of Object.entries(importData)) {
319
+ // Validate entries
320
+ if (!strapi.contentTypes[contentType]) {
321
+ results.errors.push(`Content type ${contentType} not found`);
322
+ continue;
323
+ }
324
+ if (!Array.isArray(entries)) {
325
+ results.errors.push(`Invalid data format for ${contentType}`);
326
+ continue;
327
+ }
328
+
329
+ try {
330
+ const { created, updated, errors } = await importEntries(entries, contentType);
331
+ results.created += created;
332
+ results.updated += updated;
333
+ results.errors = results.errors.concat(errors);
334
+ } catch (err) {
335
+ results.errors.push(err.message);
336
+ }
337
+ }
338
+
339
+ return results;
340
+ }
341
+
342
+ async function importEntries(entries, contentType) {
343
+ const results = { created: 0, updated: 0, errors: [] };
344
+
345
+ await strapi.db.transaction(async ({ trx, rollback, onRollback }) => {
346
+ onRollback(() => {
347
+ strapi.log.error("Transaction rolled back due to an error!");
348
+ strapi.log.error(results.errors);
349
+ });
350
+
351
+ for (let i = 0; i < entries.length; i++) {
352
+ const entry = entries[i];
353
+ let existing = null;
354
+
355
+ try {
356
+ let { id, ...data } = entry;
357
+
358
+ // Check if document exists
359
+ if (id && id !== "null" && id !== "undefined") {
360
+ existing = await strapi.documents(contentType).findFirst(
361
+ {
362
+ filters: { id },
363
+ populate: "*",
364
+ },
365
+ { transaction: trx }
366
+ );
367
+ }
368
+
369
+ // Handle relations & components
370
+ data = await handleRelations(data, contentType, trx);
371
+ data = await handleComponents(data, existing, contentType);
372
+
373
+ // Update
374
+ if (existing) {
375
+ if (hasChanges(existing, data)) {
376
+ await strapi.documents(contentType).update(
377
+ {
378
+ documentId: existing.documentId,
379
+ data,
380
+ },
381
+ { transaction: trx }
382
+ );
383
+ results.updated++;
384
+ }
385
+ }
386
+
387
+ // Create
388
+ else {
389
+ await strapi.documents(contentType).create(
390
+ { data },
391
+ { transaction: trx }
392
+ );
393
+ results.created++;
394
+ }
395
+ } catch (err) {
396
+ results.errors.push(
397
+ `Failed ${existing ? "updating" : "creating"} on row ${
398
+ i + 2
399
+ }: ${err.message}`
400
+ );
401
+ results.created = 0;
402
+ results.updated = 0;
403
+
404
+ // IMPORTANT: force rollback
405
+ throw err;
406
+ }
407
+ }
408
+ });
409
+
410
+ return results;
411
+ }
412
+
413
+
414
+ module.exports = {
415
+ importData,
416
+ };
@@ -0,0 +1,7 @@
1
+ const exportService = require('./export-service');
2
+ const importService = require('./import-service');
3
+
4
+ module.exports = {
5
+ 'export-service': exportService,
6
+ 'import-service': importService,
7
+ };
@@ -0,0 +1,88 @@
1
+ import React from 'react';
2
+ import pluginPkg from './package.json';
3
+ import pluginId from './admin/src/pluginId';
4
+ import Initializer from './admin/src/components/Initializer';
5
+ import ExportImportButtons from './admin/src/components/ExportImportButtons';
6
+
7
+ const name = pluginPkg.strapi.name;
8
+
9
+ export default {
10
+ register(app) {
11
+ const plugin = {
12
+ id: pluginId,
13
+ initializer: Initializer,
14
+ isReady: false,
15
+ name,
16
+ };
17
+
18
+ app.registerPlugin(plugin);
19
+ },
20
+
21
+ bootstrap(app) {
22
+ // Try different injection methods for Strapi v5
23
+ try {
24
+ // Method 1: Direct injection
25
+ if (app.injectContentManagerComponent) {
26
+ app.injectContentManagerComponent('listView', 'actions', {
27
+ name: 'export-import-buttons',
28
+ Component: ExportImportButtons,
29
+ });
30
+ }
31
+ // Method 2: Plugin-based injection
32
+ else if (app.getPlugin) {
33
+ const contentManager = app.getPlugin('content-manager');
34
+ if (contentManager && contentManager.injectComponent) {
35
+ contentManager.injectComponent('listView', 'actions', {
36
+ name: 'export-import-buttons',
37
+ Component: ExportImportButtons,
38
+ });
39
+ }
40
+ }
41
+ // Method 3: Global injection
42
+ else if (app.addComponent) {
43
+ app.addComponent('content-manager.listView.actions', ExportImportButtons);
44
+ }
45
+ } catch (error) {
46
+ console.warn('Failed to inject export-import buttons:', error);
47
+
48
+ // Fallback: Add as menu item if injection fails
49
+ app.addMenuLink({
50
+ to: `/plugins/${pluginId}`,
51
+ icon: () => React.createElement('span', null, '📊'),
52
+ intlLabel: {
53
+ id: `${pluginId}.plugin.name`,
54
+ defaultMessage: 'Export Import',
55
+ },
56
+ Component: async () => {
57
+ const component = await import('./admin/src/pages/App');
58
+ return component;
59
+ },
60
+ permissions: [],
61
+ });
62
+ }
63
+ },
64
+
65
+ async registerTrads(app) {
66
+ const { locales } = app;
67
+
68
+ const importedTrads = await Promise.all(
69
+ locales.map((locale) => {
70
+ return import(`./admin/src/translations/${locale}.json`)
71
+ .then(({ default: data }) => {
72
+ return {
73
+ data: data,
74
+ locale,
75
+ };
76
+ })
77
+ .catch(() => {
78
+ return {
79
+ data: {},
80
+ locale,
81
+ };
82
+ });
83
+ })
84
+ );
85
+
86
+ return Promise.resolve(importedTrads);
87
+ },
88
+ };
@@ -0,0 +1,34 @@
1
+ module.exports = {
2
+ register({ strapi }) {
3
+ // Register phase
4
+ },
5
+
6
+ bootstrap({ strapi }) {
7
+ // Bootstrap phase
8
+ },
9
+
10
+ destroy({ strapi }) {
11
+ // Destroy phase
12
+ },
13
+
14
+ config: {
15
+ default: {},
16
+ validator() {},
17
+ },
18
+
19
+ controllers: {
20
+ 'export-controller': require('./server/controllers/export-controller'),
21
+ 'import-controller': require('./server/controllers/import-controller'),
22
+ },
23
+
24
+ routes: require('./server/routes'),
25
+
26
+ services: {
27
+ 'export-service': require('./server/services/export-service'),
28
+ 'import-service': require('./server/services/import-service'),
29
+ },
30
+
31
+ contentTypes: {},
32
+ policies: {},
33
+ middlewares: {},
34
+ };