@tunghtml/strapi-plugin-export-import-clsx 1.0.0 → 1.0.2
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/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @tunghtml/export-import-clsx
|
|
1
|
+
# @tunghtml/strapi-plugin-export-import-clsx
|
|
2
2
|
|
|
3
3
|
A powerful Strapi plugin for exporting and importing data with enhanced functionality, including Excel support and advanced filtering.
|
|
4
4
|
|
|
@@ -15,7 +15,9 @@ A powerful Strapi plugin for exporting and importing data with enhanced function
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npm install @tunghtml/export-import-clsx
|
|
18
|
+
npm install @tunghtml/strapi-plugin-export-import-clsx
|
|
19
|
+
# or
|
|
20
|
+
yarn add @tunghtml/strapi-plugin-export-import-clsx
|
|
19
21
|
```
|
|
20
22
|
|
|
21
23
|
## Usage
|
|
@@ -86,7 +88,7 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
|
|
86
88
|
|
|
87
89
|
## License
|
|
88
90
|
|
|
89
|
-
MIT ©
|
|
91
|
+
MIT © finnwasabi
|
|
90
92
|
|
|
91
93
|
## Support
|
|
92
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tunghtml/strapi-plugin-export-import-clsx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "A powerful Strapi plugin for exporting and importing data with Excel support and advanced filtering",
|
|
5
5
|
"main": "./strapi-server.js",
|
|
6
6
|
"scripts": {
|
|
@@ -21,17 +21,17 @@
|
|
|
21
21
|
"strapi-plugin"
|
|
22
22
|
],
|
|
23
23
|
"author": {
|
|
24
|
-
"name": "
|
|
25
|
-
"email": "
|
|
24
|
+
"name": "finnwasabi",
|
|
25
|
+
"email": "finnwasabi@example.com"
|
|
26
26
|
},
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/
|
|
30
|
+
"url": "https://github.com/finnwasabi/strapi-plugin-export-import-clsx.git"
|
|
31
31
|
},
|
|
32
|
-
"homepage": "https://github.com/
|
|
32
|
+
"homepage": "https://github.com/finnwasabi/strapi-plugin-export-import-clsx#readme",
|
|
33
33
|
"bugs": {
|
|
34
|
-
"url": "https://github.com/
|
|
34
|
+
"url": "https://github.com/finnwasabi/strapi-plugin-export-import-clsx/issues"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=16.0.0",
|
|
@@ -57,4 +57,4 @@
|
|
|
57
57
|
"kind": "plugin",
|
|
58
58
|
"category": "data-management"
|
|
59
59
|
}
|
|
60
|
-
}
|
|
60
|
+
}
|
|
@@ -12,7 +12,7 @@ module.exports = ({ strapi }) => ({
|
|
|
12
12
|
|
|
13
13
|
const importService = strapi.plugin('export-import-clsx').service('import-service');
|
|
14
14
|
|
|
15
|
-
const result = await importService.importData(file
|
|
15
|
+
const result = await importService.importData(file);
|
|
16
16
|
|
|
17
17
|
// Create appropriate message based on results
|
|
18
18
|
let message = 'Import completed successfully';
|
|
@@ -22,13 +22,13 @@ module.exports = ({ strapi }) => ({
|
|
|
22
22
|
try {
|
|
23
23
|
// Parse filters from URL format
|
|
24
24
|
const parsedFilters = this.parseFilters(filters);
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
strapi.log.info(`Exporting ${ct} with raw filters:`, filters);
|
|
27
27
|
strapi.log.info(`Parsed filters:`, parsedFilters);
|
|
28
28
|
strapi.log.info(`Selected IDs:`, selectedIds);
|
|
29
29
|
|
|
30
30
|
let entries = [];
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
// If specific IDs are selected, export only those
|
|
33
33
|
if (selectedIds && selectedIds.length > 0) {
|
|
34
34
|
try {
|
|
@@ -74,22 +74,18 @@ module.exports = ({ strapi }) => ({
|
|
|
74
74
|
populate: '*',
|
|
75
75
|
// Don't specify status to get all
|
|
76
76
|
});
|
|
77
|
-
|
|
78
77
|
// Group by documentId and keep only the best version (published > modified draft > draft)
|
|
79
78
|
const uniqueEntries = new Map();
|
|
80
|
-
|
|
81
79
|
for (const entry of allEntries) {
|
|
82
80
|
const docId = entry.documentId;
|
|
83
81
|
const isPublished = !!entry.publishedAt;
|
|
84
82
|
const isModified = entry.updatedAt !== entry.createdAt;
|
|
85
|
-
|
|
86
83
|
if (!uniqueEntries.has(docId)) {
|
|
87
84
|
uniqueEntries.set(docId, entry);
|
|
88
85
|
} else {
|
|
89
86
|
const existing = uniqueEntries.get(docId);
|
|
90
87
|
const existingIsPublished = !!existing.publishedAt;
|
|
91
88
|
const existingIsModified = existing.updatedAt !== existing.createdAt;
|
|
92
|
-
|
|
93
89
|
// Priority: published > modified draft > draft
|
|
94
90
|
if (isPublished && !existingIsPublished) {
|
|
95
91
|
uniqueEntries.set(docId, entry);
|
|
@@ -98,10 +94,10 @@ module.exports = ({ strapi }) => ({
|
|
|
98
94
|
}
|
|
99
95
|
}
|
|
100
96
|
}
|
|
101
|
-
|
|
97
|
+
|
|
102
98
|
entries = Array.from(uniqueEntries.values());
|
|
103
99
|
strapi.log.info(`Found ${allEntries.length} total entries, ${entries.length} unique entries after deduplication`);
|
|
104
|
-
|
|
100
|
+
|
|
105
101
|
// Apply filters
|
|
106
102
|
if (parsedFilters && Object.keys(parsedFilters).length > 0) {
|
|
107
103
|
strapi.log.info('Applying filters:', parsedFilters);
|
|
@@ -120,9 +116,9 @@ module.exports = ({ strapi }) => ({
|
|
|
120
116
|
strapi.log.error(`Failed to query entries:`, error);
|
|
121
117
|
}
|
|
122
118
|
}
|
|
123
|
-
|
|
119
|
+
|
|
124
120
|
strapi.log.info(`Final result: ${entries?.length || 0} entries for ${ct} (total found: ${entries?.length || 0})`);
|
|
125
|
-
|
|
121
|
+
|
|
126
122
|
if (entries && entries.length > 0) {
|
|
127
123
|
exportData.data[ct] = entries;
|
|
128
124
|
}
|
|
@@ -130,8 +126,9 @@ module.exports = ({ strapi }) => ({
|
|
|
130
126
|
strapi.log.error(`Failed to export ${ct}:`, error);
|
|
131
127
|
}
|
|
132
128
|
}
|
|
133
|
-
|
|
129
|
+
|
|
134
130
|
if (format === 'excel') {
|
|
131
|
+
console.log(exportData.data)
|
|
135
132
|
return this.convertToExcel(exportData.data);
|
|
136
133
|
}
|
|
137
134
|
|
|
@@ -140,27 +137,26 @@ module.exports = ({ strapi }) => ({
|
|
|
140
137
|
|
|
141
138
|
parseFilters(filters) {
|
|
142
139
|
const parsed = {};
|
|
143
|
-
|
|
144
140
|
for (const [key, value] of Object.entries(filters)) {
|
|
145
141
|
// Skip pagination and sorting params
|
|
146
142
|
if (['page', 'pageSize', 'sort', 'locale', 'format', 'contentType', 'selectedIds'].includes(key)) {
|
|
147
143
|
continue;
|
|
148
144
|
}
|
|
149
|
-
|
|
145
|
+
|
|
150
146
|
// Handle URL encoded filter format like filters[$and][0][shortName][$contains]
|
|
151
147
|
if (key.startsWith('filters[')) {
|
|
152
148
|
// Extract the actual filter structure
|
|
153
149
|
const match = key.match(/filters\[([^\]]+)\](?:\[(\d+)\])?\[([^\]]+)\](?:\[([^\]]+)\])?/);
|
|
154
150
|
if (match) {
|
|
155
151
|
const [, operator, index, field, condition] = match;
|
|
156
|
-
|
|
152
|
+
|
|
157
153
|
if (!parsed.filters) parsed.filters = {};
|
|
158
|
-
|
|
154
|
+
|
|
159
155
|
if (operator === '$and') {
|
|
160
156
|
if (!parsed.filters.$and) parsed.filters.$and = [];
|
|
161
157
|
const idx = parseInt(index) || 0;
|
|
162
158
|
if (!parsed.filters.$and[idx]) parsed.filters.$and[idx] = {};
|
|
163
|
-
|
|
159
|
+
|
|
164
160
|
if (condition) {
|
|
165
161
|
if (!parsed.filters.$and[idx][field]) parsed.filters.$and[idx][field] = {};
|
|
166
162
|
parsed.filters.$and[idx][field][condition] = value;
|
|
@@ -173,7 +169,7 @@ module.exports = ({ strapi }) => ({
|
|
|
173
169
|
parsed[key] = value;
|
|
174
170
|
}
|
|
175
171
|
}
|
|
176
|
-
|
|
172
|
+
|
|
177
173
|
return parsed;
|
|
178
174
|
},
|
|
179
175
|
|
|
@@ -192,7 +188,6 @@ module.exports = ({ strapi }) => ({
|
|
|
192
188
|
if (entry[field]) {
|
|
193
189
|
const fieldValue = String(entry[field]).toLowerCase();
|
|
194
190
|
const searchValue = String(criteria.$contains).toLowerCase();
|
|
195
|
-
|
|
196
191
|
if (!fieldValue.includes(searchValue)) {
|
|
197
192
|
return false;
|
|
198
193
|
}
|
|
@@ -208,30 +203,26 @@ module.exports = ({ strapi }) => ({
|
|
|
208
203
|
}
|
|
209
204
|
}
|
|
210
205
|
}
|
|
211
|
-
|
|
212
206
|
// Handle other filter formats
|
|
213
207
|
for (const [key, value] of Object.entries(filters)) {
|
|
214
208
|
if (key === 'filters') continue; // Already handled above
|
|
215
|
-
|
|
209
|
+
|
|
216
210
|
// Handle simple search (global search)
|
|
217
211
|
if (key === '_q' || key === 'search') {
|
|
218
212
|
// Global search across main fields
|
|
219
213
|
const searchFields = ['shortName', 'name', 'title'];
|
|
220
214
|
const searchValue = String(value).toLowerCase();
|
|
221
|
-
|
|
222
215
|
const found = searchFields.some(field => {
|
|
223
216
|
if (entry[field]) {
|
|
224
217
|
return String(entry[field]).toLowerCase().includes(searchValue);
|
|
225
218
|
}
|
|
226
219
|
return false;
|
|
227
220
|
});
|
|
228
|
-
|
|
229
221
|
if (!found) {
|
|
230
222
|
return false;
|
|
231
223
|
}
|
|
232
224
|
}
|
|
233
225
|
}
|
|
234
|
-
|
|
235
226
|
return true;
|
|
236
227
|
});
|
|
237
228
|
|
|
@@ -245,53 +236,94 @@ module.exports = ({ strapi }) => ({
|
|
|
245
236
|
for (const [contentType, entries] of Object.entries(data)) {
|
|
246
237
|
// Clean sheet name (Excel has restrictions)
|
|
247
238
|
const sheetName = contentType.replace(/[^\w\s]/gi, '_').substring(0, 31);
|
|
248
|
-
|
|
249
239
|
if (entries && entries.length > 0) {
|
|
250
240
|
hasData = true;
|
|
251
|
-
|
|
241
|
+
|
|
242
|
+
const attr = strapi.contentTypes[contentType].attributes;
|
|
243
|
+
const customFields = Object.entries(attr)
|
|
244
|
+
.filter(([key, definition]) => definition.customField)
|
|
245
|
+
.map(([key, definition]) => key);
|
|
246
|
+
|
|
252
247
|
// Clean and flatten entries for Excel
|
|
253
248
|
const cleanedEntries = entries.map(entry => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
249
|
+
const SYSTEM_KEYS = [
|
|
250
|
+
'documentId',
|
|
251
|
+
'locale',
|
|
252
|
+
'createdAt',
|
|
253
|
+
'updatedAt',
|
|
254
|
+
'publishedAt',
|
|
255
|
+
'createdBy',
|
|
256
|
+
'updatedBy',
|
|
257
|
+
'localizations',
|
|
258
|
+
'status'
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
function cleanAndFlatten(obj) {
|
|
262
|
+
if (Array.isArray(obj)) {
|
|
263
|
+
return obj.map(cleanAndFlatten);
|
|
264
|
+
} else if (obj !== null && typeof obj === 'object') {
|
|
265
|
+
const result = {};
|
|
266
|
+
|
|
267
|
+
for (const key in obj) {
|
|
268
|
+
const value = obj[key];
|
|
269
|
+
|
|
270
|
+
// Skip system keys
|
|
271
|
+
if (SYSTEM_KEYS.includes(key)) continue;
|
|
272
|
+
if (customFields.includes(key)) continue;
|
|
273
|
+
|
|
274
|
+
// Null or primitive
|
|
275
|
+
if (value === null || typeof value !== 'object') {
|
|
276
|
+
result[key] = value;
|
|
279
277
|
continue;
|
|
280
278
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
279
|
+
|
|
280
|
+
// Array handling
|
|
281
|
+
if (Array.isArray(value)) {
|
|
282
|
+
// Array of objects
|
|
283
|
+
if (value.length > 0 && typeof value[0] === 'object') {
|
|
284
|
+
result[key] = value.map(cleanAndFlatten);
|
|
285
|
+
} else {
|
|
286
|
+
// Array of primitives
|
|
287
|
+
result[key] = value;
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Component (no documentId)
|
|
293
|
+
if (!('documentId' in value)) {
|
|
294
|
+
for (const subKey in value) {
|
|
295
|
+
if (subKey === 'id') continue; // skip id
|
|
296
|
+
result[`${key}_${subKey}`] = value[subKey];
|
|
297
|
+
}
|
|
298
|
+
continue; // skip keeping the original key
|
|
299
|
+
}
|
|
300
|
+
// Relation object (has documentId)
|
|
301
|
+
result[key] = cleanAndFlatten(value);
|
|
286
302
|
}
|
|
303
|
+
return result;
|
|
304
|
+
} else {
|
|
305
|
+
return obj; // primitive
|
|
287
306
|
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
return
|
|
307
|
+
}
|
|
308
|
+
// Example usage
|
|
309
|
+
const cleaned = cleanAndFlatten(entry);
|
|
310
|
+
return cleaned;
|
|
292
311
|
});
|
|
293
312
|
|
|
294
|
-
|
|
313
|
+
function flattenForXLSX(obj) {
|
|
314
|
+
const result = {};
|
|
315
|
+
for (const key in obj) {
|
|
316
|
+
const value = obj[key];
|
|
317
|
+
if (Array.isArray(value)) {
|
|
318
|
+
result[key] = JSON.stringify(value);
|
|
319
|
+
} else {
|
|
320
|
+
result[key] = value;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
const cleanedFlat = cleanedEntries.map(entry => flattenForXLSX(entry));
|
|
326
|
+
const worksheet = XLSX.utils.json_to_sheet(cleanedFlat);
|
|
295
327
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
|
296
328
|
} else {
|
|
297
329
|
// Create empty sheet with headers if no data
|
|
@@ -1,306 +1,358 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
1
|
const XLSX = require('xlsx');
|
|
2
|
+
const fs = require('fs');
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
4
|
+
function toCamel(str) {
|
|
5
|
+
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
6
|
+
}
|
|
7
|
+
const SYSTEM_KEYS = [
|
|
8
|
+
'documentId',
|
|
9
|
+
'locale',
|
|
10
|
+
'createdAt',
|
|
11
|
+
'updatedAt',
|
|
12
|
+
'publishedAt',
|
|
13
|
+
'createdBy',
|
|
14
|
+
'updatedBy',
|
|
15
|
+
'localizations',
|
|
16
|
+
'status'
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
async function importData(file) {
|
|
20
|
+
let result;
|
|
21
|
+
try {
|
|
22
|
+
let importData;
|
|
23
|
+
// Check file extension
|
|
24
|
+
const fileName = file.name || file.originalFilename || 'unknown.json';
|
|
25
|
+
const fileExtension = fileName.split('.').pop().toLowerCase();
|
|
26
|
+
const filePath = file.path || file.filepath;
|
|
27
|
+
if (!filePath) {
|
|
28
|
+
throw new Error('File path not found');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (fileExtension === 'json') {
|
|
32
|
+
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
33
|
+
importData = JSON.parse(fileContent);
|
|
34
|
+
strapi.log.info('Parsed JSON data:', Object.keys(importData));
|
|
35
|
+
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
|
|
36
|
+
importData = transformExcelData(filePath);
|
|
37
|
+
}
|
|
38
|
+
result = await bulkInsertData(importData);
|
|
39
|
+
return result;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// Clean up uploaded file on error
|
|
42
|
+
const filePath = file && (file.path || file.filepath);
|
|
43
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
44
|
+
fs.unlinkSync(filePath);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
function transformExcelData(filePath) {
|
|
52
|
+
const workbook = XLSX.readFile(filePath);
|
|
53
|
+
const importData = {};
|
|
18
54
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
const parseJsonIfNeeded = (value) => {
|
|
56
|
+
if (typeof value !== 'string') return value;
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return value;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(trimmed);
|
|
62
|
+
} catch {
|
|
63
|
+
return value; // keep as string if invalid JSON
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isComponentField = (key) => {
|
|
68
|
+
const parts = key.split('_');
|
|
69
|
+
return parts.length === 2; // exactly one underscore
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function unflattenRow(rows, targetContentType) {
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const rowData = {};
|
|
76
|
+
|
|
77
|
+
for (const [key, value] of Object.entries(row)) {
|
|
78
|
+
if (value === null || value === undefined || value === '') {
|
|
79
|
+
rowData[key] = null
|
|
40
80
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
!['createdAt', 'updatedAt', 'publishedAt'].includes(key) &&
|
|
58
|
-
componentPrefixes.some(prefix => key.startsWith(prefix + '_'));
|
|
59
|
-
|
|
60
|
-
if (shouldUnflatten) {
|
|
61
|
-
// Handle nested objects like corporateInfo_companyName
|
|
62
|
-
const parts = key.split('_');
|
|
63
|
-
let current = result;
|
|
64
|
-
|
|
65
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
66
|
-
if (!current[parts[i]]) {
|
|
67
|
-
current[parts[i]] = {};
|
|
68
|
-
}
|
|
69
|
-
current = current[parts[i]];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
current[parts[parts.length - 1]] = value;
|
|
73
|
-
} else {
|
|
74
|
-
// Handle arrays/JSON strings
|
|
75
|
-
if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
|
|
76
|
-
try {
|
|
77
|
-
result[key] = JSON.parse(value);
|
|
78
|
-
} catch (error) {
|
|
79
|
-
result[key] = value; // Keep as string if parsing fails
|
|
80
|
-
}
|
|
81
|
-
} else {
|
|
82
|
-
result[key] = value;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Debug info removed for cleaner logs
|
|
88
|
-
|
|
89
|
-
return result;
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
importData[contentTypeName] = unflattened;
|
|
93
|
-
strapi.log.info(`Mapped sheet ${sheetName} to ${contentTypeName}`);
|
|
81
|
+
|
|
82
|
+
if (isComponentField(key)) {
|
|
83
|
+
const [comp, field] = key.split('_');
|
|
84
|
+
|
|
85
|
+
if (!rowData[comp]) rowData[comp] = {};
|
|
86
|
+
rowData[comp][field] = parseJsonIfNeeded(value);
|
|
87
|
+
|
|
88
|
+
} else {
|
|
89
|
+
rowData[key] = parseJsonIfNeeded(value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
existedComponents = getComponentFields(targetContentType);
|
|
94
|
+
for (const comp of existedComponents) {
|
|
95
|
+
if (!rowData[comp]) {
|
|
96
|
+
rowData[comp] = {};
|
|
94
97
|
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
} else {
|
|
99
|
-
throw new Error('Unsupported file format. Please use JSON or Excel files.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
result.push(rowData);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
updated: 0,
|
|
105
|
-
errors: [],
|
|
106
|
-
};
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
107
105
|
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
const mapSheetNameToContentType = (sheetName) => {
|
|
107
|
+
if (!sheetName.startsWith('api__')) return sheetName;
|
|
108
|
+
return sheetName.replace(/^api__/, 'api::').replace(/_/g, '.');
|
|
109
|
+
};
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (targetContentType && contentType !== targetContentType) {
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
111
|
+
workbook.SheetNames.forEach(sheetName => {
|
|
112
|
+
const worksheet = workbook.Sheets[sheetName];
|
|
113
|
+
const rows = XLSX.utils.sheet_to_json(worksheet);
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
if (!contentType.startsWith('api::')) {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
115
|
+
if (!rows.length) return;
|
|
122
116
|
|
|
123
|
-
|
|
124
|
-
if (!strapi.contentTypes[contentType]) {
|
|
125
|
-
results.errors.push(`Content type ${contentType} not found`);
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
117
|
+
const contentTypeName = mapSheetNameToContentType(sheetName);
|
|
128
118
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
119
|
+
strapi.log.info(`Reading sheet "${sheetName}" -> ${rows.length} rows`);
|
|
120
|
+
strapi.log.info(`Mapped sheet to content-type: ${contentTypeName}`);
|
|
121
|
+
|
|
122
|
+
if (contentTypeName.startsWith('api::')) {
|
|
123
|
+
importData[contentTypeName] = unflattenRow(rows, contentTypeName);
|
|
124
|
+
} else {
|
|
125
|
+
strapi.log.error(`Unknown content-type: ${contentTypeName}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
strapi.log.info('Final import data keys:', Object.keys(importData));
|
|
131
|
+
return importData;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getRelationFields(contentType) {
|
|
135
|
+
const schema = strapi.contentTypes[contentType];
|
|
136
|
+
|
|
137
|
+
if (!schema) {
|
|
138
|
+
strapi.log.warn(`Content type ${contentType} not found`);
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Object.entries(schema.attributes)
|
|
143
|
+
.filter(([_, attr]) => attr.type === "relation")
|
|
144
|
+
.map(([fieldName, attr]) => ({
|
|
145
|
+
field: toCamel(fieldName),
|
|
146
|
+
target: attr.target, // e.g. "api::category.category"
|
|
147
|
+
relation: attr.relation,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getRelationFieldsStrArr(contentType) {
|
|
152
|
+
const schema = strapi.contentTypes[contentType];
|
|
153
|
+
|
|
154
|
+
if (!schema) {
|
|
155
|
+
strapi.log.warn(`Content type ${contentType} not found`);
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return Object.entries(schema.attributes)
|
|
160
|
+
.filter(([_, attr]) => attr.type === "relation")
|
|
161
|
+
.map(([fieldName, attr]) => toCamel(fieldName));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getComponentFields(contentType) {
|
|
165
|
+
const schema = strapi.contentTypes[contentType];
|
|
133
166
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
});
|
|
174
|
-
if (existingEntry) {
|
|
175
|
-
updateMode = true;
|
|
176
|
-
strapi.log.info(`Found existing entry for update: ${documentId}`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
} catch (error) {
|
|
180
|
-
// Entry not found, will create new one
|
|
181
|
-
strapi.log.info(`DocumentId ${documentId} not found, will create new entry`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// If no documentId provided or not found, this will be a new entry
|
|
186
|
-
if (!existingEntry) {
|
|
187
|
-
strapi.log.info(`Creating new entry for: ${cleanEntry.shortName || cleanEntry.name || cleanEntry.title || 'Unknown'}`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Skip entries without basic required fields
|
|
191
|
-
if (!cleanEntry.shortName && !cleanEntry.name && !cleanEntry.title) {
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Ensure required components exist for corporate
|
|
196
|
-
if (contentType === 'api::corporate.corporate') {
|
|
197
|
-
if (!cleanEntry.corporateInfo) cleanEntry.corporateInfo = {};
|
|
198
|
-
if (!cleanEntry.meetingRequirements) cleanEntry.meetingRequirements = {};
|
|
199
|
-
if (!cleanEntry.internalInfo) cleanEntry.internalInfo = {};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (existingEntry) {
|
|
203
|
-
// Check if there are actual changes before updating
|
|
204
|
-
const hasChanges = this.hasDataChanges(existingEntry, cleanEntry);
|
|
205
|
-
const statusChanged = (status === 'published') !== !!existingEntry.publishedAt;
|
|
206
|
-
|
|
207
|
-
if (hasChanges || statusChanged) {
|
|
208
|
-
try {
|
|
209
|
-
if (strapi.documents) {
|
|
210
|
-
const updateData = {
|
|
211
|
-
documentId: existingEntry.documentId,
|
|
212
|
-
data: cleanEntry,
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
// Handle status change
|
|
216
|
-
if (statusChanged) {
|
|
217
|
-
updateData.status = status === 'published' ? 'published' : 'draft';
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
await strapi.documents(contentType).update(updateData);
|
|
221
|
-
results.updated++;
|
|
222
|
-
} else {
|
|
223
|
-
await strapi.entityService.update(contentType, existingEntry.id, {
|
|
224
|
-
data: cleanEntry,
|
|
225
|
-
});
|
|
226
|
-
results.updated++;
|
|
227
|
-
}
|
|
228
|
-
} catch (updateError) {
|
|
229
|
-
results.errors.push(`Failed to update entry: ${updateError.message}`);
|
|
230
|
-
}
|
|
231
|
-
} else {
|
|
232
|
-
// No changes, skip update
|
|
233
|
-
strapi.log.info(`No changes detected for entry: ${cleanEntry.shortName || 'Unknown'}`);
|
|
234
|
-
}
|
|
235
|
-
} else {
|
|
236
|
-
// Create new entry
|
|
237
|
-
try {
|
|
238
|
-
if (strapi.documents) {
|
|
239
|
-
await strapi.documents(contentType).create({
|
|
240
|
-
data: cleanEntry,
|
|
241
|
-
status: status === 'published' ? 'published' : 'draft',
|
|
242
|
-
});
|
|
243
|
-
results.created++;
|
|
244
|
-
} else {
|
|
245
|
-
await strapi.entityService.create(contentType, {
|
|
246
|
-
data: cleanEntry,
|
|
247
|
-
});
|
|
248
|
-
results.created++;
|
|
249
|
-
}
|
|
250
|
-
} catch (createError) {
|
|
251
|
-
results.errors.push(`Failed to create entry: ${createError.message}`);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
} catch (error) {
|
|
255
|
-
results.errors.push(`Failed to import entry in ${contentType}: ${error.message}`);
|
|
256
|
-
strapi.log.error('Import entry error:', error);
|
|
167
|
+
if (!schema) {
|
|
168
|
+
strapi.log.warn(`Content type ${contentType} not found`);
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Object.entries(schema.attributes)
|
|
173
|
+
.filter(([_, attr]) => attr.type === "component")
|
|
174
|
+
.map(([fieldName, attr]) => toCamel(fieldName));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function handleRelations(entry, contentType) {
|
|
178
|
+
let relationFields = getRelationFields(contentType);
|
|
179
|
+
if (relationFields.length === 0) {
|
|
180
|
+
return entry;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let existing = null;
|
|
184
|
+
const newEntry = { ...entry };
|
|
185
|
+
let isUpdated = false;
|
|
186
|
+
|
|
187
|
+
for (const rel of relationFields) {
|
|
188
|
+
const { field, target } = rel;
|
|
189
|
+
const relValue = entry[field];
|
|
190
|
+
try {
|
|
191
|
+
if (!relValue) continue;
|
|
192
|
+
|
|
193
|
+
if (Array.isArray(relValue)) {
|
|
194
|
+
const processed = [];
|
|
195
|
+
|
|
196
|
+
for (const item of relValue) {
|
|
197
|
+
if (item.id) {
|
|
198
|
+
existing = await strapi.documents(target).findFirst({
|
|
199
|
+
filters: {
|
|
200
|
+
id: { $eq: item.id }
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
if (existing && hasChanges(existing, item, getRelationFieldsStrArr(target))) {
|
|
204
|
+
await strapi.documents(target).update({ documentId: existing.documentId, data: item });
|
|
205
|
+
isUpdated = true;
|
|
257
206
|
}
|
|
207
|
+
processed.push({ id: item.id });
|
|
208
|
+
} else {
|
|
209
|
+
const created = await strapi.documents(target).create({ data: item });
|
|
210
|
+
processed.push({ id: created.id });
|
|
258
211
|
}
|
|
259
|
-
} catch (error) {
|
|
260
|
-
results.errors.push(`Failed to process ${contentType}: ${error.message}`);
|
|
261
|
-
strapi.log.error('Import process error:', error);
|
|
262
212
|
}
|
|
213
|
+
newEntry[field] = processed;
|
|
214
|
+
continue;
|
|
263
215
|
}
|
|
264
216
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
217
|
+
if (!relValue.id) {
|
|
218
|
+
const created = await strapi.documents(target).create({ data: relValue });
|
|
219
|
+
newEntry[field] = { id: created.id };
|
|
220
|
+
} else {
|
|
221
|
+
existing = await strapi.documents(target).findFirst({
|
|
222
|
+
filters: {
|
|
223
|
+
id: { $eq: relValue.id }
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
if (hasChanges(existing, relValue, getRelationFieldsStrArr(target))) {
|
|
227
|
+
await strapi.documents(target).update({ documentId: existing.documentId, data: relValue });
|
|
228
|
+
isUpdated = true;
|
|
229
|
+
}
|
|
230
|
+
newEntry[field] = { id: relValue.id };
|
|
268
231
|
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
throw new Error(`Field: ${field}, data: ${JSON.stringify(relValue)}, error: ${err.message}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
269
236
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
237
|
+
return [newEntry, isUpdated];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hasChanges(existing, incoming, relationFieldStrArr = []) {
|
|
241
|
+
if (!incoming || typeof incoming !== "object") return false;
|
|
242
|
+
|
|
243
|
+
for (const key of Object.keys(incoming)) {
|
|
244
|
+
// Skip system keys
|
|
245
|
+
if (SYSTEM_KEYS.includes(key)) continue;
|
|
246
|
+
|
|
247
|
+
// Skip relation fields entirely
|
|
248
|
+
if (relationFieldStrArr.includes(key)) continue;
|
|
249
|
+
|
|
250
|
+
const newVal = incoming[key];
|
|
251
|
+
const oldVal = existing[key];
|
|
252
|
+
|
|
253
|
+
// If incoming defines a field but existing doesn't → change
|
|
254
|
+
if (oldVal === undefined || newVal === undefined) {
|
|
255
|
+
continue;
|
|
278
256
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const fieldsToCompare = ['shortName', 'name', 'title'];
|
|
284
|
-
|
|
285
|
-
for (const field of fieldsToCompare) {
|
|
286
|
-
if (existingEntry[field] !== newData[field]) {
|
|
257
|
+
|
|
258
|
+
// Primitive comparison
|
|
259
|
+
if (newVal === null || typeof newVal !== "object") {
|
|
260
|
+
if (oldVal !== newVal) {
|
|
287
261
|
return true;
|
|
288
262
|
}
|
|
263
|
+
continue;
|
|
289
264
|
}
|
|
290
|
-
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
265
|
+
|
|
266
|
+
// ARRAY comparison
|
|
267
|
+
if (Array.isArray(newVal)) {
|
|
268
|
+
if (!Array.isArray(oldVal)) return true;
|
|
269
|
+
if (newVal.length !== oldVal.length) return true;
|
|
270
|
+
// Compare values shallowly
|
|
271
|
+
for (let i = 0; i < newVal.length; i++) {
|
|
272
|
+
if (typeof newVal[i] === "object" && typeof oldVal[i] === "object" && hasChanges(oldVal[i], newVal[i])) {
|
|
273
|
+
return true;
|
|
274
|
+
} else if (typeof newVal[i] !== "object" && typeof oldVal[i] !== "object" && newVal[i] !== oldVal[i]) {
|
|
299
275
|
return true;
|
|
300
276
|
}
|
|
301
277
|
}
|
|
278
|
+
continue;
|
|
302
279
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
280
|
+
|
|
281
|
+
// OBJECT comparison (recursive, but ONLY fields in incoming object)
|
|
282
|
+
if (typeof newVal === "object" && typeof oldVal === "object") {
|
|
283
|
+
if (hasChanges(oldVal, newVal)) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async function bulkInsertData(importData) {
|
|
295
|
+
const results = {
|
|
296
|
+
created: 0,
|
|
297
|
+
updated: 0,
|
|
298
|
+
errors: [],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
for (const [contentType, entries] of Object.entries(importData)) {
|
|
302
|
+
// Validate entries
|
|
303
|
+
if (!strapi.contentTypes[contentType]) {
|
|
304
|
+
results.errors.push(`Content type ${contentType} not found`);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (!Array.isArray(entries)) {
|
|
308
|
+
results.errors.push(`Invalid data format for ${contentType}`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (i = 0; i < entries.length; i++) {
|
|
313
|
+
const entry = entries[i];
|
|
314
|
+
let existing = null;
|
|
315
|
+
try {
|
|
316
|
+
let { id, ...data } = entry; // keep id out, keep everything else
|
|
317
|
+
let isUpdated = false;
|
|
318
|
+
let isCreated = false;
|
|
319
|
+
if (id && id !== 'null' && id !== 'undefined') {
|
|
320
|
+
existing = await strapi.documents(contentType).findFirst({
|
|
321
|
+
filters: {
|
|
322
|
+
id: { $eq: id }
|
|
323
|
+
},
|
|
324
|
+
populate: '*'
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
[data, isUpdated] = await handleRelations(data, contentType);
|
|
329
|
+
|
|
330
|
+
if (existing) {
|
|
331
|
+
if (hasChanges(existing, data)) {
|
|
332
|
+
await strapi.documents(contentType).update({
|
|
333
|
+
documentId: existing.documentId,
|
|
334
|
+
data,
|
|
335
|
+
});
|
|
336
|
+
isUpdated = true;
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
await strapi.documents(contentType).create({ data });
|
|
340
|
+
isCreated = true;
|
|
341
|
+
}
|
|
342
|
+
if (isUpdated) {
|
|
343
|
+
results.updated++;
|
|
344
|
+
} else if (isCreated) {
|
|
345
|
+
results.created++;
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
results.errors.push(`Failed ${existing ? 'updating' : 'creating'} on row ${i+2}: ${err.message}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return results;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
importData,
|
|
358
|
+
};
|