@arela/uploader 1.0.4 → 1.0.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.
@@ -37,7 +37,7 @@ C:\Users\Docs → C:/Users/Docs (stored) → scan_palco_local_c_users_docs
37
37
  ### cli_registry Table
38
38
  - `company_slug`: Company identifier
39
39
  - `server_id`: Server identifier
40
- - `base_path_full`: Absolute path with forward slashes (`C:/Users/Documents/2023`)
40
+ - `base_path_full`: Absolute path (preserves original format for Windows paths like `O:\expediente\archivos`)
41
41
  - `table_name`: Sanitized version (`scan_palco_local_c_users_documents_2023`)
42
42
  - **Removed**: `base_path_label` (was redundant - same as base_path_full)
43
43
 
@@ -46,18 +46,22 @@ C:\Users\Docs → C:/Users/Docs (stored) → scan_palco_local_c_users_docs
46
46
  - `relative_path`: Relative to base with forward slashes
47
47
  - `absolute_path`: Absolute path with forward slashes
48
48
 
49
- **Note**: All paths stored with forward slashes for consistency, but work on all platforms.
49
+ **Note**: Paths are stored consistently. Windows absolute paths (with drive letters) are preserved when the CLI runs on the same Windows machine or a different OS (macOS/Linux).
50
50
 
51
51
  ## Key Features
52
52
 
53
- ### 1. Cross-Platform Path Normalization
53
+ ### 1. Cross-Platform Path Detection
54
54
 
55
- All paths are normalized to POSIX format (forward slashes) for consistency:
55
+ The CLI correctly identifies Windows absolute paths even when running on macOS/Linux:
56
56
 
57
- - **Windows paths**: `C:\Users\Documents` → `/Users/Documents`
58
- - **Unix paths**: `/home/user/docs` → `/home/user/docs`
59
- - **Relative paths**: `../parent/folder` → `../parent/folder`
60
- - **Network drives**: `O:/data/files` → `/data/files`
57
+ - **Windows paths on any OS**: `O:\expediente\archivos` → recognized as absolute, preserved as-is
58
+ - **Unix paths**: `/home/user/docs` → recognized as absolute, normalized
59
+ - **Relative paths**: `./sample` → resolved against cwd
60
+
61
+ This allows:
62
+ - Running `arela scan` on Windows server → creates table with Windows path in `base_path_full`
63
+ - Running `arela identify` on macOS (for testing) → correctly uses the same Windows path
64
+ - Both resolve to the same table lookup
61
65
 
62
66
  ### 2. Full Path in Table Names
63
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arela/uploader",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "CLI to upload files/directories to Arela",
5
5
  "bin": {
6
6
  "arela": "./src/index.js"
@@ -272,18 +272,17 @@ export class PushCommand {
272
272
  break;
273
273
  }
274
274
 
275
- // Upload files in smaller batches
275
+ // Upload files in smaller batches using new CLI upload endpoint
276
276
  for (let i = 0; i < files.length; i += uploadBatchSize) {
277
277
  const uploadBatch = files.slice(i, i + uploadBatchSize);
278
- const batchResults = await this.#uploadBatch(
278
+ const batchResults = await this.#uploadBatchViaCli(
279
+ tableName,
279
280
  uploadBatch,
280
281
  uploadApiConfig,
281
282
  );
282
283
 
283
- // Update results in database
284
- await this.scanApiService.batchUpdateUpload(tableName, batchResults);
285
-
286
- // Update counters
284
+ // Update counters from API response
285
+ // Note: The CLI endpoint now handles updating the scan table directly
287
286
  batchResults.forEach((result) => {
288
287
  results.processed++;
289
288
  if (result.uploaded) {
@@ -317,8 +316,160 @@ export class PushCommand {
317
316
  }
318
317
 
319
318
  /**
320
- * Upload a batch of files
319
+ * Upload a batch of files using the new CLI upload endpoint
320
+ * The endpoint updates the CLI scan table directly
321
+ * @private
322
+ */
323
+ async #uploadBatchViaCli(tableName, files, uploadApiConfig) {
324
+ const pushConfig = appConfig.getPushConfig();
325
+ const results = [];
326
+
327
+ // Process files one by one (simpler for now, can optimize to true batch later)
328
+ for (const file of files) {
329
+ const result = await this.#uploadFileViaCli(
330
+ tableName,
331
+ file,
332
+ uploadApiConfig,
333
+ pushConfig,
334
+ );
335
+ results.push(result);
336
+ }
337
+
338
+ return results;
339
+ }
340
+
341
+ /**
342
+ * Upload a single file using the CLI upload endpoint
343
+ * @private
344
+ */
345
+ async #uploadFileViaCli(tableName, file, uploadApiConfig, pushConfig) {
346
+ const result = {
347
+ id: file.id,
348
+ uploaded: false,
349
+ uploadError: null,
350
+ uploadPath: null,
351
+ uploadedToStorageId: null,
352
+ };
353
+
354
+ try {
355
+ // Check if file exists
356
+ if (!fs.existsSync(file.absolute_path)) {
357
+ result.uploadError =
358
+ 'FILE_NOT_FOUND: File does not exist on filesystem';
359
+ // Update the scan table with the error
360
+ await this.scanApiService.batchUpdateUpload(tableName, [result]);
361
+ return result;
362
+ }
363
+
364
+ // Get file stats
365
+ const stats = fs.statSync(file.absolute_path);
366
+ if (!stats.isFile()) {
367
+ result.uploadError = 'NOT_A_FILE: Path is not a regular file';
368
+ await this.scanApiService.batchUpdateUpload(tableName, [result]);
369
+ return result;
370
+ }
371
+
372
+ // Construct upload path using arela_path
373
+ // arela_path format: RFC/Year/Patente/Aduana/Pedimento/
374
+ const uploadPath = `${file.arela_path}${file.file_name}`;
375
+ result.uploadPath = uploadPath;
376
+
377
+ // Create form data for CLI upload endpoint
378
+ const form = new FormData();
379
+
380
+ // Encode fileId and folderStructure in the filename
381
+ // Format: [fileId][folderStructure]filename
382
+ const folderStructure = file.arela_path.endsWith('/')
383
+ ? file.arela_path.slice(0, -1)
384
+ : file.arela_path;
385
+
386
+ const encodedFilename = `[${file.id}][${folderStructure}]${file.file_name}`;
387
+
388
+ // Create a read stream with the encoded filename
389
+ const fileStream = fs.createReadStream(file.absolute_path);
390
+ form.append('files', fileStream, {
391
+ filename: encodedFilename,
392
+ contentType: this.#getMimeType(file.file_extension),
393
+ });
394
+
395
+ // Add required fields for CLI upload
396
+ form.append('tableName', tableName);
397
+ form.append('rfc', file.rfc);
398
+ form.append('bucket', pushConfig.bucket);
399
+ form.append('autoDetect', 'true');
400
+ form.append('autoOrganize', 'false');
401
+ form.append('batchSize', '1');
402
+ form.append('clientVersion', appConfig.packageVersion);
403
+
404
+ // Upload file using new CLI upload endpoint
405
+ const response = await fetch(
406
+ `${uploadApiConfig.baseUrl}/api/storage/cli-upload`,
407
+ {
408
+ method: 'POST',
409
+ headers: {
410
+ 'x-api-key': uploadApiConfig.token,
411
+ ...form.getHeaders(),
412
+ },
413
+ body: form,
414
+ },
415
+ );
416
+
417
+ if (!response.ok) {
418
+ const errorText = await response.text();
419
+ result.uploadError = `HTTP ${response.status}: ${errorText}`;
420
+ logger.error(`✗ Failed: ${file.file_name} - ${result.uploadError}`);
421
+ return result;
422
+ }
423
+
424
+ const apiResult = await response.json();
425
+
426
+ // Check response from CLI upload endpoint
427
+ if (apiResult.uploaded && apiResult.uploaded.length > 0) {
428
+ const uploadedFile = apiResult.uploaded[0];
429
+ result.uploaded = true;
430
+ result.uploadedToStorageId = uploadedFile.storageId;
431
+ logger.info(`✓ Uploaded: ${file.file_name} → ${uploadPath}`);
432
+ } else if (apiResult.errors && apiResult.errors.length > 0) {
433
+ const error = apiResult.errors[0];
434
+ result.uploadError = `UPLOAD_FAILED: ${error.error || 'Upload failed'}`;
435
+ logger.error(`✗ Failed: ${file.file_name} - ${result.uploadError}`);
436
+ } else {
437
+ result.uploadError = 'Unknown upload error - no files uploaded';
438
+ logger.error(`✗ Failed: ${file.file_name} - ${result.uploadError}`);
439
+ }
440
+ } catch (error) {
441
+ result.uploadError = `UPLOAD_ERROR: ${error.message}`;
442
+ logger.error(`✗ Error uploading ${file.file_name}:`, error.message);
443
+ }
444
+
445
+ return result;
446
+ }
447
+
448
+ /**
449
+ * Get MIME type from file extension
450
+ * @private
451
+ */
452
+ #getMimeType(extension) {
453
+ const mimeTypes = {
454
+ pdf: 'application/pdf',
455
+ doc: 'application/msword',
456
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
457
+ xls: 'application/vnd.ms-excel',
458
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
459
+ txt: 'text/plain',
460
+ jpg: 'image/jpeg',
461
+ jpeg: 'image/jpeg',
462
+ png: 'image/png',
463
+ gif: 'image/gif',
464
+ xml: 'application/xml',
465
+ };
466
+ return mimeTypes[extension?.toLowerCase()] || 'application/octet-stream';
467
+ }
468
+
469
+ /**
470
+ * Upload a batch of files (legacy - kept for compatibility)
321
471
  * @private
472
+ * @deprecated Use #uploadBatchViaCli instead
322
473
  */
323
474
  async #uploadBatch(files, uploadApiConfig) {
324
475
  const uploadPromises = files.map((file) =>
@@ -34,10 +34,10 @@ class Config {
34
34
  const __dirname = path.dirname(__filename);
35
35
  const packageJsonPath = path.resolve(__dirname, '../../package.json');
36
36
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
37
- return packageJson.version || '1.0.4';
37
+ return packageJson.version || '1.0.6';
38
38
  } catch (error) {
39
39
  console.warn('⚠️ Could not read package.json version, using fallback');
40
- return '1.0.4';
40
+ return '1.0.6';
41
41
  }
42
42
  }
43
43
 
@@ -257,11 +257,13 @@ class Config {
257
257
  if (!basePathLabel && process.env.UPLOAD_BASE_PATH) {
258
258
  const basePath = process.env.UPLOAD_BASE_PATH;
259
259
  // Resolve to absolute path (handles ../sample vs ./sample correctly)
260
+ // Note: toAbsolutePath handles Windows paths (O:\...) even on macOS/Linux
260
261
  basePathLabel = PathNormalizer.toAbsolutePath(basePath);
261
262
  }
262
263
 
263
264
  // If basePathLabel is provided, ensure it's absolute
264
- if (basePathLabel && !path.isAbsolute(basePathLabel)) {
265
+ // Use PathNormalizer.isAbsolutePath for cross-platform Windows path detection
266
+ if (basePathLabel && !PathNormalizer.isAbsolutePath(basePathLabel)) {
265
267
  basePathLabel = PathNormalizer.toAbsolutePath(basePathLabel);
266
268
  }
267
269
 
@@ -10,31 +10,83 @@ import { fileURLToPath } from 'url';
10
10
  * 2. Store absolute paths in base_path_full
11
11
  * 3. Normalize ONLY for table name generation (sanitize special chars)
12
12
  * 4. Keep full path structure in table names (hash if too long)
13
+ *
14
+ * Cross-platform support:
15
+ * - Recognizes Windows paths (C:\, D:\, etc.) even when running on macOS/Linux
16
+ * - Preserves Windows paths as-is when detected (for remote server scenarios)
17
+ * - This allows CLI running on macOS to work with paths stored from Windows servers
13
18
  */
14
19
  export class PathNormalizer {
20
+ /**
21
+ * Check if a path is a Windows-style absolute path (has drive letter)
22
+ * This works on any OS, not just Windows
23
+ *
24
+ * Examples:
25
+ * - C:\Users\Documents -> true
26
+ * - O:\expediente\archivos -> true
27
+ * - /home/user/docs -> false
28
+ * - ./relative/path -> false
29
+ *
30
+ * @param {string} inputPath - Path to check
31
+ * @returns {boolean} True if path has a Windows drive letter
32
+ */
33
+ static isWindowsAbsolutePath(inputPath) {
34
+ if (!inputPath || typeof inputPath !== 'string') return false;
35
+ // Match drive letter followed by colon and backslash or forward slash
36
+ // e.g., C:\, D:/, O:\
37
+ return /^[A-Za-z]:[/\\]/.test(inputPath);
38
+ }
39
+
40
+ /**
41
+ * Check if a path is absolute (works cross-platform)
42
+ * Recognizes both Unix and Windows absolute paths regardless of current OS
43
+ *
44
+ * @param {string} inputPath - Path to check
45
+ * @returns {boolean} True if path is absolute (Unix or Windows style)
46
+ */
47
+ static isAbsolutePath(inputPath) {
48
+ if (!inputPath) return false;
49
+ // Check native absolute path first, then Windows-style
50
+ return path.isAbsolute(inputPath) || this.isWindowsAbsolutePath(inputPath);
51
+ }
52
+
15
53
  /**
16
54
  * Resolve a path to an absolute path
17
55
  * This ensures ../sample and ./sample are treated as different paths
18
56
  *
57
+ * Cross-platform behavior:
58
+ * - Windows paths (C:\, O:\, etc.) are preserved as-is, even on macOS/Linux
59
+ * - Unix paths are normalized using native path.resolve
60
+ * - Relative paths are resolved against basePath or cwd
61
+ *
19
62
  * Examples:
20
63
  * - ../sample (from /data/project) -> /data/sample
21
64
  * - ./sample (from /data/project) -> /data/project/sample
22
- * - C:\Users\Documents -> C:\Users\Documents (on Windows)
65
+ * - C:\Users\Documents -> C:\Users\Documents (preserved on any OS)
66
+ * - O:\expediente\archivos -> O:\expediente\archivos (preserved on any OS)
23
67
  * - /home/user/docs -> /home/user/docs (on Unix)
24
68
  *
25
69
  * @param {string} inputPath - Path to resolve
26
70
  * @param {string} basePath - Base path for resolving relative paths (defaults to cwd)
27
- * @returns {string} Absolute path in native OS format
71
+ * @returns {string} Absolute path (native format for Unix, preserved for Windows)
28
72
  */
29
73
  static toAbsolutePath(inputPath, basePath = null) {
30
74
  if (!inputPath) return '';
31
75
 
32
- // If already absolute, return as-is
76
+ // Check for Windows absolute path first (works on any OS)
77
+ // This prevents macOS from treating "O:\path" as relative
78
+ if (this.isWindowsAbsolutePath(inputPath)) {
79
+ // Normalize Windows path but preserve the format
80
+ // Replace forward slashes with backslashes for consistency
81
+ return inputPath.replace(/\//g, '\\');
82
+ }
83
+
84
+ // If native absolute path, return normalized
33
85
  if (path.isAbsolute(inputPath)) {
34
86
  return path.normalize(inputPath);
35
87
  }
36
88
 
37
- // Resolve relative path
89
+ // Resolve relative path against base
38
90
  const base = basePath || process.cwd();
39
91
  return path.resolve(base, inputPath);
40
92
  }
@@ -137,7 +189,7 @@ export class PathNormalizer {
137
189
 
138
190
  const basePrefix = 'scan_';
139
191
  const prefix = `${basePrefix}${sanitizedCompany}_${sanitizedServer}_`;
140
-
192
+
141
193
  // Check if even prefix + hash exceeds limit
142
194
  if (prefix.length + hash.length > 63) {
143
195
  // Company/server names are too long, need to truncate them too
@@ -145,15 +197,15 @@ export class PathNormalizer {
145
197
  const halfLength = Math.floor(maxCompanyServerLength / 2);
146
198
  const companyLength = halfLength;
147
199
  const serverLength = maxCompanyServerLength - companyLength;
148
-
200
+
149
201
  const truncatedCompany = sanitizedCompany.substring(0, companyLength);
150
202
  const truncatedServer = sanitizedServer.substring(0, serverLength);
151
-
203
+
152
204
  tableName = `${basePrefix}${truncatedCompany}_${truncatedServer}_${hash}`;
153
205
  } else {
154
206
  // Preserve start and end of path, put hash in middle
155
207
  const availableSpace = 63 - prefix.length - hash.length - 2; // -2 for underscores around hash
156
-
208
+
157
209
  if (availableSpace <= 0 || !sanitizedPath) {
158
210
  // If no space for path or path is empty, just use hash
159
211
  tableName = `${prefix}${hash}`;
@@ -165,11 +217,13 @@ export class PathNormalizer {
165
217
  const halfSpace = Math.floor(availableSpace / 2);
166
218
  const startLength = halfSpace;
167
219
  const endLength = availableSpace - startLength;
168
-
220
+
169
221
  // Extract start and end portions of the sanitized path
170
222
  const pathStart = sanitizedPath.substring(0, startLength);
171
- const pathEnd = sanitizedPath.substring(sanitizedPath.length - endLength);
172
-
223
+ const pathEnd = sanitizedPath.substring(
224
+ sanitizedPath.length - endLength,
225
+ );
226
+
173
227
  // Build table name with start, hash, and end
174
228
  tableName = `${prefix}${pathStart}_${hash}_${pathEnd}`;
175
229
  }