sfdx-hardis 6.4.1 → 6.4.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.
@@ -34,6 +34,7 @@ export class FilesExporter {
34
34
  queueInterval;
35
35
  bulkApiRecordsEnded = false;
36
36
  recordChunksNumber = 0;
37
+ logFile;
37
38
  totalSoqlRequests = 0;
38
39
  totalParentRecords = 0;
39
40
  parentRecordsWithFiles = 0;
@@ -61,16 +62,109 @@ export class FilesExporter {
61
62
  if (this.dtl === null) {
62
63
  this.dtl = await getFilesWorkspaceDetail(this.filesPath);
63
64
  }
64
- uxLog("action", this.commandThis, c.cyan(`Exporting files from ${c.green(this.dtl.full_label)} ...`));
65
+ uxLog("action", this.commandThis, c.cyan(`Initializing files export using workspace ${c.green(this.dtl.full_label)} ...`));
65
66
  uxLog("log", this.commandThis, c.italic(c.grey(this.dtl.description)));
66
67
  // Make sure export folder for files is existing
67
68
  this.exportedFilesFolder = path.join(this.filesPath, 'export');
68
69
  await fs.ensureDir(this.exportedFilesFolder);
69
70
  await this.calculateApiConsumption();
70
- this.startQueue();
71
- await this.processParentRecords();
71
+ const reportDir = await getReportDirectory();
72
+ const reportExportDir = path.join(reportDir, 'files-export-log');
73
+ const now = new Date();
74
+ const dateStr = now.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '');
75
+ this.logFile = path.join(reportExportDir, `files-export-log-${this.dtl.name}-${dateStr}.csv`);
76
+ // Initialize CSV log file with headers
77
+ await this.initializeCsvLog();
78
+ // Phase 1: Calculate total files count for accurate progress tracking
79
+ uxLog("action", this.commandThis, c.cyan("Estimating total files to download..."));
80
+ const totalFilesCount = await this.calculateTotalFilesCount();
81
+ uxLog("log", this.commandThis, c.grey(`Estimated ${totalFilesCount} files to download`));
82
+ // Phase 2: Process downloads with accurate progress tracking
83
+ await this.processDownloadsWithProgress(totalFilesCount);
84
+ const result = await this.buildResult();
85
+ return result;
86
+ }
87
+ // Phase 1: Calculate total files count using efficient COUNT() queries
88
+ async calculateTotalFilesCount() {
89
+ let totalFiles = 0;
90
+ // Get parent records count to estimate batching
91
+ const countSoqlQuery = this.dtl.soqlQuery.replace(/SELECT (.*) FROM/gi, 'SELECT COUNT() FROM');
92
+ this.totalSoqlRequests++;
93
+ const countSoqlQueryRes = await soqlQuery(countSoqlQuery, this.conn);
94
+ const totalParentRecords = countSoqlQueryRes.totalSize;
95
+ // Count Attachments - use COUNT() query with IN clause batching for memory efficiency
96
+ const attachmentBatchSize = 200;
97
+ // Estimate Attachments count by sampling
98
+ const sampleSize = Math.min(attachmentBatchSize, totalParentRecords);
99
+ if (sampleSize > 0) {
100
+ // Get sample of parent IDs
101
+ const sampleQuery = this.dtl.soqlQuery.replace(/SELECT (.*) FROM/gi, 'SELECT Id FROM') + ` LIMIT ${sampleSize}`;
102
+ this.totalSoqlRequests++;
103
+ const sampleParents = await soqlQuery(sampleQuery, this.conn);
104
+ if (sampleParents.records.length > 0) {
105
+ const sampleParentIds = sampleParents.records.map((record) => `'${record.Id}'`).join(',');
106
+ const attachmentCountQuery = `SELECT COUNT() FROM Attachment WHERE ParentId IN (${sampleParentIds})`;
107
+ this.totalSoqlRequests++;
108
+ const attachmentCountRes = await soqlQuery(attachmentCountQuery, this.conn);
109
+ // Extrapolate from sample
110
+ const avgAttachmentsPerRecord = attachmentCountRes.totalSize / sampleParents.records.length;
111
+ totalFiles += Math.round(avgAttachmentsPerRecord * totalParentRecords);
112
+ }
113
+ }
114
+ // Count ContentVersions - use COUNT() query with sampling for memory efficiency
115
+ if (sampleSize > 0) {
116
+ const sampleQuery = this.dtl.soqlQuery.replace(/SELECT (.*) FROM/gi, 'SELECT Id FROM') + ` LIMIT ${sampleSize}`;
117
+ const sampleParents = await soqlQuery(sampleQuery, this.conn);
118
+ if (sampleParents.records.length > 0) {
119
+ const sampleParentIds = sampleParents.records.map((record) => `'${record.Id}'`).join(',');
120
+ // Count ContentDocumentLinks for sample
121
+ const linkCountQuery = `SELECT COUNT() FROM ContentDocumentLink WHERE LinkedEntityId IN (${sampleParentIds})`;
122
+ this.totalSoqlRequests++;
123
+ const linkCountRes = await soqlQuery(linkCountQuery, this.conn);
124
+ // Extrapolate from sample (ContentVersions ≈ ContentDocumentLinks for latest versions)
125
+ const avgContentVersionsPerRecord = linkCountRes.totalSize / sampleParents.records.length;
126
+ totalFiles += Math.round(avgContentVersionsPerRecord * totalParentRecords);
127
+ }
128
+ }
129
+ return Math.max(totalFiles, 1); // Ensure at least 1 for progress tracking
130
+ }
131
+ // Phase 2: Process downloads with accurate file-based progress tracking
132
+ async processDownloadsWithProgress(estimatedFilesCount) {
133
+ let filesProcessed = 0;
134
+ let totalFilesDiscovered = 0; // Track actual files discovered
135
+ let actualTotalFiles = estimatedFilesCount; // Start with estimation, will be adjusted as we discover actual files
136
+ // Start progress tracking with estimated total files count
137
+ WebSocketClient.sendProgressStartMessage('Exporting files', actualTotalFiles);
138
+ // Progress callback function with total adjustment capability
139
+ const progressCallback = (filesCompleted, filesDiscoveredInChunk) => {
140
+ filesProcessed += filesCompleted;
141
+ // If we discovered files in this chunk, update our tracking
142
+ if (filesDiscoveredInChunk !== undefined) {
143
+ totalFilesDiscovered += filesDiscoveredInChunk;
144
+ // Update total to use actual discovered count + remaining estimation
145
+ const processedChunks = this.recordChunksNumber;
146
+ const totalChunks = this.chunksNumber;
147
+ const remainingChunks = totalChunks - processedChunks;
148
+ if (remainingChunks > 0) {
149
+ // Estimate remaining files based on actual discovery rate
150
+ const avgFilesPerChunk = totalFilesDiscovered / processedChunks;
151
+ const estimatedRemainingFiles = Math.round(avgFilesPerChunk * remainingChunks);
152
+ actualTotalFiles = totalFilesDiscovered + estimatedRemainingFiles;
153
+ }
154
+ else {
155
+ // All chunks processed, use actual total
156
+ actualTotalFiles = totalFilesDiscovered;
157
+ }
158
+ uxLog("other", this, c.grey(`Discovered ${filesDiscoveredInChunk} files in chunk, updated total estimate to ${actualTotalFiles}`));
159
+ }
160
+ WebSocketClient.sendProgressStepMessage(filesProcessed, actualTotalFiles);
161
+ };
162
+ // Use modified queue system with progress tracking
163
+ this.startQueue(progressCallback);
164
+ await this.processParentRecords(progressCallback);
72
165
  await this.queueCompleted();
73
- return await this.buildResult();
166
+ // End progress tracking with final total
167
+ WebSocketClient.sendProgressEndMessage(actualTotalFiles);
74
168
  }
75
169
  // Calculate API consumption
76
170
  async calculateApiConsumption() {
@@ -104,12 +198,15 @@ export class FilesExporter {
104
198
  }
105
199
  }
106
200
  // Run chunks one by one, and don't wait to have all the records fetched to start it
107
- startQueue() {
201
+ startQueue(progressCallback) {
108
202
  this.queueInterval = setInterval(async () => {
109
203
  if (this.recordsChunkQueueRunning === false && this.recordsChunkQueue.length > 0) {
110
204
  this.recordsChunkQueueRunning = true;
111
- const recordChunk = this.recordsChunkQueue.shift();
112
- await this.processRecordsChunk(recordChunk);
205
+ const queueItem = this.recordsChunkQueue.shift();
206
+ // Handle both old format (array) and new format (object with records and progressCallback)
207
+ const recordChunk = Array.isArray(queueItem) ? queueItem : queueItem.records;
208
+ const chunkProgressCallback = Array.isArray(queueItem) ? progressCallback : queueItem.progressCallback;
209
+ await this.processRecordsChunk(recordChunk, chunkProgressCallback);
113
210
  this.recordsChunkQueueRunning = false;
114
211
  // Manage last chunk
115
212
  }
@@ -118,7 +215,7 @@ export class FilesExporter {
118
215
  this.recordsChunk.length > 0) {
119
216
  const recordsToProcess = [...this.recordsChunk];
120
217
  this.recordsChunk = [];
121
- this.recordsChunkQueue.push(recordsToProcess);
218
+ this.recordsChunkQueue.push({ records: recordsToProcess, progressCallback });
122
219
  }
123
220
  }, 1000);
124
221
  }
@@ -142,7 +239,7 @@ export class FilesExporter {
142
239
  clearInterval(this.queueInterval);
143
240
  this.queueInterval = null;
144
241
  }
145
- async processParentRecords() {
242
+ async processParentRecords(progressCallback) {
146
243
  // Query parent records using SOQL defined in export.json file
147
244
  this.totalSoqlRequests++;
148
245
  this.conn.bulk.pollTimeout = this.pollTimeout || 600000; // Increase timeout in case we are on a bad internet connection or if the bulk api batch is queued
@@ -156,25 +253,26 @@ export class FilesExporter {
156
253
  this.recordsIgnored++;
157
254
  continue;
158
255
  }
159
- await this.addToRecordsChunk(record);
256
+ await this.addToRecordsChunk(record, progressCallback);
160
257
  }
161
258
  this.bulkApiRecordsEnded = true;
162
259
  }
163
- async addToRecordsChunk(record) {
260
+ async addToRecordsChunk(record, progressCallback) {
164
261
  this.recordsChunk.push(record);
165
262
  // If chunk size is reached , process the chunk of records
166
263
  if (this.recordsChunk.length === this.recordsChunkSize) {
167
264
  const recordsToProcess = [...this.recordsChunk];
168
265
  this.recordsChunk = [];
169
- this.recordsChunkQueue.push(recordsToProcess);
266
+ this.recordsChunkQueue.push({ records: recordsToProcess, progressCallback });
170
267
  }
171
268
  }
172
- async processRecordsChunk(records) {
269
+ async processRecordsChunk(records, progressCallback) {
173
270
  this.recordChunksNumber++;
174
271
  if (this.recordChunksNumber < this.startChunkNumber) {
175
272
  uxLog("action", this, c.cyan(`Skip parent records chunk #${this.recordChunksNumber} because it is lesser than ${this.startChunkNumber}`));
176
273
  return;
177
274
  }
275
+ let actualFilesInChunk = 0;
178
276
  uxLog("action", this, c.cyan(`Processing parent records chunk #${this.recordChunksNumber} on ${this.chunksNumber} (${records.length} records) ...`));
179
277
  // Process records in batches of 200 for Attachments and 1000 for ContentVersions to avoid hitting the SOQL query limit
180
278
  const attachmentBatchSize = 200;
@@ -186,6 +284,7 @@ export class FilesExporter {
186
284
  const attachmentQuery = `SELECT Id, Name, ContentType, ParentId FROM Attachment WHERE ParentId IN (${parentIdIn})`;
187
285
  this.totalSoqlRequests++;
188
286
  const attachments = await this.conn.query(attachmentQuery);
287
+ actualFilesInChunk += attachments.records.length; // Count actual files discovered
189
288
  if (attachments.records.length > 0) {
190
289
  // Download attachments using REST API
191
290
  await PromisePool.withConcurrency(5)
@@ -193,10 +292,14 @@ export class FilesExporter {
193
292
  .process(async (attachment) => {
194
293
  try {
195
294
  await this.downloadAttachmentFile(attachment, batch);
295
+ // Call progress callback if available
296
+ if (progressCallback) {
297
+ progressCallback(1);
298
+ }
196
299
  }
197
300
  catch (e) {
198
301
  this.filesErrors++;
199
- uxLog("error", this, c.red('Download file error: ' + attachment.Name + '\n' + e));
302
+ uxLog("warning", this, c.red('Download file error: ' + attachment.Name + '\n' + e));
200
303
  }
201
304
  });
202
305
  }
@@ -238,6 +341,7 @@ export class FilesExporter {
238
341
  }
239
342
  });
240
343
  });
344
+ actualFilesInChunk += versionsAndLinks.length; // Count actual ContentVersion files discovered
241
345
  uxLog("log", this, c.grey(`Downloading ${versionsAndLinks.length} found files...`));
242
346
  // Download files
243
347
  await PromisePool.withConcurrency(5)
@@ -245,10 +349,14 @@ export class FilesExporter {
245
349
  .process(async (versionAndLink) => {
246
350
  try {
247
351
  await this.downloadContentVersionFile(versionAndLink.contentVersion, batch, versionAndLink.contentDocumentLink);
352
+ // Call progress callback if available
353
+ if (progressCallback) {
354
+ progressCallback(1);
355
+ }
248
356
  }
249
357
  catch (e) {
250
358
  this.filesErrors++;
251
- uxLog("error", this, c.red('Download file error: ' + versionAndLink.contentVersion.Title + '\n' + e));
359
+ uxLog("warning", this, c.red('Download file error: ' + versionAndLink.contentVersion.Title + '\n' + e));
252
360
  }
253
361
  });
254
362
  }
@@ -257,14 +365,93 @@ export class FilesExporter {
257
365
  uxLog("log", this, c.grey('No ContentDocumentLinks found for the parent records in this batch'));
258
366
  }
259
367
  }
368
+ // At the end of chunk processing, report the actual files discovered in this chunk
369
+ if (progressCallback && actualFilesInChunk > 0) {
370
+ // This will help adjust the total progress based on actual discovered files
371
+ progressCallback(0, actualFilesInChunk); // Report actual files found in this chunk
372
+ }
373
+ }
374
+ // Initialize CSV log file with headers
375
+ async initializeCsvLog() {
376
+ await fs.ensureDir(path.dirname(this.logFile));
377
+ const headers = 'Status,Folder,File Name,Extension,File Size (KB),Error Detail\n';
378
+ await fs.writeFile(this.logFile, headers, 'utf8');
379
+ uxLog("log", this, c.grey(`CSV log file initialized: ${this.logFile}`));
380
+ WebSocketClient.sendReportFileMessage(this.logFile, "Exported files report (CSV)", 'report');
381
+ }
382
+ // Helper method to extract file information from output path
383
+ extractFileInfo(outputFile) {
384
+ const fileName = path.basename(outputFile);
385
+ const extension = path.extname(fileName);
386
+ const folderPath = path.dirname(outputFile)
387
+ .replace(process.cwd(), '')
388
+ .replace(this.exportedFilesFolder, '')
389
+ .replace(/\\/g, '/')
390
+ .replace(/^\/+/, '');
391
+ return { fileName, extension, folderPath };
392
+ }
393
+ // Helper method to log skipped files
394
+ async logSkippedFile(outputFile, errorDetail) {
395
+ const { fileName, extension, folderPath } = this.extractFileInfo(outputFile);
396
+ await this.writeCsvLogEntry('skipped', folderPath, fileName, extension, 0, errorDetail);
397
+ }
398
+ // Write a CSV entry for each file processed (fileSize in KB)
399
+ async writeCsvLogEntry(status, folder, fileName, extension, fileSizeKB, errorDetail = '') {
400
+ try {
401
+ // Escape CSV values to handle commas, quotes, and newlines
402
+ const escapeCsvValue = (value) => {
403
+ const strValue = String(value);
404
+ if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
405
+ return `"${strValue.replace(/"/g, '""')}"`;
406
+ }
407
+ return strValue;
408
+ };
409
+ const csvLine = [
410
+ escapeCsvValue(status),
411
+ escapeCsvValue(folder),
412
+ escapeCsvValue(fileName),
413
+ escapeCsvValue(extension),
414
+ escapeCsvValue(fileSizeKB),
415
+ escapeCsvValue(errorDetail)
416
+ ].join(',') + '\n';
417
+ await fs.appendFile(this.logFile, csvLine, 'utf8');
418
+ }
419
+ catch (e) {
420
+ uxLog("warning", this, c.yellow(`Error writing to CSV log: ${e.message}`));
421
+ }
260
422
  }
261
423
  async downloadFile(fetchUrl, outputFile) {
262
424
  const downloadResult = await new FileDownloader(fetchUrl, { conn: this.conn, outputFile: outputFile, label: 'file' }).download();
425
+ // Extract file information for CSV logging
426
+ const { fileName, extension, folderPath } = this.extractFileInfo(outputFile);
427
+ let fileSizeKB = 0;
428
+ let errorDetail = '';
429
+ // Get file size if download was successful
430
+ if (downloadResult.success && fs.existsSync(outputFile)) {
431
+ try {
432
+ const stats = await fs.stat(outputFile);
433
+ fileSizeKB = Math.round(stats.size / 1024); // Convert bytes to KB
434
+ }
435
+ catch (e) {
436
+ uxLog("warning", this, c.yellow(`Could not get file size for ${fileName}: ${e.message}`));
437
+ }
438
+ }
439
+ else if (!downloadResult.success) {
440
+ errorDetail = downloadResult.error || 'Unknown download error';
441
+ }
442
+ // Use file folder and file name for log display
443
+ const fileDisplay = path.join(folderPath, fileName).replace(/\\/g, '/');
263
444
  if (downloadResult.success) {
445
+ uxLog("success", this, c.grey(`Downloaded ${fileDisplay}`));
264
446
  this.filesDownloaded++;
447
+ // Write success entry to CSV log
448
+ await this.writeCsvLogEntry('success', folderPath, fileName, extension, fileSizeKB);
265
449
  }
266
450
  else {
451
+ uxLog("warning", this, c.red(`Error ${fileDisplay}`));
267
452
  this.filesErrors++;
453
+ // Write failed entry to CSV log
454
+ await this.writeCsvLogEntry('failed', folderPath, fileName, extension, fileSizeKB, errorDetail);
268
455
  }
269
456
  }
270
457
  async downloadAttachmentFile(attachment, records) {
@@ -309,12 +496,16 @@ export class FilesExporter {
309
496
  if (this.dtl.fileTypes !== 'all' && !this.dtl.fileTypes.includes(contentVersion.FileType)) {
310
497
  uxLog("log", this, c.grey(`Skipped - ${outputFile.replace(this.exportedFilesFolder, '')} - File type ignored`));
311
498
  this.filesIgnoredType++;
499
+ // Log skipped file to CSV
500
+ await this.logSkippedFile(outputFile, 'File type ignored');
312
501
  return;
313
502
  }
314
503
  // Check file overwrite
315
504
  if (this.dtl.overwriteFiles !== true && fs.existsSync(outputFile)) {
316
505
  uxLog("warning", this, c.yellow(`Skipped - ${outputFile.replace(this.exportedFilesFolder, '')} - File already existing`));
317
506
  this.filesIgnoredExisting++;
507
+ // Log skipped file to CSV
508
+ await this.logSkippedFile(outputFile, 'File already exists');
318
509
  return;
319
510
  }
320
511
  // Create directory if not existing
@@ -329,30 +520,22 @@ export class FilesExporter {
329
520
  const apiCallsRemaining = connAny?.limitInfo?.apiUsage?.used
330
521
  ? (connAny?.limitInfo?.apiUsage?.limit || 0) - (connAny?.limitInfo?.apiUsage?.used || 0)
331
522
  : null;
332
- uxLog("action", this, c.cyan(`API limit: ${c.bold(connAny?.limitInfo?.apiUsage?.limit || null)}`));
333
- uxLog("action", this, c.cyan(`API used before process: ${c.bold(this.apiUsedBefore)}`));
334
- uxLog("action", this, c.cyan(`API used after process: ${c.bold(connAny?.limitInfo?.apiUsage?.used || null)}`));
335
- uxLog("action", this, c.cyan(`API calls remaining for today: ${c.bold(apiCallsRemaining)}`));
336
- uxLog("action", this, c.cyan(`Total SOQL requests: ${c.bold(this.totalSoqlRequests)}`));
337
- uxLog("action", this, c.cyan(`Total parent records found: ${c.bold(this.totalParentRecords)}`));
338
- uxLog("action", this, c.cyan(`Total parent records with files: ${c.bold(this.parentRecordsWithFiles)}`));
339
- uxLog("action", this, c.cyan(`Total parent records ignored because already existing: ${c.bold(this.recordsIgnored)}`));
340
- uxLog("action", this, c.cyan(`Total files downloaded: ${c.bold(this.filesDownloaded)}`));
341
- uxLog("action", this, c.cyan(`Total file download errors: ${c.bold(this.filesErrors)}`));
342
- uxLog("action", this, c.cyan(`Total file skipped because of type constraint: ${c.bold(this.filesIgnoredType)}`));
343
- uxLog("action", this, c.cyan(`Total file skipped because previously downloaded: ${c.bold(this.filesIgnoredExisting)}`));
344
523
  return {
345
- totalParentRecords: this.totalParentRecords,
346
- parentRecordsWithFiles: this.parentRecordsWithFiles,
347
- filesDownloaded: this.filesDownloaded,
348
- filesErrors: this.filesErrors,
349
- recordsIgnored: this.recordsIgnored,
350
- filesIgnoredType: this.filesIgnoredType,
351
- filesIgnoredExisting: this.filesIgnoredExisting,
352
- apiLimit: connAny?.limitInfo?.apiUsage?.limit || null,
353
- apiUsedBefore: this.apiUsedBefore,
354
- apiUsedAfter: connAny?.limitInfo?.apiUsage?.used || null,
355
- apiCallsRemaining,
524
+ stats: {
525
+ filesDownloaded: this.filesDownloaded,
526
+ filesErrors: this.filesErrors,
527
+ filesIgnoredType: this.filesIgnoredType,
528
+ filesIgnoredExisting: this.filesIgnoredExisting,
529
+ totalSoqlRequests: this.totalSoqlRequests,
530
+ totalParentRecords: this.totalParentRecords,
531
+ parentRecordsWithFiles: this.parentRecordsWithFiles,
532
+ recordsIgnored: this.recordsIgnored,
533
+ apiLimit: connAny?.limitInfo?.apiUsage?.limit || null,
534
+ apiUsedBefore: this.apiUsedBefore,
535
+ apiUsedAfter: connAny?.limitInfo?.apiUsage?.used || null,
536
+ apiCallsRemaining,
537
+ },
538
+ logFile: this.logFile
356
539
  };
357
540
  }
358
541
  }
@@ -363,6 +546,16 @@ export class FilesImporter {
363
546
  dtl = null; // export config
364
547
  exportedFilesFolder = '';
365
548
  handleOverwrite = false;
549
+ logFile;
550
+ // Statistics tracking
551
+ totalFolders = 0;
552
+ totalFiles = 0;
553
+ filesUploaded = 0;
554
+ filesOverwritten = 0;
555
+ filesErrors = 0;
556
+ filesSkipped = 0;
557
+ apiUsedBefore = 0;
558
+ apiLimit = 0;
366
559
  constructor(filesPath, conn, options, commandThis) {
367
560
  this.filesPath = filesPath;
368
561
  this.exportedFilesFolder = path.join(this.filesPath, 'export');
@@ -372,6 +565,48 @@ export class FilesImporter {
372
565
  if (options.exportConfig) {
373
566
  this.dtl = options.exportConfig;
374
567
  }
568
+ // Initialize log file path
569
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
570
+ this.logFile = path.join(this.filesPath, `import-log-${timestamp}.csv`);
571
+ }
572
+ // Initialize CSV log file with headers
573
+ async initializeCsvLog() {
574
+ await fs.ensureDir(path.dirname(this.logFile));
575
+ const headers = 'Status,Folder,File Name,Extension,File Size (KB),Error Detail\n';
576
+ await fs.writeFile(this.logFile, headers, 'utf8');
577
+ uxLog("log", this.commandThis, c.grey(`CSV log file initialized: ${this.logFile}`));
578
+ WebSocketClient.sendReportFileMessage(this.logFile, "Imported files report (CSV)", 'report');
579
+ }
580
+ // Helper method to extract file information from file path
581
+ extractFileInfo(filePath, folderName) {
582
+ const fileName = path.basename(filePath);
583
+ const extension = path.extname(fileName);
584
+ return { fileName, extension, folderPath: folderName };
585
+ }
586
+ // Write a CSV entry for each file processed (fileSize in KB)
587
+ async writeCsvLogEntry(status, folder, fileName, extension, fileSizeKB, errorDetail = '') {
588
+ try {
589
+ // Escape CSV values to handle commas, quotes, and newlines
590
+ const escapeCsvValue = (value) => {
591
+ const strValue = String(value);
592
+ if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
593
+ return `"${strValue.replace(/"/g, '""')}"`;
594
+ }
595
+ return strValue;
596
+ };
597
+ const csvLine = [
598
+ escapeCsvValue(status),
599
+ escapeCsvValue(folder),
600
+ escapeCsvValue(fileName),
601
+ escapeCsvValue(extension),
602
+ escapeCsvValue(fileSizeKB),
603
+ escapeCsvValue(errorDetail)
604
+ ].join(',') + '\n';
605
+ await fs.appendFile(this.logFile, csvLine, 'utf8');
606
+ }
607
+ catch (e) {
608
+ uxLog("warning", this.commandThis, c.yellow(`Error writing to CSV log: ${e.message}`));
609
+ }
375
610
  }
376
611
  async processImport() {
377
612
  // Get config
@@ -384,18 +619,23 @@ export class FilesImporter {
384
619
  const allRecordFolders = fs.readdirSync(this.exportedFilesFolder).filter((file) => {
385
620
  return fs.statSync(path.join(this.exportedFilesFolder, file)).isDirectory();
386
621
  });
387
- let totalFilesNumber = 0;
622
+ this.totalFolders = allRecordFolders.length;
623
+ // Count total files
388
624
  for (const folder of allRecordFolders) {
389
- totalFilesNumber += fs.readdirSync(path.join(this.exportedFilesFolder, folder)).length;
625
+ this.totalFiles += fs.readdirSync(path.join(this.exportedFilesFolder, folder)).length;
390
626
  }
391
- await this.calculateApiConsumption(totalFilesNumber);
627
+ // Initialize API usage tracking with total file count
628
+ await this.calculateApiConsumption(this.totalFiles);
629
+ // Initialize CSV logging
630
+ await this.initializeCsvLog();
631
+ // Start progress tracking
632
+ WebSocketClient.sendProgressStartMessage("Importing files", this.totalFiles);
392
633
  // Query parent objects to find Ids corresponding to field value used as folder name
393
634
  const parentObjectsRes = await bulkQuery(this.dtl.soqlQuery, this.conn);
394
635
  const parentObjects = parentObjectsRes.records;
395
- let successNb = 0;
396
- let errorNb = 0;
636
+ let processedFiles = 0;
397
637
  for (const recordFolder of allRecordFolders) {
398
- uxLog("log", this, c.grey(`Processing record ${recordFolder} ...`));
638
+ uxLog("log", this.commandThis, c.grey(`Processing record ${recordFolder} ...`));
399
639
  const recordFolderPath = path.join(this.exportedFilesFolder, recordFolder);
400
640
  // List files in folder
401
641
  const files = fs.readdirSync(recordFolderPath).filter((file) => {
@@ -404,7 +644,18 @@ export class FilesImporter {
404
644
  // Find Id of parent object using folder name
405
645
  const parentRecordIds = parentObjects.filter((parentObj) => parentObj[this.dtl.outputFolderNameField] === recordFolder);
406
646
  if (parentRecordIds.length === 0) {
407
- uxLog("error", this, c.red(`Unable to find Id for ${this.dtl.outputFolderNameField}=${recordFolder}`));
647
+ uxLog("error", this.commandThis, c.red(`Unable to find Id for ${this.dtl.outputFolderNameField}=${recordFolder}`));
648
+ // Log all files in this folder as skipped
649
+ for (const file of files) {
650
+ const { fileName, extension } = this.extractFileInfo(file, recordFolder);
651
+ const filePath = path.join(recordFolderPath, file);
652
+ const fileSizeKB = fs.existsSync(filePath) ? Math.round(fs.statSync(filePath).size / 1024) : 0;
653
+ await this.writeCsvLogEntry('skipped', recordFolder, fileName, extension, fileSizeKB, 'Parent record not found');
654
+ this.filesSkipped++;
655
+ processedFiles++;
656
+ // Update progress
657
+ WebSocketClient.sendProgressStepMessage(processedFiles, this.totalFiles);
658
+ }
408
659
  continue;
409
660
  }
410
661
  const parentRecordId = parentRecordIds[0].Id;
@@ -416,45 +667,95 @@ export class FilesImporter {
416
667
  existingDocuments = existingDocsQueryRes.records;
417
668
  }
418
669
  for (const file of files) {
419
- const fileData = fs.readFileSync(path.join(recordFolderPath, file));
420
- const contentVersionParams = {
421
- Title: file,
422
- PathOnClient: file,
423
- VersionData: fileData.toString('base64'),
424
- };
425
- const matchingExistingDocs = existingDocuments.filter((doc) => doc.Title === file);
426
- if (matchingExistingDocs.length > 0) {
427
- contentVersionParams.ContentDocumentId = matchingExistingDocs[0].ContentDocumentId;
428
- uxLog("log", this, c.grey(`Overwriting file ${file} ...`));
429
- }
430
- else {
431
- contentVersionParams.FirstPublishLocationId = parentRecordId;
432
- uxLog("log", this, c.grey(`Uploading file ${file} ...`));
433
- }
670
+ const filePath = path.join(recordFolderPath, file);
671
+ const { fileName, extension } = this.extractFileInfo(file, recordFolder);
672
+ const fileSizeKB = fs.existsSync(filePath) ? Math.round(fs.statSync(filePath).size / 1024) : 0;
434
673
  try {
674
+ const fileData = fs.readFileSync(filePath);
675
+ const contentVersionParams = {
676
+ Title: file,
677
+ PathOnClient: file,
678
+ VersionData: fileData.toString('base64'),
679
+ };
680
+ const matchingExistingDocs = existingDocuments.filter((doc) => doc.Title === file);
681
+ let isOverwrite = false;
682
+ if (matchingExistingDocs.length > 0) {
683
+ contentVersionParams.ContentDocumentId = matchingExistingDocs[0].ContentDocumentId;
684
+ uxLog("log", this.commandThis, c.grey(`Overwriting file ${file} ...`));
685
+ isOverwrite = true;
686
+ }
687
+ else {
688
+ contentVersionParams.FirstPublishLocationId = parentRecordId;
689
+ uxLog("log", this.commandThis, c.grey(`Uploading file ${file} ...`));
690
+ }
435
691
  const insertResult = await this.conn.sobject('ContentVersion').create(contentVersionParams);
436
- if (insertResult.length === 0) {
437
- uxLog("error", this, c.red(`Unable to upload file ${file}`));
438
- errorNb++;
692
+ if (Array.isArray(insertResult) && insertResult.length === 0) {
693
+ uxLog("error", this.commandThis, c.red(`Unable to upload file ${file}`));
694
+ await this.writeCsvLogEntry('failed', recordFolder, fileName, extension, fileSizeKB, 'Upload failed');
695
+ this.filesErrors++;
696
+ }
697
+ else if (Array.isArray(insertResult) && !insertResult[0].success) {
698
+ uxLog("error", this.commandThis, c.red(`Unable to upload file ${file}`));
699
+ await this.writeCsvLogEntry('failed', recordFolder, fileName, extension, fileSizeKB, insertResult[0].errors?.join(', ') || 'Upload failed');
700
+ this.filesErrors++;
439
701
  }
440
702
  else {
441
- successNb++;
703
+ if (isOverwrite) {
704
+ uxLog("success", this.commandThis, c.grey(`Overwritten ${file}`));
705
+ await this.writeCsvLogEntry('overwritten', recordFolder, fileName, extension, fileSizeKB);
706
+ this.filesOverwritten++;
707
+ }
708
+ else {
709
+ uxLog("success", this.commandThis, c.grey(`Uploaded ${file}`));
710
+ await this.writeCsvLogEntry('success', recordFolder, fileName, extension, fileSizeKB);
711
+ this.filesUploaded++;
712
+ }
442
713
  }
443
714
  }
444
715
  catch (e) {
445
- uxLog("error", this, c.red(`Unable to upload file ${file}: ${e.message}`));
446
- errorNb++;
716
+ const errorDetail = e.message;
717
+ uxLog("error", this.commandThis, c.red(`Unable to upload file ${file}: ${errorDetail}`));
718
+ await this.writeCsvLogEntry('failed', recordFolder, fileName, extension, fileSizeKB, errorDetail);
719
+ this.filesErrors++;
447
720
  }
721
+ processedFiles++;
722
+ // Update progress
723
+ WebSocketClient.sendProgressStepMessage(processedFiles, this.totalFiles);
448
724
  }
449
725
  }
450
- uxLog("success", this, c.green(`Uploaded ${successNb} files`));
451
- if (errorNb > 0) {
452
- uxLog("warning", this, c.yellow(`Errors during the upload of ${successNb} files`));
453
- }
454
- return { successNb: successNb, errorNb: errorNb };
726
+ // End progress tracking
727
+ WebSocketClient.sendProgressEndMessage(this.totalFiles);
728
+ // Build and return result
729
+ return await this.buildResult();
730
+ }
731
+ // Build stats & result
732
+ async buildResult() {
733
+ const connAny = this.conn;
734
+ const apiCallsRemaining = connAny?.limitInfo?.apiUsage?.used
735
+ ? (connAny?.limitInfo?.apiUsage?.limit || 0) - (connAny?.limitInfo?.apiUsage?.used || 0)
736
+ : null;
737
+ return {
738
+ stats: {
739
+ filesUploaded: this.filesUploaded,
740
+ filesOverwritten: this.filesOverwritten,
741
+ filesErrors: this.filesErrors,
742
+ filesSkipped: this.filesSkipped,
743
+ totalFolders: this.totalFolders,
744
+ totalFiles: this.totalFiles,
745
+ apiLimit: this.apiLimit,
746
+ apiUsedBefore: this.apiUsedBefore,
747
+ apiUsedAfter: connAny?.limitInfo?.apiUsage?.used || null,
748
+ apiCallsRemaining,
749
+ },
750
+ logFile: this.logFile
751
+ };
455
752
  }
456
753
  // Calculate API consumption
457
754
  async calculateApiConsumption(totalFilesNumber) {
755
+ // Track API usage before process
756
+ const connAny = this.conn;
757
+ this.apiUsedBefore = connAny?.limitInfo?.apiUsage?.used || 0;
758
+ this.apiLimit = connAny?.limitInfo?.apiUsage?.limit || 0;
458
759
  const bulkCallsNb = 1;
459
760
  if (this.handleOverwrite) {
460
761
  totalFilesNumber = totalFilesNumber * 2;
@@ -696,28 +997,28 @@ export async function generateCsvFile(data, outputPath, options) {
696
997
  WebSocketClient.sendReportFileMessage(outputPath, csvFileTitle, "report");
697
998
  if (data.length > 0 && !options?.noExcel) {
698
999
  try {
699
- // Generate mirror XSLX file
1000
+ // Generate mirror XLSX file
700
1001
  const xlsDirName = path.join(path.dirname(outputPath), 'xls');
701
1002
  const xslFileName = path.basename(outputPath).replace('.csv', '.xlsx');
702
1003
  const xslxFile = path.join(xlsDirName, xslFileName);
703
1004
  await fs.ensureDir(xlsDirName);
704
1005
  await csvToXls(outputPath, xslxFile);
705
- uxLog("action", this, c.cyan(c.italic(`Please see detailed XSLX log in ${c.bold(xslxFile)}`)));
706
- const xlsFileTitle = options?.fileTitle ? `${options.fileTitle} (XSLX)` : options?.xlsFileTitle ?? "Report (XSLX)";
1006
+ uxLog("action", this, c.cyan(c.italic(`Please see detailed XLSX log in ${c.bold(xslxFile)}`)));
1007
+ const xlsFileTitle = options?.fileTitle ? `${options.fileTitle} (XLSX)` : options?.xlsFileTitle ?? "Report (XLSX)";
707
1008
  WebSocketClient.sendReportFileMessage(xslxFile, xlsFileTitle, "report");
708
1009
  result.xlsxFile = xslxFile;
709
1010
  if (!isCI && !(process.env.NO_OPEN === 'true') && !WebSocketClient.isAliveWithLwcUI()) {
710
1011
  try {
711
- uxLog("other", this, c.italic(c.grey(`Opening XSLX file ${c.bold(xslxFile)}... (define NO_OPEN=true to disable this)`)));
1012
+ uxLog("other", this, c.italic(c.grey(`Opening XLSX file ${c.bold(xslxFile)}... (define NO_OPEN=true to disable this)`)));
712
1013
  await open(xslxFile, { wait: false });
713
1014
  }
714
1015
  catch (e) {
715
- uxLog("warning", this, c.yellow('Error while opening XSLX file:\n' + e.message + '\n' + e.stack));
1016
+ uxLog("warning", this, c.yellow('Error while opening XLSX file:\n' + e.message + '\n' + e.stack));
716
1017
  }
717
1018
  }
718
1019
  }
719
1020
  catch (e2) {
720
- uxLog("warning", this, c.yellow('Error while generating XSLX log file:\n' + e2.message + '\n' + e2.stack));
1021
+ uxLog("warning", this, c.yellow('Error while generating XLSX log file:\n' + e2.message + '\n' + e2.stack));
721
1022
  }
722
1023
  }
723
1024
  else {