sfdx-hardis 6.4.1-beta202509082207.0 → 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 +15 -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/apiUtils.js +8 -3
- package/lib/common/utils/apiUtils.js.map +1 -1
- package/lib/common/utils/filesUtils.d.ts +48 -13
- package/lib/common/utils/filesUtils.js +379 -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 +677 -677
- 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,16 +341,22 @@ export class FilesExporter {
|
|
|
238
341
|
}
|
|
239
342
|
});
|
|
240
343
|
});
|
|
344
|
+
actualFilesInChunk += versionsAndLinks.length; // Count actual ContentVersion files discovered
|
|
345
|
+
uxLog("log", this, c.grey(`Downloading ${versionsAndLinks.length} found files...`));
|
|
241
346
|
// Download files
|
|
242
347
|
await PromisePool.withConcurrency(5)
|
|
243
348
|
.for(versionsAndLinks)
|
|
244
349
|
.process(async (versionAndLink) => {
|
|
245
350
|
try {
|
|
246
351
|
await this.downloadContentVersionFile(versionAndLink.contentVersion, batch, versionAndLink.contentDocumentLink);
|
|
352
|
+
// Call progress callback if available
|
|
353
|
+
if (progressCallback) {
|
|
354
|
+
progressCallback(1);
|
|
355
|
+
}
|
|
247
356
|
}
|
|
248
357
|
catch (e) {
|
|
249
358
|
this.filesErrors++;
|
|
250
|
-
uxLog("
|
|
359
|
+
uxLog("warning", this, c.red('Download file error: ' + versionAndLink.contentVersion.Title + '\n' + e));
|
|
251
360
|
}
|
|
252
361
|
});
|
|
253
362
|
}
|
|
@@ -256,14 +365,93 @@ export class FilesExporter {
|
|
|
256
365
|
uxLog("log", this, c.grey('No ContentDocumentLinks found for the parent records in this batch'));
|
|
257
366
|
}
|
|
258
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
|
+
}
|
|
259
422
|
}
|
|
260
423
|
async downloadFile(fetchUrl, outputFile) {
|
|
261
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, '/');
|
|
262
444
|
if (downloadResult.success) {
|
|
445
|
+
uxLog("success", this, c.grey(`Downloaded ${fileDisplay}`));
|
|
263
446
|
this.filesDownloaded++;
|
|
447
|
+
// Write success entry to CSV log
|
|
448
|
+
await this.writeCsvLogEntry('success', folderPath, fileName, extension, fileSizeKB);
|
|
264
449
|
}
|
|
265
450
|
else {
|
|
451
|
+
uxLog("warning", this, c.red(`Error ${fileDisplay}`));
|
|
266
452
|
this.filesErrors++;
|
|
453
|
+
// Write failed entry to CSV log
|
|
454
|
+
await this.writeCsvLogEntry('failed', folderPath, fileName, extension, fileSizeKB, errorDetail);
|
|
267
455
|
}
|
|
268
456
|
}
|
|
269
457
|
async downloadAttachmentFile(attachment, records) {
|
|
@@ -308,12 +496,16 @@ export class FilesExporter {
|
|
|
308
496
|
if (this.dtl.fileTypes !== 'all' && !this.dtl.fileTypes.includes(contentVersion.FileType)) {
|
|
309
497
|
uxLog("log", this, c.grey(`Skipped - ${outputFile.replace(this.exportedFilesFolder, '')} - File type ignored`));
|
|
310
498
|
this.filesIgnoredType++;
|
|
499
|
+
// Log skipped file to CSV
|
|
500
|
+
await this.logSkippedFile(outputFile, 'File type ignored');
|
|
311
501
|
return;
|
|
312
502
|
}
|
|
313
503
|
// Check file overwrite
|
|
314
504
|
if (this.dtl.overwriteFiles !== true && fs.existsSync(outputFile)) {
|
|
315
505
|
uxLog("warning", this, c.yellow(`Skipped - ${outputFile.replace(this.exportedFilesFolder, '')} - File already existing`));
|
|
316
506
|
this.filesIgnoredExisting++;
|
|
507
|
+
// Log skipped file to CSV
|
|
508
|
+
await this.logSkippedFile(outputFile, 'File already exists');
|
|
317
509
|
return;
|
|
318
510
|
}
|
|
319
511
|
// Create directory if not existing
|
|
@@ -328,30 +520,22 @@ export class FilesExporter {
|
|
|
328
520
|
const apiCallsRemaining = connAny?.limitInfo?.apiUsage?.used
|
|
329
521
|
? (connAny?.limitInfo?.apiUsage?.limit || 0) - (connAny?.limitInfo?.apiUsage?.used || 0)
|
|
330
522
|
: null;
|
|
331
|
-
uxLog("action", this, c.cyan(`API limit: ${c.bold(connAny?.limitInfo?.apiUsage?.limit || null)}`));
|
|
332
|
-
uxLog("action", this, c.cyan(`API used before process: ${c.bold(this.apiUsedBefore)}`));
|
|
333
|
-
uxLog("action", this, c.cyan(`API used after process: ${c.bold(connAny?.limitInfo?.apiUsage?.used || null)}`));
|
|
334
|
-
uxLog("action", this, c.cyan(`API calls remaining for today: ${c.bold(apiCallsRemaining)}`));
|
|
335
|
-
uxLog("action", this, c.cyan(`Total SOQL requests: ${c.bold(this.totalSoqlRequests)}`));
|
|
336
|
-
uxLog("action", this, c.cyan(`Total parent records found: ${c.bold(this.totalParentRecords)}`));
|
|
337
|
-
uxLog("action", this, c.cyan(`Total parent records with files: ${c.bold(this.parentRecordsWithFiles)}`));
|
|
338
|
-
uxLog("action", this, c.cyan(`Total parent records ignored because already existing: ${c.bold(this.recordsIgnored)}`));
|
|
339
|
-
uxLog("action", this, c.cyan(`Total files downloaded: ${c.bold(this.filesDownloaded)}`));
|
|
340
|
-
uxLog("action", this, c.cyan(`Total file download errors: ${c.bold(this.filesErrors)}`));
|
|
341
|
-
uxLog("action", this, c.cyan(`Total file skipped because of type constraint: ${c.bold(this.filesIgnoredType)}`));
|
|
342
|
-
uxLog("action", this, c.cyan(`Total file skipped because previously downloaded: ${c.bold(this.filesIgnoredExisting)}`));
|
|
343
523
|
return {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
355
539
|
};
|
|
356
540
|
}
|
|
357
541
|
}
|
|
@@ -362,6 +546,16 @@ export class FilesImporter {
|
|
|
362
546
|
dtl = null; // export config
|
|
363
547
|
exportedFilesFolder = '';
|
|
364
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;
|
|
365
559
|
constructor(filesPath, conn, options, commandThis) {
|
|
366
560
|
this.filesPath = filesPath;
|
|
367
561
|
this.exportedFilesFolder = path.join(this.filesPath, 'export');
|
|
@@ -371,6 +565,48 @@ export class FilesImporter {
|
|
|
371
565
|
if (options.exportConfig) {
|
|
372
566
|
this.dtl = options.exportConfig;
|
|
373
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
|
+
}
|
|
374
610
|
}
|
|
375
611
|
async processImport() {
|
|
376
612
|
// Get config
|
|
@@ -383,18 +619,23 @@ export class FilesImporter {
|
|
|
383
619
|
const allRecordFolders = fs.readdirSync(this.exportedFilesFolder).filter((file) => {
|
|
384
620
|
return fs.statSync(path.join(this.exportedFilesFolder, file)).isDirectory();
|
|
385
621
|
});
|
|
386
|
-
|
|
622
|
+
this.totalFolders = allRecordFolders.length;
|
|
623
|
+
// Count total files
|
|
387
624
|
for (const folder of allRecordFolders) {
|
|
388
|
-
|
|
625
|
+
this.totalFiles += fs.readdirSync(path.join(this.exportedFilesFolder, folder)).length;
|
|
389
626
|
}
|
|
390
|
-
|
|
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);
|
|
391
633
|
// Query parent objects to find Ids corresponding to field value used as folder name
|
|
392
634
|
const parentObjectsRes = await bulkQuery(this.dtl.soqlQuery, this.conn);
|
|
393
635
|
const parentObjects = parentObjectsRes.records;
|
|
394
|
-
let
|
|
395
|
-
let errorNb = 0;
|
|
636
|
+
let processedFiles = 0;
|
|
396
637
|
for (const recordFolder of allRecordFolders) {
|
|
397
|
-
uxLog("log", this, c.grey(`Processing record ${recordFolder} ...`));
|
|
638
|
+
uxLog("log", this.commandThis, c.grey(`Processing record ${recordFolder} ...`));
|
|
398
639
|
const recordFolderPath = path.join(this.exportedFilesFolder, recordFolder);
|
|
399
640
|
// List files in folder
|
|
400
641
|
const files = fs.readdirSync(recordFolderPath).filter((file) => {
|
|
@@ -403,7 +644,18 @@ export class FilesImporter {
|
|
|
403
644
|
// Find Id of parent object using folder name
|
|
404
645
|
const parentRecordIds = parentObjects.filter((parentObj) => parentObj[this.dtl.outputFolderNameField] === recordFolder);
|
|
405
646
|
if (parentRecordIds.length === 0) {
|
|
406
|
-
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
|
+
}
|
|
407
659
|
continue;
|
|
408
660
|
}
|
|
409
661
|
const parentRecordId = parentRecordIds[0].Id;
|
|
@@ -415,45 +667,95 @@ export class FilesImporter {
|
|
|
415
667
|
existingDocuments = existingDocsQueryRes.records;
|
|
416
668
|
}
|
|
417
669
|
for (const file of files) {
|
|
418
|
-
const
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
PathOnClient: file,
|
|
422
|
-
VersionData: fileData.toString('base64'),
|
|
423
|
-
};
|
|
424
|
-
const matchingExistingDocs = existingDocuments.filter((doc) => doc.Title === file);
|
|
425
|
-
if (matchingExistingDocs.length > 0) {
|
|
426
|
-
contentVersionParams.ContentDocumentId = matchingExistingDocs[0].ContentDocumentId;
|
|
427
|
-
uxLog("log", this, c.grey(`Overwriting file ${file} ...`));
|
|
428
|
-
}
|
|
429
|
-
else {
|
|
430
|
-
contentVersionParams.FirstPublishLocationId = parentRecordId;
|
|
431
|
-
uxLog("log", this, c.grey(`Uploading file ${file} ...`));
|
|
432
|
-
}
|
|
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;
|
|
433
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
|
+
}
|
|
434
691
|
const insertResult = await this.conn.sobject('ContentVersion').create(contentVersionParams);
|
|
435
|
-
if (insertResult.length === 0) {
|
|
436
|
-
uxLog("error", this, c.red(`Unable to upload file ${file}`));
|
|
437
|
-
|
|
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++;
|
|
438
701
|
}
|
|
439
702
|
else {
|
|
440
|
-
|
|
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
|
+
}
|
|
441
713
|
}
|
|
442
714
|
}
|
|
443
715
|
catch (e) {
|
|
444
|
-
|
|
445
|
-
|
|
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++;
|
|
446
720
|
}
|
|
721
|
+
processedFiles++;
|
|
722
|
+
// Update progress
|
|
723
|
+
WebSocketClient.sendProgressStepMessage(processedFiles, this.totalFiles);
|
|
447
724
|
}
|
|
448
725
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
+
};
|
|
454
752
|
}
|
|
455
753
|
// Calculate API consumption
|
|
456
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;
|
|
457
759
|
const bulkCallsNb = 1;
|
|
458
760
|
if (this.handleOverwrite) {
|
|
459
761
|
totalFilesNumber = totalFilesNumber * 2;
|
|
@@ -695,28 +997,28 @@ export async function generateCsvFile(data, outputPath, options) {
|
|
|
695
997
|
WebSocketClient.sendReportFileMessage(outputPath, csvFileTitle, "report");
|
|
696
998
|
if (data.length > 0 && !options?.noExcel) {
|
|
697
999
|
try {
|
|
698
|
-
// Generate mirror
|
|
1000
|
+
// Generate mirror XLSX file
|
|
699
1001
|
const xlsDirName = path.join(path.dirname(outputPath), 'xls');
|
|
700
1002
|
const xslFileName = path.basename(outputPath).replace('.csv', '.xlsx');
|
|
701
1003
|
const xslxFile = path.join(xlsDirName, xslFileName);
|
|
702
1004
|
await fs.ensureDir(xlsDirName);
|
|
703
1005
|
await csvToXls(outputPath, xslxFile);
|
|
704
|
-
uxLog("action", this, c.cyan(c.italic(`Please see detailed
|
|
705
|
-
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)";
|
|
706
1008
|
WebSocketClient.sendReportFileMessage(xslxFile, xlsFileTitle, "report");
|
|
707
1009
|
result.xlsxFile = xslxFile;
|
|
708
1010
|
if (!isCI && !(process.env.NO_OPEN === 'true') && !WebSocketClient.isAliveWithLwcUI()) {
|
|
709
1011
|
try {
|
|
710
|
-
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)`)));
|
|
711
1013
|
await open(xslxFile, { wait: false });
|
|
712
1014
|
}
|
|
713
1015
|
catch (e) {
|
|
714
|
-
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));
|
|
715
1017
|
}
|
|
716
1018
|
}
|
|
717
1019
|
}
|
|
718
1020
|
catch (e2) {
|
|
719
|
-
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));
|
|
720
1022
|
}
|
|
721
1023
|
}
|
|
722
1024
|
else {
|