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