@tunghtml/strapi-plugin-export-import-clsx 1.0.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.
@@ -0,0 +1,336 @@
1
+ const XLSX = require('xlsx');
2
+
3
+ module.exports = ({ strapi }) => ({
4
+ async exportData(format = 'json', contentType = null, filters = {}, selectedIds = []) {
5
+ // Get only API content types (collections)
6
+ let contentTypes;
7
+ if (contentType) {
8
+ contentTypes = [contentType];
9
+ } else {
10
+ contentTypes = Object.keys(strapi.contentTypes).filter(
11
+ (key) => key.startsWith('api::')
12
+ );
13
+ }
14
+
15
+ const exportData = {
16
+ version: strapi.config.get('info.strapi'),
17
+ timestamp: new Date().toISOString(),
18
+ data: {},
19
+ };
20
+
21
+ for (const ct of contentTypes) {
22
+ try {
23
+ // Parse filters from URL format
24
+ const parsedFilters = this.parseFilters(filters);
25
+
26
+ strapi.log.info(`Exporting ${ct} with raw filters:`, filters);
27
+ strapi.log.info(`Parsed filters:`, parsedFilters);
28
+ strapi.log.info(`Selected IDs:`, selectedIds);
29
+
30
+ let entries = [];
31
+
32
+ // If specific IDs are selected, export only those
33
+ if (selectedIds && selectedIds.length > 0) {
34
+ try {
35
+ if (strapi.documents) {
36
+ // Get entries by documentId for Strapi v5
37
+ for (const id of selectedIds) {
38
+ try {
39
+ const entry = await strapi.documents(ct).findOne({
40
+ documentId: id,
41
+ populate: '*',
42
+ });
43
+ if (entry) {
44
+ entries.push(entry);
45
+ }
46
+ } catch (error) {
47
+ strapi.log.warn(`Failed to find entry ${id}:`, error.message);
48
+ }
49
+ }
50
+ } else {
51
+ // Fallback for older Strapi versions
52
+ for (const id of selectedIds) {
53
+ try {
54
+ const entry = await strapi.entityService.findOne(ct, id, {
55
+ populate: '*',
56
+ });
57
+ if (entry) {
58
+ entries.push(entry);
59
+ }
60
+ } catch (error) {
61
+ strapi.log.warn(`Failed to find entry ${id}:`, error.message);
62
+ }
63
+ }
64
+ }
65
+ } catch (error) {
66
+ strapi.log.error(`Failed to export selected entries:`, error);
67
+ }
68
+ } else {
69
+ // Export all entries with filters
70
+ try {
71
+ if (strapi.documents) {
72
+ // Get all entries (both published and draft) but avoid duplicates
73
+ const allEntries = await strapi.documents(ct).findMany({
74
+ populate: '*',
75
+ // Don't specify status to get all
76
+ });
77
+
78
+ // Group by documentId and keep only the best version (published > modified draft > draft)
79
+ const uniqueEntries = new Map();
80
+
81
+ for (const entry of allEntries) {
82
+ const docId = entry.documentId;
83
+ const isPublished = !!entry.publishedAt;
84
+ const isModified = entry.updatedAt !== entry.createdAt;
85
+
86
+ if (!uniqueEntries.has(docId)) {
87
+ uniqueEntries.set(docId, entry);
88
+ } else {
89
+ const existing = uniqueEntries.get(docId);
90
+ const existingIsPublished = !!existing.publishedAt;
91
+ const existingIsModified = existing.updatedAt !== existing.createdAt;
92
+
93
+ // Priority: published > modified draft > draft
94
+ if (isPublished && !existingIsPublished) {
95
+ uniqueEntries.set(docId, entry);
96
+ } else if (!isPublished && !existingIsPublished && isModified && !existingIsModified) {
97
+ uniqueEntries.set(docId, entry);
98
+ }
99
+ }
100
+ }
101
+
102
+ entries = Array.from(uniqueEntries.values());
103
+ strapi.log.info(`Found ${allEntries.length} total entries, ${entries.length} unique entries after deduplication`);
104
+
105
+ // Apply filters
106
+ if (parsedFilters && Object.keys(parsedFilters).length > 0) {
107
+ strapi.log.info('Applying filters:', parsedFilters);
108
+ entries = this.applyClientSideFilters(entries, parsedFilters);
109
+ strapi.log.info(`After filtering: ${entries.length} entries`);
110
+ }
111
+ } else {
112
+ // Fallback for older Strapi versions
113
+ entries = await strapi.entityService.findMany(ct, {
114
+ populate: '*',
115
+ filters: parsedFilters,
116
+ });
117
+ strapi.log.info(`EntityService found ${entries?.length || 0} entries`);
118
+ }
119
+ } catch (error) {
120
+ strapi.log.error(`Failed to query entries:`, error);
121
+ }
122
+ }
123
+
124
+ strapi.log.info(`Final result: ${entries?.length || 0} entries for ${ct} (total found: ${entries?.length || 0})`);
125
+
126
+ if (entries && entries.length > 0) {
127
+ exportData.data[ct] = entries;
128
+ }
129
+ } catch (error) {
130
+ strapi.log.error(`Failed to export ${ct}:`, error);
131
+ }
132
+ }
133
+
134
+ if (format === 'excel') {
135
+ return this.convertToExcel(exportData.data);
136
+ }
137
+
138
+ return exportData;
139
+ },
140
+
141
+ parseFilters(filters) {
142
+ const parsed = {};
143
+
144
+ for (const [key, value] of Object.entries(filters)) {
145
+ // Skip pagination and sorting params
146
+ if (['page', 'pageSize', 'sort', 'locale', 'format', 'contentType', 'selectedIds'].includes(key)) {
147
+ continue;
148
+ }
149
+
150
+ // Handle URL encoded filter format like filters[$and][0][shortName][$contains]
151
+ if (key.startsWith('filters[')) {
152
+ // Extract the actual filter structure
153
+ const match = key.match(/filters\[([^\]]+)\](?:\[(\d+)\])?\[([^\]]+)\](?:\[([^\]]+)\])?/);
154
+ if (match) {
155
+ const [, operator, index, field, condition] = match;
156
+
157
+ if (!parsed.filters) parsed.filters = {};
158
+
159
+ if (operator === '$and') {
160
+ if (!parsed.filters.$and) parsed.filters.$and = [];
161
+ const idx = parseInt(index) || 0;
162
+ if (!parsed.filters.$and[idx]) parsed.filters.$and[idx] = {};
163
+
164
+ if (condition) {
165
+ if (!parsed.filters.$and[idx][field]) parsed.filters.$and[idx][field] = {};
166
+ parsed.filters.$and[idx][field][condition] = value;
167
+ } else {
168
+ parsed.filters.$and[idx][field] = value;
169
+ }
170
+ }
171
+ }
172
+ } else {
173
+ parsed[key] = value;
174
+ }
175
+ }
176
+
177
+ return parsed;
178
+ },
179
+
180
+ applyClientSideFilters(entries, filters) {
181
+ if (!filters || Object.keys(filters).length === 0) {
182
+ return entries;
183
+ }
184
+
185
+ const filtered = entries.filter(entry => {
186
+ // Handle structured filters
187
+ if (filters.filters && filters.filters.$and) {
188
+ for (const condition of filters.filters.$and) {
189
+ for (const [field, criteria] of Object.entries(condition)) {
190
+ if (typeof criteria === 'object' && criteria.$contains) {
191
+ // Handle $contains filter
192
+ if (entry[field]) {
193
+ const fieldValue = String(entry[field]).toLowerCase();
194
+ const searchValue = String(criteria.$contains).toLowerCase();
195
+
196
+ if (!fieldValue.includes(searchValue)) {
197
+ return false;
198
+ }
199
+ } else {
200
+ return false; // Field doesn't exist, exclude entry
201
+ }
202
+ } else {
203
+ // Handle exact match
204
+ if (entry[field] !== criteria) {
205
+ return false;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // Handle other filter formats
213
+ for (const [key, value] of Object.entries(filters)) {
214
+ if (key === 'filters') continue; // Already handled above
215
+
216
+ // Handle simple search (global search)
217
+ if (key === '_q' || key === 'search') {
218
+ // Global search across main fields
219
+ const searchFields = ['shortName', 'name', 'title'];
220
+ const searchValue = String(value).toLowerCase();
221
+
222
+ const found = searchFields.some(field => {
223
+ if (entry[field]) {
224
+ return String(entry[field]).toLowerCase().includes(searchValue);
225
+ }
226
+ return false;
227
+ });
228
+
229
+ if (!found) {
230
+ return false;
231
+ }
232
+ }
233
+ }
234
+
235
+ return true;
236
+ });
237
+
238
+ return filtered;
239
+ },
240
+
241
+ convertToExcel(data) {
242
+ const workbook = XLSX.utils.book_new();
243
+ let hasData = false;
244
+
245
+ for (const [contentType, entries] of Object.entries(data)) {
246
+ // Clean sheet name (Excel has restrictions)
247
+ const sheetName = contentType.replace(/[^\w\s]/gi, '_').substring(0, 31);
248
+
249
+ if (entries && entries.length > 0) {
250
+ hasData = true;
251
+
252
+ // Clean and flatten entries for Excel
253
+ const cleanedEntries = entries.map(entry => {
254
+ // Keep important system fields for import
255
+ const {
256
+ createdBy,
257
+ updatedBy,
258
+ localizations,
259
+ ...entryWithSystemFields
260
+ } = entry;
261
+
262
+ const flattened = {
263
+ // Always include these at the beginning for import reference
264
+ id: entry.id,
265
+ documentId: entry.documentId,
266
+ locale: entry.locale || 'en',
267
+ };
268
+
269
+ const flatten = (obj, prefix = '') => {
270
+ for (const key in obj) {
271
+ // Skip already processed system fields and status fields
272
+ if (['id', 'documentId', 'createdBy', 'updatedBy', 'localizations', 'publishedAt', 'status'].includes(key)) {
273
+ continue;
274
+ }
275
+
276
+ if (obj[key] !== null && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
277
+ // Skip nested objects that are system fields
278
+ if (key === 'createdBy' || key === 'updatedBy') {
279
+ continue;
280
+ }
281
+ flatten(obj[key], prefix + key + '_');
282
+ } else if (Array.isArray(obj[key])) {
283
+ flattened[prefix + key] = JSON.stringify(obj[key]);
284
+ } else {
285
+ flattened[prefix + key] = obj[key];
286
+ }
287
+ }
288
+ };
289
+
290
+ flatten(entryWithSystemFields);
291
+ return flattened;
292
+ });
293
+
294
+ const worksheet = XLSX.utils.json_to_sheet(cleanedEntries);
295
+ XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
296
+ } else {
297
+ // Create empty sheet with headers if no data
298
+ const worksheet = XLSX.utils.json_to_sheet([{ message: 'No data found' }]);
299
+ XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
300
+ hasData = true; // Prevent empty workbook error
301
+ }
302
+ }
303
+
304
+ // If still no data, create a default sheet
305
+ if (!hasData) {
306
+ const worksheet = XLSX.utils.json_to_sheet([{ message: 'No data to export' }]);
307
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'NoData');
308
+ }
309
+
310
+ return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
311
+ },
312
+
313
+ async exportSingleEntry(contentType, entryId) {
314
+ try {
315
+ const entry = await strapi.entityService.findOne(contentType, entryId, {
316
+ populate: '*',
317
+ });
318
+
319
+ if (!entry) {
320
+ throw new Error('Entry not found');
321
+ }
322
+
323
+ const exportData = {
324
+ version: strapi.config.get('info.strapi'),
325
+ timestamp: new Date().toISOString(),
326
+ data: {
327
+ [contentType]: [entry]
328
+ },
329
+ };
330
+
331
+ return this.convertToExcel(exportData.data);
332
+ } catch (error) {
333
+ throw error;
334
+ }
335
+ },
336
+ });
@@ -0,0 +1,306 @@
1
+ const fs = require('fs');
2
+ const XLSX = require('xlsx');
3
+
4
+ module.exports = ({ strapi }) => ({
5
+ async importData(file, targetContentType = null) {
6
+ try {
7
+ let importData;
8
+
9
+ // Check file extension
10
+ const fileName = file.name || file.originalFilename || 'unknown.json';
11
+ const fileExtension = fileName.split('.').pop().toLowerCase();
12
+
13
+ const filePath = file.path || file.filepath;
14
+
15
+ if (!filePath) {
16
+ throw new Error('File path not found');
17
+ }
18
+
19
+ if (fileExtension === 'json') {
20
+ const fileContent = fs.readFileSync(filePath, 'utf8');
21
+ importData = JSON.parse(fileContent);
22
+ strapi.log.info('Parsed JSON data:', Object.keys(importData));
23
+ } else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
24
+ const workbook = XLSX.readFile(filePath);
25
+ importData = {};
26
+
27
+ strapi.log.info('Excel sheet names:', workbook.SheetNames);
28
+
29
+ // Convert each sheet to data
30
+ workbook.SheetNames.forEach(sheetName => {
31
+ const worksheet = workbook.Sheets[sheetName];
32
+ const jsonData = XLSX.utils.sheet_to_json(worksheet);
33
+ strapi.log.info(`Sheet ${sheetName} has ${jsonData.length} rows`);
34
+ if (jsonData.length > 0) {
35
+ // Map sheet name back to content type
36
+ let contentTypeName = sheetName;
37
+ if (sheetName.startsWith('api__')) {
38
+ // Convert api__corporate_corporate to api::corporate.corporate
39
+ contentTypeName = sheetName.replace(/^api__/, 'api::').replace(/_/g, '.');
40
+ }
41
+
42
+ // Unflatten Excel data back to nested objects
43
+ const unflattened = jsonData.map((row, index) => {
44
+ const result = {};
45
+
46
+ // Known component prefixes that should be unflattened
47
+ const componentPrefixes = ['corporateInfo', 'meetingRequirements', 'internalInfo'];
48
+
49
+ for (const [key, value] of Object.entries(row)) {
50
+ // Skip completely empty values but keep 0, false, etc.
51
+ if (value === null || value === undefined || value === '') {
52
+ continue;
53
+ }
54
+
55
+ // Check if this key should be unflattened
56
+ const shouldUnflatten = key.includes('_') &&
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}`);
94
+ }
95
+ });
96
+
97
+ strapi.log.info('Final import data keys:', Object.keys(importData));
98
+ } else {
99
+ throw new Error('Unsupported file format. Please use JSON or Excel files.');
100
+ }
101
+
102
+ const results = {
103
+ created: 0,
104
+ updated: 0,
105
+ errors: [],
106
+ };
107
+
108
+ // Handle different data structures
109
+ const dataToProcess = importData.data || importData;
110
+
111
+ for (const [contentType, entries] of Object.entries(dataToProcess)) {
112
+ try {
113
+ // If targetContentType is specified, only process that content type
114
+ if (targetContentType && contentType !== targetContentType) {
115
+ continue;
116
+ }
117
+
118
+ // Skip if not an API content type
119
+ if (!contentType.startsWith('api::')) {
120
+ continue;
121
+ }
122
+
123
+ // Check if content type exists
124
+ if (!strapi.contentTypes[contentType]) {
125
+ results.errors.push(`Content type ${contentType} not found`);
126
+ continue;
127
+ }
128
+
129
+ if (!Array.isArray(entries)) {
130
+ results.errors.push(`Invalid data format for ${contentType}`);
131
+ continue;
132
+ }
133
+
134
+ for (const entry of entries) {
135
+ try {
136
+ // Extract system fields for update/create logic
137
+ const {
138
+ id,
139
+ documentId,
140
+ status,
141
+ createdAt,
142
+ updatedAt,
143
+ publishedAt,
144
+ createdBy,
145
+ updatedBy,
146
+ localizations,
147
+ locale,
148
+ ...cleanEntry
149
+ } = entry;
150
+
151
+ // Skip empty entries
152
+ if (!cleanEntry || Object.keys(cleanEntry).length === 0) {
153
+ strapi.log.warn('Skipping empty entry');
154
+ continue;
155
+ }
156
+
157
+ // Clean up empty string values and convert to null
158
+ for (const [key, value] of Object.entries(cleanEntry)) {
159
+ if (value === '' || value === 'null' || value === 'undefined') {
160
+ cleanEntry[key] = null;
161
+ }
162
+ }
163
+
164
+ let existingEntry = null;
165
+ let updateMode = false;
166
+
167
+ // Only try to find existing entry if documentId is provided and valid
168
+ if (documentId && documentId !== '' && documentId !== 'null' && documentId !== 'undefined') {
169
+ try {
170
+ if (strapi.documents) {
171
+ existingEntry = await strapi.documents(contentType).findOne({
172
+ documentId: documentId,
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);
257
+ }
258
+ }
259
+ } catch (error) {
260
+ results.errors.push(`Failed to process ${contentType}: ${error.message}`);
261
+ strapi.log.error('Import process error:', error);
262
+ }
263
+ }
264
+
265
+ // Clean up uploaded file
266
+ if (filePath && fs.existsSync(filePath)) {
267
+ fs.unlinkSync(filePath);
268
+ }
269
+
270
+ return results;
271
+ } catch (error) {
272
+ // Clean up uploaded file on error
273
+ const filePath = file && (file.path || file.filepath);
274
+ if (filePath && fs.existsSync(filePath)) {
275
+ fs.unlinkSync(filePath);
276
+ }
277
+ throw error;
278
+ }
279
+ },
280
+
281
+ hasDataChanges(existingEntry, newData) {
282
+ // Compare key fields to detect changes
283
+ const fieldsToCompare = ['shortName', 'name', 'title'];
284
+
285
+ for (const field of fieldsToCompare) {
286
+ if (existingEntry[field] !== newData[field]) {
287
+ return true;
288
+ }
289
+ }
290
+
291
+ // Compare nested objects (components)
292
+ const componentsToCompare = ['corporateInfo', 'meetingRequirements', 'internalInfo'];
293
+
294
+ for (const component of componentsToCompare) {
295
+ if (existingEntry[component] && newData[component]) {
296
+ const existingStr = JSON.stringify(existingEntry[component]);
297
+ const newStr = JSON.stringify(newData[component]);
298
+ if (existingStr !== newStr) {
299
+ return true;
300
+ }
301
+ }
302
+ }
303
+
304
+ return false;
305
+ },
306
+ });
@@ -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
+ };