@arela/uploader 1.0.20 โ†’ 1.0.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arela/uploader",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "CLI to upload files/directories to Arela",
5
5
  "bin": {
6
6
  "arela": "./src/index.js"
@@ -39,6 +39,7 @@
39
39
  "form-data": "4.0.4",
40
40
  "formdata-node": "^6.0.3",
41
41
  "globby": "14.1.0",
42
+ "googleapis": "^171.4.0",
42
43
  "ioredis": "^5.10.0",
43
44
  "mime-types": "3.0.1",
44
45
  "node-fetch": "3.3.2",
@@ -0,0 +1,164 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import { DatastageApiService } from '../services/DatastageApiService.js';
5
+ import logger from '../services/LoggingService.js';
6
+
7
+ import appConfig from '../config/config.js';
8
+ import ErrorHandler from '../errors/ErrorHandler.js';
9
+
10
+ /**
11
+ * Datastage Command Handler
12
+ * Uploads monthly Datastage *.zip files from a directory to the API.
13
+ * Sequential, idempotent via the cli `datastage_uploads` tracking table.
14
+ */
15
+ export class DatastageCommand {
16
+ constructor() {
17
+ this.errorHandler = new ErrorHandler(logger);
18
+ }
19
+
20
+ /**
21
+ * @param {Object} options
22
+ * @param {string} options.dir - directory containing *.zip files (required)
23
+ * @param {string} [options.api] - 'default'|'agencia'|'cliente'
24
+ * @param {boolean} [options.retryFailed] - re-attempt files in 'failed' status
25
+ * @param {boolean} [options.showStats] - print final stats from API
26
+ */
27
+ async execute(options = {}) {
28
+ const startTime = Date.now();
29
+
30
+ if (!options.dir) {
31
+ throw new Error('--dir <path> is required');
32
+ }
33
+ const sourceDirectory = path.resolve(options.dir);
34
+ if (!fs.existsSync(sourceDirectory)) {
35
+ throw new Error(`Directory not found: ${sourceDirectory}`);
36
+ }
37
+ const dirStat = fs.statSync(sourceDirectory);
38
+ if (!dirStat.isDirectory()) {
39
+ throw new Error(`Not a directory: ${sourceDirectory}`);
40
+ }
41
+
42
+ const apiTarget = options.api || 'default';
43
+ const api = new DatastageApiService(apiTarget);
44
+
45
+ logger.info('๐Ÿ“ฆ Starting arela datastage command');
46
+ logger.info(`๐ŸŽฏ API Target: ${apiTarget}`);
47
+ logger.info(`๐Ÿ“‚ Source: ${sourceDirectory}`);
48
+
49
+ // 1. Enumerate *.zip in root directory (non-recursive)
50
+ const entries = fs.readdirSync(sourceDirectory, { withFileTypes: true });
51
+ const zipFiles = entries
52
+ .filter((e) => e.isFile() && /\.zip$/i.test(e.name))
53
+ .map((e) => path.join(sourceDirectory, e.name));
54
+
55
+ if (zipFiles.length === 0) {
56
+ logger.warn('No *.zip files found in directory. Nothing to do.');
57
+ return { uploaded: 0, failed: 0, skipped: 0 };
58
+ }
59
+ logger.info(`๐Ÿ—‚ Found ${zipFiles.length} zip file(s)`);
60
+
61
+ // 2. Register each file (idempotent upsert)
62
+ logger.info('๐Ÿ“ Registering files...');
63
+ for (const zipPath of zipFiles) {
64
+ const stats = fs.statSync(zipPath);
65
+ try {
66
+ await api.registerUpload({
67
+ absolutePath: zipPath,
68
+ fileName: path.basename(zipPath),
69
+ sizeBytes: stats.size,
70
+ fileModifiedAt: stats.mtime.toISOString(),
71
+ sourceDirectory,
72
+ });
73
+ } catch (err) {
74
+ logger.error(
75
+ ` โœ— register failed for ${path.basename(zipPath)}: ${err.message}`,
76
+ );
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ // 3. Fetch pending list scoped to this directory
82
+ const pending = await api.getPending(sourceDirectory);
83
+ const pendingPaths = new Set(pending.map((p) => p.absolutePath));
84
+
85
+ const alreadyUploaded = zipFiles.length - pendingPaths.size;
86
+ if (alreadyUploaded > 0) {
87
+ logger.info(`โญ Skipping ${alreadyUploaded} already uploaded file(s)`);
88
+ }
89
+
90
+ if (pending.length === 0) {
91
+ logger.success('โœ… All files already uploaded. Nothing to do.');
92
+ if (options.showStats) {
93
+ const s = await api.getStats(sourceDirectory);
94
+ logger.info(`๐Ÿ“Š Stats: ${JSON.stringify(s)}`);
95
+ }
96
+ return { uploaded: 0, failed: 0, skipped: alreadyUploaded };
97
+ }
98
+
99
+ // 4. Sequential upload loop
100
+ logger.info(`๐Ÿš€ Uploading ${pending.length} file(s) sequentially...`);
101
+ let uploaded = 0;
102
+ let failed = 0;
103
+
104
+ for (let i = 0; i < pending.length; i++) {
105
+ const row = pending[i];
106
+ const localPath = row.absolutePath;
107
+ const label = `[${i + 1}/${pending.length}] ${row.fileName}`;
108
+
109
+ if (!fs.existsSync(localPath)) {
110
+ const err = `File missing on disk: ${localPath}`;
111
+ logger.error(`โœ— ${label}: ${err}`);
112
+ try {
113
+ await api.markFailed(row.id, err);
114
+ } catch (e) {
115
+ logger.error(` mark-failed error: ${e.message}`);
116
+ }
117
+ failed++;
118
+ continue;
119
+ }
120
+
121
+ try {
122
+ logger.info(`โฌ† ${label}: uploading...`);
123
+ const result = await api.uploadZip(localPath);
124
+ const datastageId = result?.id || result?.data?.id;
125
+ const folio = result?.folio || result?.data?.folio;
126
+ if (!datastageId) {
127
+ throw new Error(
128
+ 'API returned no datastage id in response: ' +
129
+ JSON.stringify(result).slice(0, 300),
130
+ );
131
+ }
132
+ await api.markUploaded(row.id, { datastageId, folio });
133
+ logger.success(
134
+ `โœ“ ${label}: folio=${folio || 'n/a'} datastageId=${datastageId}`,
135
+ );
136
+ uploaded++;
137
+ } catch (err) {
138
+ logger.error(`โœ— ${label}: ${err.message}`);
139
+ try {
140
+ await api.markFailed(row.id, err.message);
141
+ } catch (e) {
142
+ logger.error(` mark-failed error: ${e.message}`);
143
+ }
144
+ failed++;
145
+ }
146
+ }
147
+
148
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
149
+ logger.info('โ€”'.repeat(60));
150
+ logger.info(
151
+ `Done in ${elapsed}s โ€” uploaded=${uploaded} failed=${failed} skipped=${alreadyUploaded}`,
152
+ );
153
+
154
+ if (options.showStats) {
155
+ const s = await api.getStats(sourceDirectory);
156
+ logger.info(`๐Ÿ“Š Final stats: ${JSON.stringify(s)}`);
157
+ }
158
+
159
+ return { uploaded, failed, skipped: alreadyUploaded };
160
+ }
161
+ }
162
+
163
+ const datastageCommand = new DatastageCommand();
164
+ export default datastageCommand;
@@ -0,0 +1,475 @@
1
+ import cliProgress from 'cli-progress';
2
+ import fs from 'fs';
3
+ import fsp from 'fs/promises';
4
+ import pLimit from 'p-limit';
5
+ import path from 'path';
6
+
7
+ import GoogleDriveService from '../services/GoogleDriveService.js';
8
+ import logger from '../services/LoggingService.js';
9
+
10
+ import appConfig from '../config/config.js';
11
+ import ErrorHandler from '../errors/ErrorHandler.js';
12
+ import { FileSanitizer } from '../utils/FileSanitizer.js';
13
+
14
+ const STATE_FILENAME = '.gdrive-sync-state.json';
15
+ const STATE_VERSION = 1;
16
+
17
+ /**
18
+ * GDrive Sync Command
19
+ *
20
+ * Mirrors a Google Drive folder tree to a local directory so the existing
21
+ * scan โ†’ identify โ†’ propagate โ†’ push pipeline can run unchanged.
22
+ *
23
+ * Idempotent & incremental: maintains a `.gdrive-sync-state.json` at the
24
+ * mirror root with per-file md5/modifiedTime so re-runs only download
25
+ * changed/new files.
26
+ */
27
+ export class GDriveSyncCommand {
28
+ constructor() {
29
+ this.errorHandler = new ErrorHandler(logger);
30
+ this.driveService = null;
31
+ this.sanitizer = new FileSanitizer();
32
+ this.onProgress = null;
33
+ }
34
+
35
+ /**
36
+ * Execute the gdrive-sync command
37
+ * @param {Object} [options]
38
+ * @param {string} [options.rootFolder] - override GDRIVE_ROOT_FOLDER_ID
39
+ * @param {string} [options.dest] - override local mirror path
40
+ * @param {boolean} [options.full] - ignore state file and re-verify everything
41
+ * @param {boolean} [options.dryRun] - list/plan only, no downloads or writes
42
+ * @param {Function} [options.onProgress]
43
+ */
44
+ async execute(options = {}) {
45
+ const startTime = Date.now();
46
+ this.onProgress = options.onProgress || null;
47
+
48
+ try {
49
+ // Allow CLI overrides before validation
50
+ if (options.rootFolder) {
51
+ process.env.GDRIVE_ROOT_FOLDER_ID = options.rootFolder;
52
+ }
53
+ if (options.dest) {
54
+ process.env.GDRIVE_LOCAL_MIRROR_PATH = options.dest;
55
+ }
56
+ // Reload config after env mutation
57
+ const cfg = appConfig.getGDriveConfig();
58
+ if (options.rootFolder) cfg.rootFolderId = options.rootFolder;
59
+ if (options.dest) cfg.localMirrorPath = options.dest;
60
+
61
+ appConfig.validateGDriveConfig();
62
+
63
+ const dryRun = !!options.dryRun;
64
+ const full = !!options.full;
65
+
66
+ this.driveService = new GoogleDriveService();
67
+
68
+ this.#say('โ˜๏ธ Starting arela gdrive-sync command');
69
+ this.#say(`๐Ÿ“ Root folder ID: ${cfg.rootFolderId}`);
70
+ this.#say(`๐Ÿ’พ Local mirror: ${cfg.localMirrorPath}`);
71
+ this.#say(`โš™๏ธ Concurrency: ${cfg.concurrency}`);
72
+ this.#say(`๐Ÿ“„ Skip native docs: ${cfg.skipNativeDocs}`);
73
+ if (dryRun) this.#say('๐Ÿงช DRY-RUN: no files will be written');
74
+ if (full) this.#say('โ™ป๏ธ FULL: ignoring state, re-verifying all files');
75
+
76
+ // Ensure mirror dir exists
77
+ if (!dryRun) {
78
+ await fsp.mkdir(cfg.localMirrorPath, { recursive: true });
79
+ }
80
+
81
+ // Load existing state
82
+ const statePath = path.join(cfg.localMirrorPath, STATE_FILENAME);
83
+ const state = full
84
+ ? this.#emptyState()
85
+ : await this.#loadState(statePath);
86
+
87
+ // Verify root folder exists & is accessible
88
+ this.#say('\n๐Ÿ” Verifying root folder access...');
89
+ const rootMeta = await this.driveService.getFile(cfg.rootFolderId);
90
+ if (!GoogleDriveService.isFolder(rootMeta)) {
91
+ throw new Error(
92
+ `GDRIVE_ROOT_FOLDER_ID does not point to a folder (mimeType=${rootMeta.mimeType})`,
93
+ );
94
+ }
95
+ logger.success(` โœ“ Root: "${rootMeta.name}"`);
96
+
97
+ // Walk Drive tree โ†’ produce file plan
98
+ this.#say('\n๐ŸŒฒ Walking Drive tree...');
99
+ this.#reportProgress(0, 'Walking Drive tree');
100
+ const plan = await this.#walkTree(cfg.rootFolderId, '', cfg);
101
+ this.#say(
102
+ `๐Ÿ“‹ Found ${plan.folders.length} folder(s) and ${plan.files.length} file(s)`,
103
+ );
104
+
105
+ // Stats
106
+ const stats = {
107
+ foldersCreated: 0,
108
+ filesAdded: 0,
109
+ filesUpdated: 0,
110
+ filesSkipped: 0,
111
+ filesFailed: 0,
112
+ nativeDocsSkipped: 0,
113
+ oversizedSkipped: 0,
114
+ bytesDownloaded: 0,
115
+ };
116
+
117
+ // Create folders first (cheap, sequential)
118
+ if (!dryRun) {
119
+ for (const folderRel of plan.folders) {
120
+ const abs = path.join(cfg.localMirrorPath, folderRel);
121
+ try {
122
+ await fsp.mkdir(abs, { recursive: true });
123
+ stats.foldersCreated += 1;
124
+ } catch (err) {
125
+ logger.warn(`โš ๏ธ mkdir failed for ${abs}: ${err.message}`);
126
+ }
127
+ }
128
+ }
129
+
130
+ // Download files concurrently
131
+ this.#say('\nโฌ‡๏ธ Downloading files...');
132
+ const limit = pLimit(cfg.concurrency);
133
+ const progressBar = this.#createProgressBar(plan.files.length, dryRun);
134
+ let processed = 0;
135
+
136
+ const tasks = plan.files.map((file) =>
137
+ limit(async () => {
138
+ try {
139
+ const result = await this.#processFile(file, cfg, state, dryRun);
140
+ if (result === 'added') stats.filesAdded += 1;
141
+ else if (result === 'updated') stats.filesUpdated += 1;
142
+ else if (result === 'skipped-unchanged') stats.filesSkipped += 1;
143
+ else if (result === 'skipped-native') stats.nativeDocsSkipped += 1;
144
+ else if (result === 'skipped-oversize') stats.oversizedSkipped += 1;
145
+ if (
146
+ (result === 'added' || result === 'updated') &&
147
+ file.size != null
148
+ ) {
149
+ stats.bytesDownloaded += Number(file.size) || 0;
150
+ }
151
+ } catch (err) {
152
+ stats.filesFailed += 1;
153
+ logger.error(
154
+ `โŒ Failed: ${file.relPath} (${file.id}) โ€” ${err.message}`,
155
+ );
156
+ } finally {
157
+ processed += 1;
158
+ progressBar.update(processed);
159
+ if (plan.files.length > 0) {
160
+ this.#reportProgress(
161
+ Math.round((processed / plan.files.length) * 100),
162
+ `Synced ${processed}/${plan.files.length}`,
163
+ );
164
+ }
165
+ }
166
+ }),
167
+ );
168
+
169
+ await Promise.all(tasks);
170
+ progressBar.stop();
171
+
172
+ // Persist state
173
+ if (!dryRun) {
174
+ await this.#saveState(statePath, state);
175
+ }
176
+
177
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
178
+ this.#reportProgress(100, `Sync completed in ${duration}s`);
179
+
180
+ logger.success('\nโœ… gdrive-sync completed successfully!');
181
+ this.#say('\n๐Ÿ“Š Sync Statistics:');
182
+ this.#say(` Folders created: ${stats.foldersCreated}`);
183
+ this.#say(` Files added: ${stats.filesAdded}`);
184
+ this.#say(` Files updated: ${stats.filesUpdated}`);
185
+ this.#say(` Files unchanged: ${stats.filesSkipped}`);
186
+ this.#say(` Native docs skipped: ${stats.nativeDocsSkipped}`);
187
+ this.#say(` Oversized skipped: ${stats.oversizedSkipped}`);
188
+ this.#say(` Files failed: ${stats.filesFailed}`);
189
+ this.#say(
190
+ ` Bytes downloaded: ${this.#formatBytes(stats.bytesDownloaded)}`,
191
+ );
192
+ this.#say(` Duration: ${duration}s`);
193
+ this.#say(
194
+ `\n๐Ÿ’ก Next: run "arela scan" with UPLOAD_BASE_PATH=${cfg.localMirrorPath}`,
195
+ );
196
+
197
+ return { success: true, stats };
198
+ } catch (error) {
199
+ this.errorHandler.handleError(error, 'gdrive-sync');
200
+ return { success: false, error: error.message };
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Walk Drive tree starting at folderId.
206
+ * Returns flat lists of folders (relative paths) and files (with metadata + relPath).
207
+ * @private
208
+ */
209
+ async #walkTree(folderId, relPath, cfg) {
210
+ const folders = [];
211
+ const files = [];
212
+
213
+ // BFS using a queue
214
+ const queue = [{ id: folderId, relPath }];
215
+ // Track sanitized names per folder to dedupe collisions
216
+ const folderNameMaps = new Map();
217
+
218
+ while (queue.length > 0) {
219
+ const { id, relPath: parentRel } = queue.shift();
220
+ const usedNames = folderNameMaps.get(parentRel) || new Set();
221
+
222
+ for await (const child of this.driveService.listChildren(id)) {
223
+ const safeName = this.#uniqueSanitizedName(
224
+ child.name,
225
+ child.id,
226
+ usedNames,
227
+ );
228
+ const childRel = parentRel ? `${parentRel}/${safeName}` : safeName;
229
+
230
+ if (GoogleDriveService.isFolder(child)) {
231
+ folders.push(childRel);
232
+ folderNameMaps.set(childRel, new Set());
233
+ queue.push({ id: child.id, relPath: childRel });
234
+ continue;
235
+ }
236
+
237
+ // Resolve shortcuts to their targets if enabled
238
+ let effective = child;
239
+ if (GoogleDriveService.isShortcut(child)) {
240
+ if (!cfg.followShortcuts || !child.shortcutDetails?.targetId) {
241
+ logger.debug(`โญ๏ธ Skipping shortcut: ${childRel}`);
242
+ continue;
243
+ }
244
+ try {
245
+ effective = await this.driveService.getFile(
246
+ child.shortcutDetails.targetId,
247
+ );
248
+ } catch (err) {
249
+ logger.warn(
250
+ `โš ๏ธ Could not resolve shortcut "${childRel}": ${err.message}`,
251
+ );
252
+ continue;
253
+ }
254
+ if (GoogleDriveService.isFolder(effective)) {
255
+ folders.push(childRel);
256
+ folderNameMaps.set(childRel, new Set());
257
+ queue.push({ id: effective.id, relPath: childRel });
258
+ continue;
259
+ }
260
+ }
261
+
262
+ files.push({
263
+ id: effective.id,
264
+ name: effective.name,
265
+ mimeType: effective.mimeType,
266
+ modifiedTime: effective.modifiedTime,
267
+ size: effective.size != null ? Number(effective.size) : null,
268
+ md5Checksum: effective.md5Checksum || null,
269
+ relPath: childRel,
270
+ isNativeDoc: GoogleDriveService.isNativeGoogleDoc(effective),
271
+ });
272
+ }
273
+
274
+ folderNameMaps.set(parentRel, usedNames);
275
+ }
276
+
277
+ return { folders, files };
278
+ }
279
+
280
+ /**
281
+ * Process a single file: skip / download / update.
282
+ * @private
283
+ */
284
+ async #processFile(file, cfg, state, dryRun) {
285
+ // Native Google Docs (Docs/Sheets/Slides/...): skip by default
286
+ if (file.isNativeDoc && cfg.skipNativeDocs) {
287
+ logger.debug(`โญ๏ธ Native Google Doc skipped: ${file.relPath}`);
288
+ return 'skipped-native';
289
+ }
290
+
291
+ // Oversize guard
292
+ if (
293
+ cfg.maxFileSizeBytes &&
294
+ file.size != null &&
295
+ file.size > cfg.maxFileSizeBytes
296
+ ) {
297
+ logger.warn(
298
+ `โš ๏ธ File too large (${this.#formatBytes(file.size)}): ${file.relPath}`,
299
+ );
300
+ return 'skipped-oversize';
301
+ }
302
+
303
+ const absPath = path.join(cfg.localMirrorPath, file.relPath);
304
+ const prev = state.files[file.id];
305
+
306
+ // Decide if we can skip (unchanged)
307
+ const localExists = fs.existsSync(absPath);
308
+ const unchanged =
309
+ localExists &&
310
+ prev &&
311
+ prev.relPath === file.relPath &&
312
+ ((file.md5Checksum && prev.md5Checksum === file.md5Checksum) ||
313
+ (!file.md5Checksum && prev.modifiedTime === file.modifiedTime));
314
+
315
+ if (unchanged) {
316
+ return 'skipped-unchanged';
317
+ }
318
+
319
+ if (dryRun) {
320
+ logger.info(
321
+ ` [dry-run] would ${prev ? 'update' : 'add'}: ${file.relPath}`,
322
+ );
323
+ return prev ? 'updated' : 'added';
324
+ }
325
+
326
+ // Ensure parent dir
327
+ await fsp.mkdir(path.dirname(absPath), { recursive: true });
328
+
329
+ // Download to .part then atomic rename
330
+ const partPath = `${absPath}.part`;
331
+ try {
332
+ await fsp.rm(partPath, { force: true });
333
+ } catch {
334
+ /* ignore */
335
+ }
336
+
337
+ const out = fs.createWriteStream(partPath);
338
+ await this.driveService.downloadFile(file.id, out);
339
+ await fsp.rename(partPath, absPath);
340
+
341
+ // Set mtime to Drive's modifiedTime so `arela scan` captures changes correctly
342
+ if (file.modifiedTime) {
343
+ const mtime = new Date(file.modifiedTime);
344
+ try {
345
+ await fsp.utimes(absPath, mtime, mtime);
346
+ } catch (err) {
347
+ logger.debug(`โš ๏ธ utimes failed for ${absPath}: ${err.message}`);
348
+ }
349
+ }
350
+
351
+ // Update state
352
+ state.files[file.id] = {
353
+ relPath: file.relPath,
354
+ md5Checksum: file.md5Checksum,
355
+ modifiedTime: file.modifiedTime,
356
+ size: file.size,
357
+ syncedAt: new Date().toISOString(),
358
+ };
359
+
360
+ return prev ? 'updated' : 'added';
361
+ }
362
+
363
+ /**
364
+ * Sanitize file/folder name; on duplicate, append " (gdrive-<id>)".
365
+ * @private
366
+ */
367
+ #uniqueSanitizedName(originalName, fileId, usedNames) {
368
+ let safe = this.sanitizer.sanitizeFileName(originalName);
369
+ if (!safe) safe = `file-${fileId}`;
370
+
371
+ if (usedNames.has(safe)) {
372
+ const ext = path.extname(safe);
373
+ const base = path.basename(safe, ext);
374
+ safe = `${base} (gdrive-${fileId})${ext}`;
375
+ }
376
+ usedNames.add(safe);
377
+ return safe;
378
+ }
379
+
380
+ /**
381
+ * @private
382
+ */
383
+ async #loadState(statePath) {
384
+ try {
385
+ const raw = await fsp.readFile(statePath, 'utf-8');
386
+ const parsed = JSON.parse(raw);
387
+ if (parsed.version !== STATE_VERSION) {
388
+ logger.warn(
389
+ `โš ๏ธ State file version mismatch (${parsed.version} != ${STATE_VERSION}); starting fresh`,
390
+ );
391
+ return this.#emptyState();
392
+ }
393
+ return parsed;
394
+ } catch (err) {
395
+ if (err.code !== 'ENOENT') {
396
+ logger.warn(`โš ๏ธ Could not read state file: ${err.message}`);
397
+ }
398
+ return this.#emptyState();
399
+ }
400
+ }
401
+
402
+ /**
403
+ * @private
404
+ */
405
+ async #saveState(statePath, state) {
406
+ state.savedAt = new Date().toISOString();
407
+ const tmp = `${statePath}.tmp`;
408
+ await fsp.writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
409
+ await fsp.rename(tmp, statePath);
410
+ }
411
+
412
+ /**
413
+ * @private
414
+ */
415
+ #emptyState() {
416
+ return { version: STATE_VERSION, savedAt: null, files: {} };
417
+ }
418
+
419
+ /**
420
+ * @private
421
+ */
422
+ #createProgressBar(total, dryRun) {
423
+ const label = dryRun ? '๐Ÿงช Planning' : 'โฌ‡๏ธ Downloading';
424
+ const bar = new cliProgress.SingleBar(
425
+ {
426
+ format: `${label} |{bar}| {percentage}% | {value}/{total} files`,
427
+ barCompleteChar: '\u2588',
428
+ barIncompleteChar: '\u2591',
429
+ hideCursor: true,
430
+ },
431
+ cliProgress.Presets.shades_classic,
432
+ );
433
+ bar.start(Math.max(total, 1), 0);
434
+ return bar;
435
+ }
436
+
437
+ /**
438
+ * @private
439
+ */
440
+ #reportProgress(percent, message) {
441
+ if (typeof this.onProgress === 'function') {
442
+ try {
443
+ this.onProgress(percent, message);
444
+ } catch {
445
+ /* ignore listener errors */
446
+ }
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Print to stdout AND log to file (mirrors ScanCommand's user-facing output style).
452
+ * @private
453
+ */
454
+ #say(message) {
455
+ console.log(message);
456
+ logger.info(message);
457
+ }
458
+
459
+ /**
460
+ * @private
461
+ */
462
+ #formatBytes(bytes) {
463
+ if (!bytes) return '0 B';
464
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
465
+ let i = 0;
466
+ let n = bytes;
467
+ while (n >= 1024 && i < units.length - 1) {
468
+ n /= 1024;
469
+ i += 1;
470
+ }
471
+ return `${n.toFixed(2)} ${units[i]}`;
472
+ }
473
+ }
474
+
475
+ export default new GDriveSyncCommand();