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.
- package/CHANGELOG.md +11 -1
- package/lib/commands/hardis/org/files/export.js +12 -1
- package/lib/commands/hardis/org/files/export.js.map +1 -1
- package/lib/commands/hardis/org/files/import.js +4 -2
- package/lib/commands/hardis/org/files/import.js.map +1 -1
- package/lib/commands/hardis/work/save.js +1 -1
- package/lib/commands/hardis/work/save.js.map +1 -1
- package/lib/common/utils/filesUtils.d.ts +48 -13
- package/lib/common/utils/filesUtils.js +378 -77
- package/lib/common/utils/filesUtils.js.map +1 -1
- package/lib/common/utils/index.d.ts +4 -0
- package/lib/common/utils/index.js +9 -0
- package/lib/common/utils/index.js.map +1 -1
- package/lib/common/websocketClient.d.ts +3 -0
- package/lib/common/websocketClient.js +23 -0
- package/lib/common/websocketClient.js.map +1 -1
- package/oclif.lock +39 -39
- package/oclif.manifest.json +1112 -1112
- package/package.json +6 -6
|
@@ -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(`
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
622
|
+
this.totalFolders = allRecordFolders.length;
|
|
623
|
+
// Count total files
|
|
388
624
|
for (const folder of allRecordFolders) {
|
|
389
|
-
|
|
625
|
+
this.totalFiles += fs.readdirSync(path.join(this.exportedFilesFolder, folder)).length;
|
|
390
626
|
}
|
|
391
|
-
|
|
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
|
|
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
|
|
420
|
-
const
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
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
|
|
706
|
-
const xlsFileTitle = options?.fileTitle ? `${options.fileTitle} (
|
|
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
|
|
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
|
|
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
|
|
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 {
|