sfdx-hardis 6.4.1 → 6.4.3
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 +18 -1
- package/defaults/templates/files/LargeFilesOnly.json +11 -0
- package/lib/commands/hardis/git/pull-requests/extract.js +1 -1
- package/lib/commands/hardis/git/pull-requests/extract.js.map +1 -1
- package/lib/commands/hardis/lint/access.js +1 -1
- package/lib/commands/hardis/lint/access.js.map +1 -1
- package/lib/commands/hardis/lint/metadatastatus.js +1 -1
- package/lib/commands/hardis/lint/metadatastatus.js.map +1 -1
- package/lib/commands/hardis/lint/missingattributes.js +1 -1
- package/lib/commands/hardis/lint/missingattributes.js.map +1 -1
- package/lib/commands/hardis/lint/unusedmetadatas.js +1 -1
- package/lib/commands/hardis/lint/unusedmetadatas.js.map +1 -1
- package/lib/commands/hardis/misc/servicenow-report.js +1 -1
- package/lib/commands/hardis/misc/servicenow-report.js.map +1 -1
- package/lib/commands/hardis/org/configure/files.js +2 -0
- package/lib/commands/hardis/org/configure/files.js.map +1 -1
- package/lib/commands/hardis/org/diagnose/audittrail.js +1 -1
- package/lib/commands/hardis/org/diagnose/audittrail.js.map +1 -1
- package/lib/commands/hardis/org/diagnose/legacyapi.js +2 -2
- package/lib/commands/hardis/org/diagnose/legacyapi.js.map +1 -1
- package/lib/commands/hardis/org/diagnose/licenses.js +1 -1
- package/lib/commands/hardis/org/diagnose/licenses.js.map +1 -1
- package/lib/commands/hardis/org/diagnose/releaseupdates.js +1 -1
- package/lib/commands/hardis/org/diagnose/releaseupdates.js.map +1 -1
- package/lib/commands/hardis/org/diagnose/unused-apex-classes.js +1 -1
- package/lib/commands/hardis/org/diagnose/unused-apex-classes.js.map +1 -1
- package/lib/commands/hardis/org/diagnose/unused-connected-apps.js +1 -1
- package/lib/commands/hardis/org/diagnose/unused-connected-apps.js.map +1 -1
- package/lib/commands/hardis/org/files/export.js +14 -2
- 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/org/monitor/backup.js +1 -1
- package/lib/commands/hardis/org/monitor/backup.js.map +1 -1
- package/lib/commands/hardis/org/monitor/limits.js +1 -1
- package/lib/commands/hardis/org/monitor/limits.js.map +1 -1
- package/lib/commands/hardis/org/multi-org-query.js +1 -1
- package/lib/commands/hardis/org/multi-org-query.js.map +1 -1
- package/lib/commands/hardis/project/audit/callincallout.js +4 -1
- package/lib/commands/hardis/project/audit/callincallout.js.map +1 -1
- package/lib/commands/hardis/project/audit/remotesites.js +4 -1
- package/lib/commands/hardis/project/audit/remotesites.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 +52 -14
- package/lib/common/utils/filesUtils.js +456 -101
- 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 +497 -497
- 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;
|
|
@@ -42,6 +43,7 @@ export class FilesExporter {
|
|
|
42
43
|
filesErrors = 0;
|
|
43
44
|
filesIgnoredType = 0;
|
|
44
45
|
filesIgnoredExisting = 0;
|
|
46
|
+
filesIgnoredSize = 0;
|
|
45
47
|
apiUsedBefore = 0;
|
|
46
48
|
apiLimit = 0;
|
|
47
49
|
constructor(filesPath, conn, options, commandThis) {
|
|
@@ -61,16 +63,109 @@ export class FilesExporter {
|
|
|
61
63
|
if (this.dtl === null) {
|
|
62
64
|
this.dtl = await getFilesWorkspaceDetail(this.filesPath);
|
|
63
65
|
}
|
|
64
|
-
uxLog("action", this.commandThis, c.cyan(`
|
|
66
|
+
uxLog("action", this.commandThis, c.cyan(`Initializing files export using workspace ${c.green(this.dtl.full_label)} ...`));
|
|
65
67
|
uxLog("log", this.commandThis, c.italic(c.grey(this.dtl.description)));
|
|
66
68
|
// Make sure export folder for files is existing
|
|
67
69
|
this.exportedFilesFolder = path.join(this.filesPath, 'export');
|
|
68
70
|
await fs.ensureDir(this.exportedFilesFolder);
|
|
69
71
|
await this.calculateApiConsumption();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
const reportDir = await getReportDirectory();
|
|
73
|
+
const reportExportDir = path.join(reportDir, 'files-export-log');
|
|
74
|
+
const now = new Date();
|
|
75
|
+
const dateStr = now.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '');
|
|
76
|
+
this.logFile = path.join(reportExportDir, `files-export-log-${this.dtl.name}-${dateStr}.csv`);
|
|
77
|
+
// Initialize CSV log file with headers
|
|
78
|
+
await this.initializeCsvLog();
|
|
79
|
+
// Phase 1: Calculate total files count for accurate progress tracking
|
|
80
|
+
uxLog("action", this.commandThis, c.cyan("Estimating total files to download..."));
|
|
81
|
+
const totalFilesCount = await this.calculateTotalFilesCount();
|
|
82
|
+
uxLog("log", this.commandThis, c.grey(`Estimated ${totalFilesCount} files to download`));
|
|
83
|
+
// Phase 2: Process downloads with accurate progress tracking
|
|
84
|
+
await this.processDownloadsWithProgress(totalFilesCount);
|
|
85
|
+
const result = await this.buildResult();
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
// Phase 1: Calculate total files count using efficient COUNT() queries
|
|
89
|
+
async calculateTotalFilesCount() {
|
|
90
|
+
let totalFiles = 0;
|
|
91
|
+
// Get parent records count to estimate batching
|
|
92
|
+
const countSoqlQuery = this.dtl.soqlQuery.replace(/SELECT (.*) FROM/gi, 'SELECT COUNT() FROM');
|
|
93
|
+
this.totalSoqlRequests++;
|
|
94
|
+
const countSoqlQueryRes = await soqlQuery(countSoqlQuery, this.conn);
|
|
95
|
+
const totalParentRecords = countSoqlQueryRes.totalSize;
|
|
96
|
+
// Count Attachments - use COUNT() query with IN clause batching for memory efficiency
|
|
97
|
+
const attachmentBatchSize = 200;
|
|
98
|
+
// Estimate Attachments count by sampling
|
|
99
|
+
const sampleSize = Math.min(attachmentBatchSize, totalParentRecords);
|
|
100
|
+
if (sampleSize > 0) {
|
|
101
|
+
// Get sample of parent IDs
|
|
102
|
+
const sampleQuery = this.dtl.soqlQuery.replace(/SELECT (.*) FROM/gi, 'SELECT Id FROM') + ` LIMIT ${sampleSize}`;
|
|
103
|
+
this.totalSoqlRequests++;
|
|
104
|
+
const sampleParents = await soqlQuery(sampleQuery, this.conn);
|
|
105
|
+
if (sampleParents.records.length > 0) {
|
|
106
|
+
const sampleParentIds = sampleParents.records.map((record) => `'${record.Id}'`).join(',');
|
|
107
|
+
const attachmentCountQuery = `SELECT COUNT() FROM Attachment WHERE ParentId IN (${sampleParentIds})`;
|
|
108
|
+
this.totalSoqlRequests++;
|
|
109
|
+
const attachmentCountRes = await soqlQuery(attachmentCountQuery, this.conn);
|
|
110
|
+
// Extrapolate from sample
|
|
111
|
+
const avgAttachmentsPerRecord = attachmentCountRes.totalSize / sampleParents.records.length;
|
|
112
|
+
totalFiles += Math.round(avgAttachmentsPerRecord * totalParentRecords);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Count ContentVersions - use COUNT() query with sampling for memory efficiency
|
|
116
|
+
if (sampleSize > 0) {
|
|
117
|
+
const sampleQuery = this.dtl.soqlQuery.replace(/SELECT (.*) FROM/gi, 'SELECT Id FROM') + ` LIMIT ${sampleSize}`;
|
|
118
|
+
const sampleParents = await soqlQuery(sampleQuery, this.conn);
|
|
119
|
+
if (sampleParents.records.length > 0) {
|
|
120
|
+
const sampleParentIds = sampleParents.records.map((record) => `'${record.Id}'`).join(',');
|
|
121
|
+
// Count ContentDocumentLinks for sample
|
|
122
|
+
const linkCountQuery = `SELECT COUNT() FROM ContentDocumentLink WHERE LinkedEntityId IN (${sampleParentIds})`;
|
|
123
|
+
this.totalSoqlRequests++;
|
|
124
|
+
const linkCountRes = await soqlQuery(linkCountQuery, this.conn);
|
|
125
|
+
// Extrapolate from sample (ContentVersions ≈ ContentDocumentLinks for latest versions)
|
|
126
|
+
const avgContentVersionsPerRecord = linkCountRes.totalSize / sampleParents.records.length;
|
|
127
|
+
totalFiles += Math.round(avgContentVersionsPerRecord * totalParentRecords);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return Math.max(totalFiles, 1); // Ensure at least 1 for progress tracking
|
|
131
|
+
}
|
|
132
|
+
// Phase 2: Process downloads with accurate file-based progress tracking
|
|
133
|
+
async processDownloadsWithProgress(estimatedFilesCount) {
|
|
134
|
+
let filesProcessed = 0;
|
|
135
|
+
let totalFilesDiscovered = 0; // Track actual files discovered
|
|
136
|
+
let actualTotalFiles = estimatedFilesCount; // Start with estimation, will be adjusted as we discover actual files
|
|
137
|
+
// Start progress tracking with estimated total files count
|
|
138
|
+
WebSocketClient.sendProgressStartMessage('Exporting files', actualTotalFiles);
|
|
139
|
+
// Progress callback function with total adjustment capability
|
|
140
|
+
const progressCallback = (filesCompleted, filesDiscoveredInChunk) => {
|
|
141
|
+
filesProcessed += filesCompleted;
|
|
142
|
+
// If we discovered files in this chunk, update our tracking
|
|
143
|
+
if (filesDiscoveredInChunk !== undefined) {
|
|
144
|
+
totalFilesDiscovered += filesDiscoveredInChunk;
|
|
145
|
+
// Update total to use actual discovered count + remaining estimation
|
|
146
|
+
const processedChunks = this.recordChunksNumber;
|
|
147
|
+
const totalChunks = this.chunksNumber;
|
|
148
|
+
const remainingChunks = totalChunks - processedChunks;
|
|
149
|
+
if (remainingChunks > 0) {
|
|
150
|
+
// Estimate remaining files based on actual discovery rate
|
|
151
|
+
const avgFilesPerChunk = totalFilesDiscovered / processedChunks;
|
|
152
|
+
const estimatedRemainingFiles = Math.round(avgFilesPerChunk * remainingChunks);
|
|
153
|
+
actualTotalFiles = totalFilesDiscovered + estimatedRemainingFiles;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// All chunks processed, use actual total
|
|
157
|
+
actualTotalFiles = totalFilesDiscovered;
|
|
158
|
+
}
|
|
159
|
+
uxLog("other", this, c.grey(`Discovered ${filesDiscoveredInChunk} files in chunk, updated total estimate to ${actualTotalFiles}`));
|
|
160
|
+
}
|
|
161
|
+
WebSocketClient.sendProgressStepMessage(filesProcessed, actualTotalFiles);
|
|
162
|
+
};
|
|
163
|
+
// Use modified queue system with progress tracking
|
|
164
|
+
this.startQueue(progressCallback);
|
|
165
|
+
await this.processParentRecords(progressCallback);
|
|
72
166
|
await this.queueCompleted();
|
|
73
|
-
|
|
167
|
+
// End progress tracking with final total
|
|
168
|
+
WebSocketClient.sendProgressEndMessage(actualTotalFiles);
|
|
74
169
|
}
|
|
75
170
|
// Calculate API consumption
|
|
76
171
|
async calculateApiConsumption() {
|
|
@@ -104,12 +199,15 @@ export class FilesExporter {
|
|
|
104
199
|
}
|
|
105
200
|
}
|
|
106
201
|
// Run chunks one by one, and don't wait to have all the records fetched to start it
|
|
107
|
-
startQueue() {
|
|
202
|
+
startQueue(progressCallback) {
|
|
108
203
|
this.queueInterval = setInterval(async () => {
|
|
109
204
|
if (this.recordsChunkQueueRunning === false && this.recordsChunkQueue.length > 0) {
|
|
110
205
|
this.recordsChunkQueueRunning = true;
|
|
111
|
-
const
|
|
112
|
-
|
|
206
|
+
const queueItem = this.recordsChunkQueue.shift();
|
|
207
|
+
// Handle both old format (array) and new format (object with records and progressCallback)
|
|
208
|
+
const recordChunk = Array.isArray(queueItem) ? queueItem : queueItem.records;
|
|
209
|
+
const chunkProgressCallback = Array.isArray(queueItem) ? progressCallback : queueItem.progressCallback;
|
|
210
|
+
await this.processRecordsChunk(recordChunk, chunkProgressCallback);
|
|
113
211
|
this.recordsChunkQueueRunning = false;
|
|
114
212
|
// Manage last chunk
|
|
115
213
|
}
|
|
@@ -118,7 +216,7 @@ export class FilesExporter {
|
|
|
118
216
|
this.recordsChunk.length > 0) {
|
|
119
217
|
const recordsToProcess = [...this.recordsChunk];
|
|
120
218
|
this.recordsChunk = [];
|
|
121
|
-
this.recordsChunkQueue.push(recordsToProcess);
|
|
219
|
+
this.recordsChunkQueue.push({ records: recordsToProcess, progressCallback });
|
|
122
220
|
}
|
|
123
221
|
}, 1000);
|
|
124
222
|
}
|
|
@@ -142,7 +240,7 @@ export class FilesExporter {
|
|
|
142
240
|
clearInterval(this.queueInterval);
|
|
143
241
|
this.queueInterval = null;
|
|
144
242
|
}
|
|
145
|
-
async processParentRecords() {
|
|
243
|
+
async processParentRecords(progressCallback) {
|
|
146
244
|
// Query parent records using SOQL defined in export.json file
|
|
147
245
|
this.totalSoqlRequests++;
|
|
148
246
|
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 +254,26 @@ export class FilesExporter {
|
|
|
156
254
|
this.recordsIgnored++;
|
|
157
255
|
continue;
|
|
158
256
|
}
|
|
159
|
-
await this.addToRecordsChunk(record);
|
|
257
|
+
await this.addToRecordsChunk(record, progressCallback);
|
|
160
258
|
}
|
|
161
259
|
this.bulkApiRecordsEnded = true;
|
|
162
260
|
}
|
|
163
|
-
async addToRecordsChunk(record) {
|
|
261
|
+
async addToRecordsChunk(record, progressCallback) {
|
|
164
262
|
this.recordsChunk.push(record);
|
|
165
263
|
// If chunk size is reached , process the chunk of records
|
|
166
264
|
if (this.recordsChunk.length === this.recordsChunkSize) {
|
|
167
265
|
const recordsToProcess = [...this.recordsChunk];
|
|
168
266
|
this.recordsChunk = [];
|
|
169
|
-
this.recordsChunkQueue.push(recordsToProcess);
|
|
267
|
+
this.recordsChunkQueue.push({ records: recordsToProcess, progressCallback });
|
|
170
268
|
}
|
|
171
269
|
}
|
|
172
|
-
async processRecordsChunk(records) {
|
|
270
|
+
async processRecordsChunk(records, progressCallback) {
|
|
173
271
|
this.recordChunksNumber++;
|
|
174
272
|
if (this.recordChunksNumber < this.startChunkNumber) {
|
|
175
273
|
uxLog("action", this, c.cyan(`Skip parent records chunk #${this.recordChunksNumber} because it is lesser than ${this.startChunkNumber}`));
|
|
176
274
|
return;
|
|
177
275
|
}
|
|
276
|
+
let actualFilesInChunk = 0;
|
|
178
277
|
uxLog("action", this, c.cyan(`Processing parent records chunk #${this.recordChunksNumber} on ${this.chunksNumber} (${records.length} records) ...`));
|
|
179
278
|
// Process records in batches of 200 for Attachments and 1000 for ContentVersions to avoid hitting the SOQL query limit
|
|
180
279
|
const attachmentBatchSize = 200;
|
|
@@ -183,9 +282,10 @@ export class FilesExporter {
|
|
|
183
282
|
const batch = records.slice(i, i + attachmentBatchSize);
|
|
184
283
|
// Request all Attachment related to all records of the batch using REST API
|
|
185
284
|
const parentIdIn = batch.map((record) => `'${record.Id}'`).join(',');
|
|
186
|
-
const attachmentQuery = `SELECT Id, Name, ContentType, ParentId FROM Attachment WHERE ParentId IN (${parentIdIn})`;
|
|
285
|
+
const attachmentQuery = `SELECT Id, Name, ContentType, ParentId, BodyLength FROM Attachment WHERE ParentId IN (${parentIdIn})`;
|
|
187
286
|
this.totalSoqlRequests++;
|
|
188
287
|
const attachments = await this.conn.query(attachmentQuery);
|
|
288
|
+
actualFilesInChunk += attachments.records.length; // Count actual files discovered
|
|
189
289
|
if (attachments.records.length > 0) {
|
|
190
290
|
// Download attachments using REST API
|
|
191
291
|
await PromisePool.withConcurrency(5)
|
|
@@ -193,10 +293,14 @@ export class FilesExporter {
|
|
|
193
293
|
.process(async (attachment) => {
|
|
194
294
|
try {
|
|
195
295
|
await this.downloadAttachmentFile(attachment, batch);
|
|
296
|
+
// Call progress callback if available
|
|
297
|
+
if (progressCallback) {
|
|
298
|
+
progressCallback(1);
|
|
299
|
+
}
|
|
196
300
|
}
|
|
197
301
|
catch (e) {
|
|
198
302
|
this.filesErrors++;
|
|
199
|
-
uxLog("
|
|
303
|
+
uxLog("warning", this, c.red('Download file error: ' + attachment.Name + '\n' + e));
|
|
200
304
|
}
|
|
201
305
|
});
|
|
202
306
|
}
|
|
@@ -220,7 +324,7 @@ export class FilesExporter {
|
|
|
220
324
|
// Log the progression of contentDocIdBatch
|
|
221
325
|
uxLog("action", this, c.cyan(`Processing ContentDocumentId chunk #${Math.ceil((j + 1) / contentVersionBatchSize)} on ${Math.ceil(contentDocIdIn.length / contentVersionBatchSize)}`));
|
|
222
326
|
// Request all ContentVersion related to all records of the batch
|
|
223
|
-
const contentVersionSoql = `SELECT Id,ContentDocumentId,Description,FileExtension,FileType,PathOnClient,Title FROM ContentVersion WHERE ContentDocumentId IN (${contentDocIdBatch}) AND IsLatest = true`;
|
|
327
|
+
const contentVersionSoql = `SELECT Id,ContentDocumentId,Description,FileExtension,FileType,PathOnClient,Title,ContentSize FROM ContentVersion WHERE ContentDocumentId IN (${contentDocIdBatch}) AND IsLatest = true`;
|
|
224
328
|
this.totalSoqlRequests++;
|
|
225
329
|
const contentVersions = await bulkQueryByChunks(contentVersionSoql, this.conn, this.parentRecordsChunkSize);
|
|
226
330
|
// ContentDocument object can be linked to multiple other objects even with same type (for example: same attachment can be linked to multiple EmailMessage objects).
|
|
@@ -238,6 +342,7 @@ export class FilesExporter {
|
|
|
238
342
|
}
|
|
239
343
|
});
|
|
240
344
|
});
|
|
345
|
+
actualFilesInChunk += versionsAndLinks.length; // Count actual ContentVersion files discovered
|
|
241
346
|
uxLog("log", this, c.grey(`Downloading ${versionsAndLinks.length} found files...`));
|
|
242
347
|
// Download files
|
|
243
348
|
await PromisePool.withConcurrency(5)
|
|
@@ -245,10 +350,14 @@ export class FilesExporter {
|
|
|
245
350
|
.process(async (versionAndLink) => {
|
|
246
351
|
try {
|
|
247
352
|
await this.downloadContentVersionFile(versionAndLink.contentVersion, batch, versionAndLink.contentDocumentLink);
|
|
353
|
+
// Call progress callback if available
|
|
354
|
+
if (progressCallback) {
|
|
355
|
+
progressCallback(1);
|
|
356
|
+
}
|
|
248
357
|
}
|
|
249
358
|
catch (e) {
|
|
250
359
|
this.filesErrors++;
|
|
251
|
-
uxLog("
|
|
360
|
+
uxLog("warning", this, c.red('Download file error: ' + versionAndLink.contentVersion.Title + '\n' + e));
|
|
252
361
|
}
|
|
253
362
|
});
|
|
254
363
|
}
|
|
@@ -257,17 +366,112 @@ export class FilesExporter {
|
|
|
257
366
|
uxLog("log", this, c.grey('No ContentDocumentLinks found for the parent records in this batch'));
|
|
258
367
|
}
|
|
259
368
|
}
|
|
369
|
+
// At the end of chunk processing, report the actual files discovered in this chunk
|
|
370
|
+
if (progressCallback && actualFilesInChunk > 0) {
|
|
371
|
+
// This will help adjust the total progress based on actual discovered files
|
|
372
|
+
progressCallback(0, actualFilesInChunk); // Report actual files found in this chunk
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Initialize CSV log file with headers
|
|
376
|
+
async initializeCsvLog() {
|
|
377
|
+
await fs.ensureDir(path.dirname(this.logFile));
|
|
378
|
+
const headers = 'Status,Folder,File Name,Extension,File Size (KB),Error Detail,ContentDocument Id,ContentVersion Id,Attachment Id\n';
|
|
379
|
+
await fs.writeFile(this.logFile, headers, 'utf8');
|
|
380
|
+
uxLog("log", this, c.grey(`CSV log file initialized: ${this.logFile}`));
|
|
381
|
+
WebSocketClient.sendReportFileMessage(this.logFile, "Exported files report (CSV)", 'report');
|
|
382
|
+
}
|
|
383
|
+
// Helper method to extract file information from output path
|
|
384
|
+
extractFileInfo(outputFile) {
|
|
385
|
+
const fileName = path.basename(outputFile);
|
|
386
|
+
const extension = path.extname(fileName);
|
|
387
|
+
const folderPath = path.dirname(outputFile)
|
|
388
|
+
.replace(process.cwd(), '')
|
|
389
|
+
.replace(this.exportedFilesFolder, '')
|
|
390
|
+
.replace(/\\/g, '/')
|
|
391
|
+
.replace(/^\/+/, '');
|
|
392
|
+
return { fileName, extension, folderPath };
|
|
260
393
|
}
|
|
261
|
-
|
|
394
|
+
// Helper method to log skipped files
|
|
395
|
+
async logSkippedFile(outputFile, errorDetail, contentDocumentId = '', contentVersionId = '', attachmentId = '') {
|
|
396
|
+
const { fileName, extension, folderPath } = this.extractFileInfo(outputFile);
|
|
397
|
+
await this.writeCsvLogEntry('skipped', folderPath, fileName, extension, 0, errorDetail, contentDocumentId, contentVersionId, attachmentId);
|
|
398
|
+
}
|
|
399
|
+
// Write a CSV entry for each file processed (fileSize in KB)
|
|
400
|
+
async writeCsvLogEntry(status, folder, fileName, extension, fileSizeKB, errorDetail = '', contentDocumentId = '', contentVersionId = '', attachmentId = '') {
|
|
401
|
+
try {
|
|
402
|
+
// Escape CSV values to handle commas, quotes, and newlines
|
|
403
|
+
const escapeCsvValue = (value) => {
|
|
404
|
+
const strValue = String(value);
|
|
405
|
+
if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
|
|
406
|
+
return `"${strValue.replace(/"/g, '""')}"`;
|
|
407
|
+
}
|
|
408
|
+
return strValue;
|
|
409
|
+
};
|
|
410
|
+
const csvLine = [
|
|
411
|
+
escapeCsvValue(status),
|
|
412
|
+
escapeCsvValue(folder),
|
|
413
|
+
escapeCsvValue(fileName),
|
|
414
|
+
escapeCsvValue(extension),
|
|
415
|
+
escapeCsvValue(fileSizeKB),
|
|
416
|
+
escapeCsvValue(errorDetail),
|
|
417
|
+
escapeCsvValue(contentDocumentId),
|
|
418
|
+
escapeCsvValue(contentVersionId),
|
|
419
|
+
escapeCsvValue(attachmentId)
|
|
420
|
+
].join(',') + '\n';
|
|
421
|
+
await fs.appendFile(this.logFile, csvLine, 'utf8');
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
uxLog("warning", this, c.yellow(`Error writing to CSV log: ${e.message}`));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async downloadFile(fetchUrl, outputFile, contentDocumentId = '', contentVersionId = '', attachmentId = '') {
|
|
262
428
|
const downloadResult = await new FileDownloader(fetchUrl, { conn: this.conn, outputFile: outputFile, label: 'file' }).download();
|
|
429
|
+
// Extract file information for CSV logging
|
|
430
|
+
const { fileName, extension, folderPath } = this.extractFileInfo(outputFile);
|
|
431
|
+
let fileSizeKB = 0;
|
|
432
|
+
let errorDetail = '';
|
|
433
|
+
// Get file size if download was successful
|
|
434
|
+
if (downloadResult.success && fs.existsSync(outputFile)) {
|
|
435
|
+
try {
|
|
436
|
+
const stats = await fs.stat(outputFile);
|
|
437
|
+
fileSizeKB = Math.round(stats.size / 1024); // Convert bytes to KB
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
uxLog("warning", this, c.yellow(`Could not get file size for ${fileName}: ${e.message}`));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
else if (!downloadResult.success) {
|
|
444
|
+
errorDetail = downloadResult.error || 'Unknown download error';
|
|
445
|
+
}
|
|
446
|
+
// Use file folder and file name for log display
|
|
447
|
+
const fileDisplay = path.join(folderPath, fileName).replace(/\\/g, '/');
|
|
263
448
|
if (downloadResult.success) {
|
|
449
|
+
uxLog("success", this, c.grey(`Downloaded ${fileDisplay}`));
|
|
264
450
|
this.filesDownloaded++;
|
|
451
|
+
// Write success entry to CSV log with Salesforce IDs
|
|
452
|
+
await this.writeCsvLogEntry('success', folderPath, fileName, extension, fileSizeKB, '', contentDocumentId, contentVersionId, attachmentId);
|
|
265
453
|
}
|
|
266
454
|
else {
|
|
455
|
+
uxLog("warning", this, c.red(`Error ${fileDisplay}`));
|
|
267
456
|
this.filesErrors++;
|
|
457
|
+
// Write failed entry to CSV log with Salesforce IDs
|
|
458
|
+
await this.writeCsvLogEntry('failed', folderPath, fileName, extension, fileSizeKB, errorDetail, contentDocumentId, contentVersionId, attachmentId);
|
|
268
459
|
}
|
|
269
460
|
}
|
|
270
461
|
async downloadAttachmentFile(attachment, records) {
|
|
462
|
+
// Check file size filter (BodyLength is in bytes)
|
|
463
|
+
const fileSizeKB = attachment.BodyLength ? Math.round(attachment.BodyLength / 1024) : 0;
|
|
464
|
+
if (this.dtl.fileSizeMin && this.dtl.fileSizeMin > 0 && fileSizeKB < this.dtl.fileSizeMin) {
|
|
465
|
+
uxLog("log", this, c.grey(`Skipped - ${attachment.Name} - File size (${fileSizeKB} KB) below minimum (${this.dtl.fileSizeMin} KB)`));
|
|
466
|
+
this.filesIgnoredSize++;
|
|
467
|
+
// Log skipped file to CSV
|
|
468
|
+
const parentAttachment = records.filter((record) => record.Id === attachment.ParentId)[0];
|
|
469
|
+
const attachmentParentFolderName = (parentAttachment[this.dtl.outputFolderNameField] || parentAttachment.Id).replace(/[/\\?%*:|"<>]/g, '-');
|
|
470
|
+
const parentRecordFolderForFiles = path.resolve(path.join(this.exportedFilesFolder, attachmentParentFolderName));
|
|
471
|
+
const outputFile = path.join(parentRecordFolderForFiles, attachment.Name.replace(/[/\\?%*:|"<>]/g, '-'));
|
|
472
|
+
await this.logSkippedFile(outputFile, `File size (${fileSizeKB} KB) below minimum (${this.dtl.fileSizeMin} KB)`, '', '', attachment.Id);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
271
475
|
// Retrieve initial record to build output files folder name
|
|
272
476
|
const parentAttachment = records.filter((record) => record.Id === attachment.ParentId)[0];
|
|
273
477
|
// Build record output files folder (if folder name contains slashes or antislashes, replace them by spaces)
|
|
@@ -279,9 +483,22 @@ export class FilesExporter {
|
|
|
279
483
|
await fs.ensureDir(parentRecordFolderForFiles);
|
|
280
484
|
// Download file locally
|
|
281
485
|
const fetchUrl = `${this.conn.instanceUrl}/services/data/v${getApiVersion()}/sobjects/Attachment/${attachment.Id}/Body`;
|
|
282
|
-
await this.downloadFile(fetchUrl, outputFile);
|
|
486
|
+
await this.downloadFile(fetchUrl, outputFile, '', '', attachment.Id);
|
|
283
487
|
}
|
|
284
488
|
async downloadContentVersionFile(contentVersion, records, contentDocumentLink) {
|
|
489
|
+
// Check file size filter (ContentSize is in bytes)
|
|
490
|
+
const fileSizeKB = contentVersion.ContentSize ? Math.round(contentVersion.ContentSize / 1024) : 0;
|
|
491
|
+
if (this.dtl.fileSizeMin && this.dtl.fileSizeMin > 0 && fileSizeKB < this.dtl.fileSizeMin) {
|
|
492
|
+
uxLog("log", this, c.grey(`Skipped - ${contentVersion.Title} - File size (${fileSizeKB} KB) below minimum (${this.dtl.fileSizeMin} KB)`));
|
|
493
|
+
this.filesIgnoredSize++;
|
|
494
|
+
// Log skipped file to CSV
|
|
495
|
+
const parentRecord = records.filter((record) => record.Id === contentDocumentLink.LinkedEntityId)[0];
|
|
496
|
+
const parentFolderName = (parentRecord[this.dtl.outputFolderNameField] || parentRecord.Id).replace(/[/\\?%*:|"<>]/g, '-');
|
|
497
|
+
const parentRecordFolderForFiles = path.resolve(path.join(this.exportedFilesFolder, parentFolderName));
|
|
498
|
+
const outputFile = path.join(parentRecordFolderForFiles, contentVersion.Title.replace(/[/\\?%*:|"<>]/g, '-'));
|
|
499
|
+
await this.logSkippedFile(outputFile, `File size (${fileSizeKB} KB) below minimum (${this.dtl.fileSizeMin} KB)`, contentVersion.ContentDocumentId, contentVersion.Id);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
285
502
|
// Retrieve initial record to build output files folder name
|
|
286
503
|
const parentRecord = records.filter((record) => record.Id === contentDocumentLink.LinkedEntityId)[0];
|
|
287
504
|
// Build record output files folder (if folder name contains slashes or antislashes, replace them by spaces)
|
|
@@ -309,19 +526,23 @@ export class FilesExporter {
|
|
|
309
526
|
if (this.dtl.fileTypes !== 'all' && !this.dtl.fileTypes.includes(contentVersion.FileType)) {
|
|
310
527
|
uxLog("log", this, c.grey(`Skipped - ${outputFile.replace(this.exportedFilesFolder, '')} - File type ignored`));
|
|
311
528
|
this.filesIgnoredType++;
|
|
529
|
+
// Log skipped file to CSV
|
|
530
|
+
await this.logSkippedFile(outputFile, 'File type ignored', contentVersion.ContentDocumentId, contentVersion.Id);
|
|
312
531
|
return;
|
|
313
532
|
}
|
|
314
533
|
// Check file overwrite
|
|
315
534
|
if (this.dtl.overwriteFiles !== true && fs.existsSync(outputFile)) {
|
|
316
535
|
uxLog("warning", this, c.yellow(`Skipped - ${outputFile.replace(this.exportedFilesFolder, '')} - File already existing`));
|
|
317
536
|
this.filesIgnoredExisting++;
|
|
537
|
+
// Log skipped file to CSV
|
|
538
|
+
await this.logSkippedFile(outputFile, 'File already exists', contentVersion.ContentDocumentId, contentVersion.Id);
|
|
318
539
|
return;
|
|
319
540
|
}
|
|
320
541
|
// Create directory if not existing
|
|
321
542
|
await fs.ensureDir(parentRecordFolderForFiles);
|
|
322
543
|
// Download file locally
|
|
323
544
|
const fetchUrl = `${this.conn.instanceUrl}/services/data/v${getApiVersion()}/sobjects/ContentVersion/${contentVersion.Id}/VersionData`;
|
|
324
|
-
await this.downloadFile(fetchUrl, outputFile);
|
|
545
|
+
await this.downloadFile(fetchUrl, outputFile, contentVersion.ContentDocumentId, contentVersion.Id);
|
|
325
546
|
}
|
|
326
547
|
// Build stats & result
|
|
327
548
|
async buildResult() {
|
|
@@ -329,31 +550,26 @@ export class FilesExporter {
|
|
|
329
550
|
const apiCallsRemaining = connAny?.limitInfo?.apiUsage?.used
|
|
330
551
|
? (connAny?.limitInfo?.apiUsage?.limit || 0) - (connAny?.limitInfo?.apiUsage?.used || 0)
|
|
331
552
|
: null;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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,
|
|
553
|
+
const result = {
|
|
554
|
+
stats: {
|
|
555
|
+
filesDownloaded: this.filesDownloaded,
|
|
556
|
+
filesErrors: this.filesErrors,
|
|
557
|
+
filesIgnoredType: this.filesIgnoredType,
|
|
558
|
+
filesIgnoredExisting: this.filesIgnoredExisting,
|
|
559
|
+
filesIgnoredSize: this.filesIgnoredSize,
|
|
560
|
+
totalSoqlRequests: this.totalSoqlRequests,
|
|
561
|
+
totalParentRecords: this.totalParentRecords,
|
|
562
|
+
parentRecordsWithFiles: this.parentRecordsWithFiles,
|
|
563
|
+
recordsIgnored: this.recordsIgnored,
|
|
564
|
+
apiLimit: connAny?.limitInfo?.apiUsage?.limit || null,
|
|
565
|
+
apiUsedBefore: this.apiUsedBefore,
|
|
566
|
+
apiUsedAfter: connAny?.limitInfo?.apiUsage?.used || null,
|
|
567
|
+
apiCallsRemaining,
|
|
568
|
+
},
|
|
569
|
+
logFile: this.logFile
|
|
356
570
|
};
|
|
571
|
+
await createXlsxFromCsv(this.logFile, { fileTitle: "Exported files report" }, result);
|
|
572
|
+
return result;
|
|
357
573
|
}
|
|
358
574
|
}
|
|
359
575
|
export class FilesImporter {
|
|
@@ -363,6 +579,16 @@ export class FilesImporter {
|
|
|
363
579
|
dtl = null; // export config
|
|
364
580
|
exportedFilesFolder = '';
|
|
365
581
|
handleOverwrite = false;
|
|
582
|
+
logFile;
|
|
583
|
+
// Statistics tracking
|
|
584
|
+
totalFolders = 0;
|
|
585
|
+
totalFiles = 0;
|
|
586
|
+
filesUploaded = 0;
|
|
587
|
+
filesOverwritten = 0;
|
|
588
|
+
filesErrors = 0;
|
|
589
|
+
filesSkipped = 0;
|
|
590
|
+
apiUsedBefore = 0;
|
|
591
|
+
apiLimit = 0;
|
|
366
592
|
constructor(filesPath, conn, options, commandThis) {
|
|
367
593
|
this.filesPath = filesPath;
|
|
368
594
|
this.exportedFilesFolder = path.join(this.filesPath, 'export');
|
|
@@ -372,6 +598,49 @@ export class FilesImporter {
|
|
|
372
598
|
if (options.exportConfig) {
|
|
373
599
|
this.dtl = options.exportConfig;
|
|
374
600
|
}
|
|
601
|
+
// Initialize log file path
|
|
602
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
603
|
+
this.logFile = path.join(this.filesPath, `import-log-${timestamp}.csv`);
|
|
604
|
+
}
|
|
605
|
+
// Initialize CSV log file with headers
|
|
606
|
+
async initializeCsvLog() {
|
|
607
|
+
await fs.ensureDir(path.dirname(this.logFile));
|
|
608
|
+
const headers = 'Status,Folder,File Name,Extension,File Size (KB),Error Detail,ContentVersion Id\n';
|
|
609
|
+
await fs.writeFile(this.logFile, headers, 'utf8');
|
|
610
|
+
uxLog("log", this.commandThis, c.grey(`CSV log file initialized: ${this.logFile}`));
|
|
611
|
+
WebSocketClient.sendReportFileMessage(this.logFile, "Imported files report (CSV)", 'report');
|
|
612
|
+
}
|
|
613
|
+
// Helper method to extract file information from file path
|
|
614
|
+
extractFileInfo(filePath, folderName) {
|
|
615
|
+
const fileName = path.basename(filePath);
|
|
616
|
+
const extension = path.extname(fileName);
|
|
617
|
+
return { fileName, extension, folderPath: folderName };
|
|
618
|
+
}
|
|
619
|
+
// Write a CSV entry for each file processed (fileSize in KB)
|
|
620
|
+
async writeCsvLogEntry(status, folder, fileName, extension, fileSizeKB, errorDetail = '', contentVersionId = '') {
|
|
621
|
+
try {
|
|
622
|
+
// Escape CSV values to handle commas, quotes, and newlines
|
|
623
|
+
const escapeCsvValue = (value) => {
|
|
624
|
+
const strValue = String(value);
|
|
625
|
+
if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
|
|
626
|
+
return `"${strValue.replace(/"/g, '""')}"`;
|
|
627
|
+
}
|
|
628
|
+
return strValue;
|
|
629
|
+
};
|
|
630
|
+
const csvLine = [
|
|
631
|
+
escapeCsvValue(status),
|
|
632
|
+
escapeCsvValue(folder),
|
|
633
|
+
escapeCsvValue(fileName),
|
|
634
|
+
escapeCsvValue(extension),
|
|
635
|
+
escapeCsvValue(fileSizeKB),
|
|
636
|
+
escapeCsvValue(errorDetail),
|
|
637
|
+
escapeCsvValue(contentVersionId)
|
|
638
|
+
].join(',') + '\n';
|
|
639
|
+
await fs.appendFile(this.logFile, csvLine, 'utf8');
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
uxLog("warning", this.commandThis, c.yellow(`Error writing to CSV log: ${e.message}`));
|
|
643
|
+
}
|
|
375
644
|
}
|
|
376
645
|
async processImport() {
|
|
377
646
|
// Get config
|
|
@@ -384,18 +653,23 @@ export class FilesImporter {
|
|
|
384
653
|
const allRecordFolders = fs.readdirSync(this.exportedFilesFolder).filter((file) => {
|
|
385
654
|
return fs.statSync(path.join(this.exportedFilesFolder, file)).isDirectory();
|
|
386
655
|
});
|
|
387
|
-
|
|
656
|
+
this.totalFolders = allRecordFolders.length;
|
|
657
|
+
// Count total files
|
|
388
658
|
for (const folder of allRecordFolders) {
|
|
389
|
-
|
|
659
|
+
this.totalFiles += fs.readdirSync(path.join(this.exportedFilesFolder, folder)).length;
|
|
390
660
|
}
|
|
391
|
-
|
|
661
|
+
// Initialize API usage tracking with total file count
|
|
662
|
+
await this.calculateApiConsumption(this.totalFiles);
|
|
663
|
+
// Initialize CSV logging
|
|
664
|
+
await this.initializeCsvLog();
|
|
665
|
+
// Start progress tracking
|
|
666
|
+
WebSocketClient.sendProgressStartMessage("Importing files", this.totalFiles);
|
|
392
667
|
// Query parent objects to find Ids corresponding to field value used as folder name
|
|
393
668
|
const parentObjectsRes = await bulkQuery(this.dtl.soqlQuery, this.conn);
|
|
394
669
|
const parentObjects = parentObjectsRes.records;
|
|
395
|
-
let
|
|
396
|
-
let errorNb = 0;
|
|
670
|
+
let processedFiles = 0;
|
|
397
671
|
for (const recordFolder of allRecordFolders) {
|
|
398
|
-
uxLog("log", this, c.grey(`Processing record ${recordFolder} ...`));
|
|
672
|
+
uxLog("log", this.commandThis, c.grey(`Processing record ${recordFolder} ...`));
|
|
399
673
|
const recordFolderPath = path.join(this.exportedFilesFolder, recordFolder);
|
|
400
674
|
// List files in folder
|
|
401
675
|
const files = fs.readdirSync(recordFolderPath).filter((file) => {
|
|
@@ -404,7 +678,18 @@ export class FilesImporter {
|
|
|
404
678
|
// Find Id of parent object using folder name
|
|
405
679
|
const parentRecordIds = parentObjects.filter((parentObj) => parentObj[this.dtl.outputFolderNameField] === recordFolder);
|
|
406
680
|
if (parentRecordIds.length === 0) {
|
|
407
|
-
uxLog("error", this, c.red(`Unable to find Id for ${this.dtl.outputFolderNameField}=${recordFolder}`));
|
|
681
|
+
uxLog("error", this.commandThis, c.red(`Unable to find Id for ${this.dtl.outputFolderNameField}=${recordFolder}`));
|
|
682
|
+
// Log all files in this folder as skipped
|
|
683
|
+
for (const file of files) {
|
|
684
|
+
const { fileName, extension } = this.extractFileInfo(file, recordFolder);
|
|
685
|
+
const filePath = path.join(recordFolderPath, file);
|
|
686
|
+
const fileSizeKB = fs.existsSync(filePath) ? Math.round(fs.statSync(filePath).size / 1024) : 0;
|
|
687
|
+
await this.writeCsvLogEntry('skipped', recordFolder, fileName, extension, fileSizeKB, 'Parent record not found', '');
|
|
688
|
+
this.filesSkipped++;
|
|
689
|
+
processedFiles++;
|
|
690
|
+
// Update progress
|
|
691
|
+
WebSocketClient.sendProgressStepMessage(processedFiles, this.totalFiles);
|
|
692
|
+
}
|
|
408
693
|
continue;
|
|
409
694
|
}
|
|
410
695
|
const parentRecordId = parentRecordIds[0].Id;
|
|
@@ -416,45 +701,101 @@ export class FilesImporter {
|
|
|
416
701
|
existingDocuments = existingDocsQueryRes.records;
|
|
417
702
|
}
|
|
418
703
|
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
|
-
}
|
|
704
|
+
const filePath = path.join(recordFolderPath, file);
|
|
705
|
+
const { fileName, extension } = this.extractFileInfo(file, recordFolder);
|
|
706
|
+
const fileSizeKB = fs.existsSync(filePath) ? Math.round(fs.statSync(filePath).size / 1024) : 0;
|
|
434
707
|
try {
|
|
708
|
+
const fileData = fs.readFileSync(filePath);
|
|
709
|
+
const contentVersionParams = {
|
|
710
|
+
Title: file,
|
|
711
|
+
PathOnClient: file,
|
|
712
|
+
VersionData: fileData.toString('base64'),
|
|
713
|
+
};
|
|
714
|
+
const matchingExistingDocs = existingDocuments.filter((doc) => doc.Title === file);
|
|
715
|
+
let isOverwrite = false;
|
|
716
|
+
if (matchingExistingDocs.length > 0) {
|
|
717
|
+
contentVersionParams.ContentDocumentId = matchingExistingDocs[0].ContentDocumentId;
|
|
718
|
+
uxLog("log", this.commandThis, c.grey(`Overwriting file ${file} ...`));
|
|
719
|
+
isOverwrite = true;
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
contentVersionParams.FirstPublishLocationId = parentRecordId;
|
|
723
|
+
uxLog("log", this.commandThis, c.grey(`Uploading file ${file} ...`));
|
|
724
|
+
}
|
|
435
725
|
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
|
-
|
|
726
|
+
if (Array.isArray(insertResult) && insertResult.length === 0) {
|
|
727
|
+
uxLog("error", this.commandThis, c.red(`Unable to upload file ${file}`));
|
|
728
|
+
await this.writeCsvLogEntry('failed', recordFolder, fileName, extension, fileSizeKB, 'Upload failed', '');
|
|
729
|
+
this.filesErrors++;
|
|
730
|
+
}
|
|
731
|
+
else if (Array.isArray(insertResult) && !insertResult[0].success) {
|
|
732
|
+
uxLog("error", this.commandThis, c.red(`Unable to upload file ${file}`));
|
|
733
|
+
await this.writeCsvLogEntry('failed', recordFolder, fileName, extension, fileSizeKB, insertResult[0].errors?.join(', ') || 'Upload failed', '');
|
|
734
|
+
this.filesErrors++;
|
|
439
735
|
}
|
|
440
736
|
else {
|
|
441
|
-
|
|
737
|
+
// Extract ContentVersion ID from successful insert result
|
|
738
|
+
const contentVersionId = Array.isArray(insertResult) && insertResult.length > 0
|
|
739
|
+
? insertResult[0].id
|
|
740
|
+
: insertResult.id || '';
|
|
741
|
+
if (isOverwrite) {
|
|
742
|
+
uxLog("success", this.commandThis, c.grey(`Overwritten ${file}`));
|
|
743
|
+
await this.writeCsvLogEntry('overwritten', recordFolder, fileName, extension, fileSizeKB, '', contentVersionId);
|
|
744
|
+
this.filesOverwritten++;
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
uxLog("success", this.commandThis, c.grey(`Uploaded ${file}`));
|
|
748
|
+
await this.writeCsvLogEntry('success', recordFolder, fileName, extension, fileSizeKB, '', contentVersionId);
|
|
749
|
+
this.filesUploaded++;
|
|
750
|
+
}
|
|
442
751
|
}
|
|
443
752
|
}
|
|
444
753
|
catch (e) {
|
|
445
|
-
|
|
446
|
-
|
|
754
|
+
const errorDetail = e.message;
|
|
755
|
+
uxLog("error", this.commandThis, c.red(`Unable to upload file ${file}: ${errorDetail}`));
|
|
756
|
+
await this.writeCsvLogEntry('failed', recordFolder, fileName, extension, fileSizeKB, errorDetail, '');
|
|
757
|
+
this.filesErrors++;
|
|
447
758
|
}
|
|
759
|
+
processedFiles++;
|
|
760
|
+
// Update progress
|
|
761
|
+
WebSocketClient.sendProgressStepMessage(processedFiles, this.totalFiles);
|
|
448
762
|
}
|
|
449
763
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
764
|
+
// End progress tracking
|
|
765
|
+
WebSocketClient.sendProgressEndMessage(this.totalFiles);
|
|
766
|
+
// Build and return result
|
|
767
|
+
return await this.buildResult();
|
|
768
|
+
}
|
|
769
|
+
// Build stats & result
|
|
770
|
+
async buildResult() {
|
|
771
|
+
const connAny = this.conn;
|
|
772
|
+
const apiCallsRemaining = connAny?.limitInfo?.apiUsage?.used
|
|
773
|
+
? (connAny?.limitInfo?.apiUsage?.limit || 0) - (connAny?.limitInfo?.apiUsage?.used || 0)
|
|
774
|
+
: null;
|
|
775
|
+
const result = {
|
|
776
|
+
stats: {
|
|
777
|
+
filesUploaded: this.filesUploaded,
|
|
778
|
+
filesOverwritten: this.filesOverwritten,
|
|
779
|
+
filesErrors: this.filesErrors,
|
|
780
|
+
filesSkipped: this.filesSkipped,
|
|
781
|
+
totalFolders: this.totalFolders,
|
|
782
|
+
totalFiles: this.totalFiles,
|
|
783
|
+
apiLimit: this.apiLimit,
|
|
784
|
+
apiUsedBefore: this.apiUsedBefore,
|
|
785
|
+
apiUsedAfter: connAny?.limitInfo?.apiUsage?.used || null,
|
|
786
|
+
apiCallsRemaining,
|
|
787
|
+
},
|
|
788
|
+
logFile: this.logFile
|
|
789
|
+
};
|
|
790
|
+
await createXlsxFromCsv(this.logFile, { fileTitle: "Imported files report" }, result);
|
|
791
|
+
return result;
|
|
455
792
|
}
|
|
456
793
|
// Calculate API consumption
|
|
457
794
|
async calculateApiConsumption(totalFilesNumber) {
|
|
795
|
+
// Track API usage before process
|
|
796
|
+
const connAny = this.conn;
|
|
797
|
+
this.apiUsedBefore = connAny?.limitInfo?.apiUsage?.used || 0;
|
|
798
|
+
this.apiLimit = connAny?.limitInfo?.apiUsage?.limit || 0;
|
|
458
799
|
const bulkCallsNb = 1;
|
|
459
800
|
if (this.handleOverwrite) {
|
|
460
801
|
totalFilesNumber = totalFilesNumber * 2;
|
|
@@ -522,6 +863,7 @@ export async function getFilesWorkspaceDetail(filesWorkspace) {
|
|
|
522
863
|
const outputFileNameFormat = exportFileJson.outputFileNameFormat || 'title';
|
|
523
864
|
const overwriteParentRecords = exportFileJson.overwriteParentRecords === false ? false : exportFileJson.overwriteParentRecords || true;
|
|
524
865
|
const overwriteFiles = exportFileJson.overwriteFiles || false;
|
|
866
|
+
const fileSizeMin = exportFileJson.fileSizeMin || 0;
|
|
525
867
|
return {
|
|
526
868
|
full_label: `[${folderName}]${folderName != hardisLabel ? `: ${hardisLabel}` : ''}`,
|
|
527
869
|
label: hardisLabel,
|
|
@@ -532,6 +874,7 @@ export async function getFilesWorkspaceDetail(filesWorkspace) {
|
|
|
532
874
|
outputFileNameFormat: outputFileNameFormat,
|
|
533
875
|
overwriteParentRecords: overwriteParentRecords,
|
|
534
876
|
overwriteFiles: overwriteFiles,
|
|
877
|
+
fileSizeMin: fileSizeMin,
|
|
535
878
|
};
|
|
536
879
|
}
|
|
537
880
|
export async function promptFilesExportConfiguration(filesExportConfig, override = false) {
|
|
@@ -605,6 +948,15 @@ export async function promptFilesExportConfiguration(filesExportConfig, override
|
|
|
605
948
|
description: 'Replace existing local files with newly downloaded versions',
|
|
606
949
|
initial: filesExportConfig.overwriteFiles,
|
|
607
950
|
},
|
|
951
|
+
{
|
|
952
|
+
type: 'number',
|
|
953
|
+
name: 'fileSizeMin',
|
|
954
|
+
message: 'Please input the minimum file size in KB (0 = no minimum)',
|
|
955
|
+
description: 'Only files with size greater than or equal to this value will be downloaded (in kilobytes)',
|
|
956
|
+
placeholder: 'Ex: 10',
|
|
957
|
+
initial: filesExportConfig.fileSizeMin || 0,
|
|
958
|
+
min: 0,
|
|
959
|
+
},
|
|
608
960
|
]);
|
|
609
961
|
const resp = await prompts(questions);
|
|
610
962
|
const filesConfig = Object.assign(filesExportConfig, {
|
|
@@ -616,6 +968,7 @@ export async function promptFilesExportConfiguration(filesExportConfig, override
|
|
|
616
968
|
outputFileNameFormat: resp.outputFileNameFormat,
|
|
617
969
|
overwriteParentRecords: resp.overwriteParentRecords,
|
|
618
970
|
overwriteFiles: resp.overwriteFiles,
|
|
971
|
+
fileSizeMin: resp.fileSizeMin,
|
|
619
972
|
});
|
|
620
973
|
return filesConfig;
|
|
621
974
|
}
|
|
@@ -695,30 +1048,7 @@ export async function generateCsvFile(data, outputPath, options) {
|
|
|
695
1048
|
const csvFileTitle = options?.fileTitle ? `${options.fileTitle} (CSV)` : options?.csvFileTitle ?? "Report (CSV)";
|
|
696
1049
|
WebSocketClient.sendReportFileMessage(outputPath, csvFileTitle, "report");
|
|
697
1050
|
if (data.length > 0 && !options?.noExcel) {
|
|
698
|
-
|
|
699
|
-
// Generate mirror XSLX file
|
|
700
|
-
const xlsDirName = path.join(path.dirname(outputPath), 'xls');
|
|
701
|
-
const xslFileName = path.basename(outputPath).replace('.csv', '.xlsx');
|
|
702
|
-
const xslxFile = path.join(xlsDirName, xslFileName);
|
|
703
|
-
await fs.ensureDir(xlsDirName);
|
|
704
|
-
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)";
|
|
707
|
-
WebSocketClient.sendReportFileMessage(xslxFile, xlsFileTitle, "report");
|
|
708
|
-
result.xlsxFile = xslxFile;
|
|
709
|
-
if (!isCI && !(process.env.NO_OPEN === 'true') && !WebSocketClient.isAliveWithLwcUI()) {
|
|
710
|
-
try {
|
|
711
|
-
uxLog("other", this, c.italic(c.grey(`Opening XSLX file ${c.bold(xslxFile)}... (define NO_OPEN=true to disable this)`)));
|
|
712
|
-
await open(xslxFile, { wait: false });
|
|
713
|
-
}
|
|
714
|
-
catch (e) {
|
|
715
|
-
uxLog("warning", this, c.yellow('Error while opening XSLX file:\n' + e.message + '\n' + e.stack));
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
catch (e2) {
|
|
720
|
-
uxLog("warning", this, c.yellow('Error while generating XSLX log file:\n' + e2.message + '\n' + e2.stack));
|
|
721
|
-
}
|
|
1051
|
+
await createXlsxFromCsv(outputPath, options, result);
|
|
722
1052
|
}
|
|
723
1053
|
else {
|
|
724
1054
|
uxLog("other", this, c.grey(`No XLS file generated as ${outputPath} is empty`));
|
|
@@ -729,6 +1059,31 @@ export async function generateCsvFile(data, outputPath, options) {
|
|
|
729
1059
|
}
|
|
730
1060
|
return result;
|
|
731
1061
|
}
|
|
1062
|
+
async function createXlsxFromCsv(outputPath, options, result) {
|
|
1063
|
+
try {
|
|
1064
|
+
const xlsDirName = path.join(path.dirname(outputPath), 'xls');
|
|
1065
|
+
const xslFileName = path.basename(outputPath).replace('.csv', '.xlsx');
|
|
1066
|
+
const xslxFile = path.join(xlsDirName, xslFileName);
|
|
1067
|
+
await fs.ensureDir(xlsDirName);
|
|
1068
|
+
await csvToXls(outputPath, xslxFile);
|
|
1069
|
+
uxLog("action", this, c.cyan(c.italic(`Please see detailed XLSX log in ${c.bold(xslxFile)}`)));
|
|
1070
|
+
const xlsFileTitle = options?.fileTitle ? `${options.fileTitle} (XLSX)` : options?.xlsFileTitle ?? "Report (XLSX)";
|
|
1071
|
+
WebSocketClient.sendReportFileMessage(xslxFile, xlsFileTitle, "report");
|
|
1072
|
+
result.xlsxFile = xslxFile;
|
|
1073
|
+
if (!isCI && !(process.env.NO_OPEN === 'true') && !WebSocketClient.isAliveWithLwcUI()) {
|
|
1074
|
+
try {
|
|
1075
|
+
uxLog("other", this, c.italic(c.grey(`Opening XLSX file ${c.bold(xslxFile)}... (define NO_OPEN=true to disable this)`)));
|
|
1076
|
+
await open(xslxFile, { wait: false });
|
|
1077
|
+
}
|
|
1078
|
+
catch (e) {
|
|
1079
|
+
uxLog("warning", this, c.yellow('Error while opening XLSX file:\n' + e.message + '\n' + e.stack));
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
catch (e2) {
|
|
1084
|
+
uxLog("warning", this, c.yellow('Error while generating XLSX log file:\n' + e2.message + '\n' + e2.stack));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
732
1087
|
async function csvToXls(csvFile, xslxFile) {
|
|
733
1088
|
const workbook = new ExcelJS.Workbook();
|
|
734
1089
|
const worksheet = await workbook.csv.readFile(csvFile);
|