@arela/uploader 0.2.4 → 0.2.6

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": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "CLI to upload files/directories to Arela",
5
5
  "bin": {
6
6
  "arela": "./src/index.js"
@@ -33,6 +33,7 @@
33
33
  "commander": "13.1.0",
34
34
  "dotenv": "16.5.0",
35
35
  "form-data": "4.0.4",
36
+ "formdata-node": "^6.0.3",
36
37
  "globby": "14.1.0",
37
38
  "mime-types": "3.0.1",
38
39
  "node-fetch": "3.3.2",
@@ -42,4 +43,4 @@
42
43
  "@trivago/prettier-plugin-sort-imports": "5.2.2",
43
44
  "prettier": "3.5.3"
44
45
  }
45
- }
46
+ }
@@ -0,0 +1,446 @@
1
+ import cliProgress from 'cli-progress';
2
+ import { globby } from 'globby';
3
+ import mime from 'mime-types';
4
+ import path from 'path';
5
+
6
+ import databaseService from '../services/DatabaseService.js';
7
+ import logger from '../services/LoggingService.js';
8
+ import uploadServiceFactory from '../services/upload/UploadServiceFactory.js';
9
+
10
+ import appConfig from '../config/config.js';
11
+ import ErrorHandler from '../errors/ErrorHandler.js';
12
+ import {
13
+ ConfigurationError,
14
+ FileOperationError,
15
+ } from '../errors/ErrorTypes.js';
16
+ import FileOperations from '../utils/FileOperations.js';
17
+ import fileSanitizer from '../utils/FileSanitizer.js';
18
+ import pathDetector from '../utils/PathDetector.js';
19
+
20
+ /**
21
+ * Upload Command Handler
22
+ * Handles the main upload functionality
23
+ */
24
+ export class UploadCommand {
25
+ constructor() {
26
+ this.errorHandler = new ErrorHandler(logger);
27
+ }
28
+
29
+ /**
30
+ * Execute the upload command
31
+ * @param {Object} options - Command options
32
+ */
33
+ async execute(options) {
34
+ try {
35
+ // Validate configuration
36
+ this.#validateOptions(options);
37
+
38
+ // Initialize services
39
+ const uploadService = await uploadServiceFactory.getUploadService(
40
+ options.forceSupabase,
41
+ );
42
+ const sources = appConfig.getUploadSources();
43
+ const basePath = appConfig.getBasePath();
44
+
45
+ // Log command start
46
+ logger.info(`Starting upload with ${uploadService.getServiceName()}`);
47
+
48
+ if (options.clearLog) {
49
+ logger.clearLogFile();
50
+ logger.info('Log file cleared');
51
+ }
52
+
53
+ // Process each source
54
+ let globalResults = {
55
+ successCount: 0,
56
+ detectedCount: 0,
57
+ organizedCount: 0,
58
+ failureCount: 0,
59
+ skippedCount: 0,
60
+ };
61
+
62
+ for (const source of sources) {
63
+ const sourcePath = path.resolve(basePath, source).replace(/\\/g, '/');
64
+ logger.info(`Processing folder: ${sourcePath}`);
65
+
66
+ try {
67
+ const files = await this.#discoverFiles(sourcePath);
68
+ logger.info(`Found ${files.length} files to process`);
69
+
70
+ const result = await this.#processFilesInBatches(
71
+ files,
72
+ options,
73
+ uploadService,
74
+ basePath,
75
+ source,
76
+ sourcePath,
77
+ );
78
+
79
+ this.#updateGlobalResults(globalResults, result);
80
+ this.#logSourceSummary(source, result, options);
81
+ } catch (error) {
82
+ this.errorHandler.handleError(error, { source, sourcePath });
83
+ globalResults.failureCount++;
84
+ }
85
+ }
86
+
87
+ this.#logFinalSummary(globalResults, options, uploadService);
88
+
89
+ // Handle additional phases if requested
90
+ if (options.runAllPhases && options.statsOnly) {
91
+ await this.#runAdditionalPhases(options);
92
+ }
93
+ } catch (error) {
94
+ this.errorHandler.handleFatalError(error, { command: 'upload', options });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate command options
100
+ * @private
101
+ * @param {Object} options - Options to validate
102
+ */
103
+ #validateOptions(options) {
104
+ try {
105
+ appConfig.validateConfiguration(options.forceSupabase);
106
+ } catch (error) {
107
+ throw new ConfigurationError(error.message);
108
+ }
109
+
110
+ if (
111
+ options.batchSize &&
112
+ (options.batchSize < 1 || options.batchSize > 100)
113
+ ) {
114
+ throw new ConfigurationError('Batch size must be between 1 and 100');
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Discover files in a source path
120
+ * @private
121
+ * @param {string} sourcePath - Path to discover files in
122
+ * @returns {Promise<string[]>} Array of file paths
123
+ */
124
+ async #discoverFiles(sourcePath) {
125
+ try {
126
+ if (!FileOperations.fileExists(sourcePath)) {
127
+ throw new FileOperationError(
128
+ `Source path does not exist: ${sourcePath}`,
129
+ );
130
+ }
131
+
132
+ const stats = FileOperations.getFileStats(sourcePath);
133
+
134
+ if (stats?.isDirectory()) {
135
+ return await globby([`${sourcePath}/**/*`], { onlyFiles: true });
136
+ } else {
137
+ return [sourcePath];
138
+ }
139
+ } catch (error) {
140
+ throw new FileOperationError(
141
+ `Failed to discover files in ${sourcePath}`,
142
+ sourcePath,
143
+ { originalError: error.message },
144
+ );
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Process files in batches
150
+ * @private
151
+ * @param {string[]} files - Files to process
152
+ * @param {Object} options - Processing options
153
+ * @param {Object} uploadService - Upload service instance
154
+ * @param {string} basePath - Base path
155
+ * @param {string} source - Source name
156
+ * @param {string} sourcePath - Source path
157
+ * @returns {Promise<Object>} Processing results
158
+ */
159
+ async #processFilesInBatches(
160
+ files,
161
+ options,
162
+ uploadService,
163
+ basePath,
164
+ source,
165
+ sourcePath,
166
+ ) {
167
+ const batchSize = parseInt(options.batchSize) || 10;
168
+ const results = {
169
+ successCount: 0,
170
+ detectedCount: 0,
171
+ organizedCount: 0,
172
+ failureCount: 0,
173
+ skippedCount: 0,
174
+ };
175
+
176
+ // Get processed paths if available
177
+ const processedPaths = options.skipProcessed
178
+ ? databaseService.getProcessedPaths()
179
+ : new Set();
180
+
181
+ // Create progress bar
182
+ const progressBar = new cliProgress.SingleBar({
183
+ format: `📤 ${source} |{bar}| {percentage}% | {value}/{total} | Success: {success} | Errors: {errors}`,
184
+ barCompleteChar: '█',
185
+ barIncompleteChar: '░',
186
+ hideCursor: true,
187
+ });
188
+
189
+ progressBar.start(files.length, 0, { success: 0, errors: 0 });
190
+
191
+ // Process files in batches
192
+ for (let i = 0; i < files.length; i += batchSize) {
193
+ const batch = files.slice(i, i + batchSize);
194
+
195
+ try {
196
+ const batchResult = await this.#processBatch(
197
+ batch,
198
+ options,
199
+ uploadService,
200
+ basePath,
201
+ processedPaths,
202
+ );
203
+
204
+ this.#updateResults(results, batchResult);
205
+
206
+ progressBar.update(Math.min(i + batchSize, files.length), {
207
+ success: results.successCount,
208
+ errors: results.failureCount,
209
+ });
210
+
211
+ // Delay between batches if configured
212
+ if (appConfig.performance.batchDelay > 0) {
213
+ await new Promise((resolve) =>
214
+ setTimeout(resolve, appConfig.performance.batchDelay),
215
+ );
216
+ }
217
+ } catch (error) {
218
+ this.errorHandler.handleError(error, {
219
+ batch: i / batchSize + 1,
220
+ batchSize,
221
+ });
222
+ results.failureCount += batch.length;
223
+ }
224
+ }
225
+
226
+ progressBar.stop();
227
+ return results;
228
+ }
229
+
230
+ /**
231
+ * Process a batch of files
232
+ * @private
233
+ * @param {string[]} batch - Files in this batch
234
+ * @param {Object} options - Processing options
235
+ * @param {Object} uploadService - Upload service
236
+ * @param {string} basePath - Base path
237
+ * @param {Set} processedPaths - Already processed paths
238
+ * @returns {Promise<Object>} Batch results
239
+ */
240
+ async #processBatch(batch, options, uploadService, basePath, processedPaths) {
241
+ const batchResults = {
242
+ successCount: 0,
243
+ detectedCount: 0,
244
+ organizedCount: 0,
245
+ failureCount: 0,
246
+ skippedCount: 0,
247
+ };
248
+
249
+ if (options.statsOnly) {
250
+ // Stats-only mode: just record file information
251
+ const fileObjects = batch.map((filePath) => ({
252
+ path: filePath,
253
+ originalName: path.basename(filePath),
254
+ stats: FileOperations.getFileStats(filePath),
255
+ }));
256
+
257
+ try {
258
+ const result = await databaseService.insertStatsOnlyToUploaderTable(
259
+ fileObjects,
260
+ options,
261
+ );
262
+ batchResults.successCount = result.totalInserted;
263
+ batchResults.skippedCount = result.totalSkipped;
264
+ } catch (error) {
265
+ throw new Error(`Failed to insert stats: ${error.message}`);
266
+ }
267
+ } else {
268
+ // Upload mode: process files for upload
269
+ for (const filePath of batch) {
270
+ try {
271
+ await this.#processFile(
272
+ filePath,
273
+ options,
274
+ uploadService,
275
+ basePath,
276
+ processedPaths,
277
+ batchResults,
278
+ );
279
+ } catch (error) {
280
+ this.errorHandler.handleError(error, { filePath });
281
+ batchResults.failureCount++;
282
+ }
283
+ }
284
+ }
285
+
286
+ return batchResults;
287
+ }
288
+
289
+ /**
290
+ * Process a single file
291
+ * @private
292
+ */
293
+ async #processFile(
294
+ filePath,
295
+ options,
296
+ uploadService,
297
+ basePath,
298
+ processedPaths,
299
+ batchResults,
300
+ ) {
301
+ // Skip if already processed
302
+ if (processedPaths.has(filePath)) {
303
+ batchResults.skippedCount++;
304
+ return;
305
+ }
306
+
307
+ // Prepare file for upload
308
+ const sanitizedName = fileSanitizer.sanitizeFileName(
309
+ path.basename(filePath),
310
+ );
311
+ const pathInfo = pathDetector.extractYearAndPedimentoFromPath(
312
+ filePath,
313
+ basePath,
314
+ );
315
+
316
+ let uploadPath = sanitizedName;
317
+ if (pathInfo.detected && options.autoDetectStructure) {
318
+ uploadPath = `${pathInfo.year}/${pathInfo.pedimento}/${sanitizedName}`;
319
+ }
320
+
321
+ const fileObject = {
322
+ path: filePath,
323
+ name: sanitizedName,
324
+ contentType: this.#getMimeType(filePath),
325
+ };
326
+
327
+ // Upload based on service type
328
+ if (uploadService.getServiceName() === 'Arela API') {
329
+ const result = await uploadService.upload([fileObject], {
330
+ ...options,
331
+ uploadPath,
332
+ });
333
+
334
+ batchResults.successCount++;
335
+ if (result.detectedCount)
336
+ batchResults.detectedCount += result.detectedCount;
337
+ if (result.organizedCount)
338
+ batchResults.organizedCount += result.organizedCount;
339
+ } else {
340
+ // Supabase direct upload
341
+ await uploadService.upload([fileObject], { uploadPath });
342
+ batchResults.successCount++;
343
+ }
344
+
345
+ logger.info(`SUCCESS: ${path.basename(filePath)} -> ${uploadPath}`);
346
+ }
347
+
348
+ /**
349
+ * Get MIME type for file
350
+ * @private
351
+ */
352
+ #getMimeType(filePath) {
353
+ return mime.lookup(filePath) || 'application/octet-stream';
354
+ }
355
+
356
+ /**
357
+ * Update results object
358
+ * @private
359
+ */
360
+ #updateResults(target, source) {
361
+ target.successCount += source.successCount;
362
+ target.detectedCount += source.detectedCount;
363
+ target.organizedCount += source.organizedCount;
364
+ target.failureCount += source.failureCount;
365
+ target.skippedCount += source.skippedCount;
366
+ }
367
+
368
+ /**
369
+ * Update global results
370
+ * @private
371
+ */
372
+ #updateGlobalResults(global, source) {
373
+ this.#updateResults(global, source);
374
+ }
375
+
376
+ /**
377
+ * Log source summary
378
+ * @private
379
+ */
380
+ #logSourceSummary(source, result, options) {
381
+ console.log(`\n📦 Summary for ${source}:`);
382
+ if (options.statsOnly) {
383
+ console.log(` 📊 Stats recorded: ${result.successCount}`);
384
+ console.log(` ⏭️ Duplicates: ${result.skippedCount}`);
385
+ } else {
386
+ console.log(` ✅ Uploaded: ${result.successCount}`);
387
+ if (result.detectedCount)
388
+ console.log(` 🔍 Detected: ${result.detectedCount}`);
389
+ if (result.organizedCount)
390
+ console.log(` 📁 Organized: ${result.organizedCount}`);
391
+ console.log(` ⏭️ Skipped: ${result.skippedCount}`);
392
+ }
393
+ console.log(` ❌ Errors: ${result.failureCount}`);
394
+ }
395
+
396
+ /**
397
+ * Log final summary
398
+ * @private
399
+ */
400
+ #logFinalSummary(results, options, uploadService) {
401
+ console.log(`\n${'='.repeat(60)}`);
402
+ if (options.statsOnly) {
403
+ console.log(`📊 STATS COLLECTION COMPLETED`);
404
+ console.log(` 📊 Total stats recorded: ${results.successCount}`);
405
+ console.log(` ⏭️ Total duplicates: ${results.skippedCount}`);
406
+ } else {
407
+ console.log(
408
+ `🎯 ${uploadService.getServiceName().toUpperCase()} UPLOAD COMPLETED`,
409
+ );
410
+ console.log(` ✅ Total uploaded: ${results.successCount}`);
411
+ if (results.detectedCount)
412
+ console.log(` 🔍 Total detected: ${results.detectedCount}`);
413
+ if (results.organizedCount)
414
+ console.log(` 📁 Total organized: ${results.organizedCount}`);
415
+ console.log(` ⏭️ Total skipped: ${results.skippedCount}`);
416
+ }
417
+ console.log(` ❌ Total errors: ${results.failureCount}`);
418
+ console.log(` 📜 Log file: ${logger.getLogFilePath()}`);
419
+ console.log(`${'='.repeat(60)}\n`);
420
+ }
421
+
422
+ /**
423
+ * Run additional phases
424
+ * @private
425
+ */
426
+ async #runAdditionalPhases(options) {
427
+ try {
428
+ // Phase 2: PDF Detection
429
+ console.log('\n🔍 === PHASE 2: PDF Detection ===');
430
+ const detectionResult = await databaseService.detectPedimentosInDatabase({
431
+ batchSize: parseInt(options.batchSize) || 10,
432
+ });
433
+ console.log(
434
+ `✅ Phase 2 Complete: ${detectionResult.detectedCount} detected, ${detectionResult.errorCount} errors`,
435
+ );
436
+
437
+ // Additional phases would be implemented here
438
+ console.log('\n🎉 All phases completed successfully!');
439
+ } catch (error) {
440
+ this.errorHandler.handleError(error, { phase: 'additional-phases' });
441
+ throw error;
442
+ }
443
+ }
444
+ }
445
+
446
+ export default UploadCommand;
@@ -0,0 +1,178 @@
1
+ import { config } from 'dotenv';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ config();
6
+
7
+ /**
8
+ * Configuration management for Arela Uploader
9
+ * Centralizes all environment variable handling and validation
10
+ */
11
+ class Config {
12
+ constructor() {
13
+ this.packageVersion = this.#loadPackageVersion();
14
+ this.supabase = this.#loadSupabaseConfig();
15
+ this.api = this.#loadApiConfig();
16
+ this.upload = this.#loadUploadConfig();
17
+ this.performance = this.#loadPerformanceConfig();
18
+ this.logging = this.#loadLoggingConfig();
19
+ }
20
+
21
+ /**
22
+ * Load package version from package.json
23
+ * @private
24
+ */
25
+ #loadPackageVersion() {
26
+ try {
27
+ const __filename = new URL(import.meta.url).pathname;
28
+ const __dirname = path.dirname(__filename);
29
+ const packageJsonPath = path.resolve(__dirname, '../../package.json');
30
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
31
+ return packageJson.version || '0.2.6';
32
+ } catch (error) {
33
+ console.warn('⚠️ Could not read package.json version, using fallback');
34
+ return '0.2.6';
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Load Supabase configuration
40
+ * @private
41
+ */
42
+ #loadSupabaseConfig() {
43
+ return {
44
+ url: process.env.SUPABASE_URL,
45
+ key: process.env.SUPABASE_KEY,
46
+ bucket: process.env.SUPABASE_BUCKET,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Load API configuration
52
+ * @private
53
+ */
54
+ #loadApiConfig() {
55
+ return {
56
+ baseUrl: process.env.ARELA_API_URL,
57
+ token: process.env.ARELA_API_TOKEN,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Load upload configuration
63
+ * @private
64
+ */
65
+ #loadUploadConfig() {
66
+ const basePath = process.env.UPLOAD_BASE_PATH;
67
+ const sources = process.env.UPLOAD_SOURCES?.split('|')
68
+ .map((s) => s.trim())
69
+ .filter(Boolean);
70
+
71
+ const uploadRfcs = process.env.UPLOAD_RFCS?.split('|')
72
+ .map((s) => s.trim())
73
+ .filter(Boolean);
74
+
75
+ return {
76
+ basePath,
77
+ sources,
78
+ rfcs: uploadRfcs,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Load performance configuration
84
+ * @private
85
+ */
86
+ #loadPerformanceConfig() {
87
+ return {
88
+ batchDelay: parseInt(process.env.BATCH_DELAY) || 100,
89
+ progressUpdateInterval:
90
+ parseInt(process.env.PROGRESS_UPDATE_INTERVAL) || 10,
91
+ logBufferSize: 100,
92
+ logFlushInterval: 5000,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Load logging configuration
98
+ * @private
99
+ */
100
+ #loadLoggingConfig() {
101
+ return {
102
+ verbose: process.env.VERBOSE_LOGGING === 'true',
103
+ logFilePath: path.resolve(process.cwd(), 'arela-upload.log'),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Check if API mode is available
109
+ * @returns {boolean}
110
+ */
111
+ isApiModeAvailable() {
112
+ return !!(this.api.baseUrl && this.api.token);
113
+ }
114
+
115
+ /**
116
+ * Check if Supabase mode is available
117
+ * @returns {boolean}
118
+ */
119
+ isSupabaseModeAvailable() {
120
+ return !!(this.supabase.url && this.supabase.key && this.supabase.bucket);
121
+ }
122
+
123
+ /**
124
+ * Validate configuration for the requested mode
125
+ * @param {boolean} forceSupabase - Whether to force Supabase mode
126
+ * @throws {Error} If required configuration is missing
127
+ */
128
+ validateConfiguration(forceSupabase = false) {
129
+ if (forceSupabase) {
130
+ if (!this.isSupabaseModeAvailable()) {
131
+ throw new Error(
132
+ '⚠️ Missing Supabase credentials. Please set SUPABASE_URL, SUPABASE_KEY, and SUPABASE_BUCKET',
133
+ );
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (!this.isApiModeAvailable() && !this.isSupabaseModeAvailable()) {
139
+ throw new Error(
140
+ '⚠️ Missing credentials. Please set either:\n' +
141
+ ' - ARELA_API_URL and ARELA_API_TOKEN for API mode, or\n' +
142
+ ' - SUPABASE_URL, SUPABASE_KEY, and SUPABASE_BUCKET for direct mode',
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get upload sources with validation
149
+ * @returns {string[]} Array of upload sources
150
+ * @throws {Error} If sources are not configured
151
+ */
152
+ getUploadSources() {
153
+ if (!this.upload.sources || this.upload.sources.length === 0) {
154
+ throw new Error(
155
+ '⚠️ No upload sources configured. Please set UPLOAD_SOURCES environment variable.',
156
+ );
157
+ }
158
+ return this.upload.sources;
159
+ }
160
+
161
+ /**
162
+ * Get base path with validation
163
+ * @returns {string} Base path for uploads
164
+ * @throws {Error} If base path is not configured
165
+ */
166
+ getBasePath() {
167
+ if (!this.upload.basePath) {
168
+ throw new Error(
169
+ '⚠️ No base path configured. Please set UPLOAD_BASE_PATH environment variable.',
170
+ );
171
+ }
172
+ return this.upload.basePath;
173
+ }
174
+ }
175
+
176
+ // Export singleton instance
177
+ export const appConfig = new Config();
178
+ export default appConfig;