@arela/uploader 0.0.9 → 0.0.10

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.
Files changed (3) hide show
  1. package/README.md +83 -0
  2. package/package.json +1 -1
  3. package/src/index.js +189 -35
package/README.md CHANGED
@@ -1 +1,84 @@
1
1
  # arela-uploader
2
+
3
+ CLI tool to upload files and directories to Supabase Storage with automatic file renaming and sanitization.
4
+
5
+ ## Features
6
+
7
+ - 📁 Upload entire directories or individual files
8
+ - 🔄 Automatic file renaming to handle problematic characters
9
+ - 📝 Comprehensive logging (local and remote)
10
+ - ⚡ Retry mechanism for failed uploads
11
+ - 🎯 Skip duplicate files automatically
12
+ - 📊 Progress bars and detailed summaries
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install -g @arela/uploader
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ### Basic Upload
23
+ ```bash
24
+ arela -p "my-folder"
25
+ ```
26
+
27
+ ### Upload with File Renaming
28
+ For files with accents, special characters, or problematic names:
29
+
30
+ ```bash
31
+ # Preview what files would be renamed (dry run)
32
+ arela --rename-files --dry-run
33
+
34
+ # Actually rename and upload files
35
+ arela --rename-files -p "documents"
36
+ ```
37
+
38
+ ### Options
39
+
40
+ - `-p, --prefix <prefix>`: Prefix path in bucket (default: "")
41
+ - `-r, --rename-files`: Rename files with problematic characters before uploading
42
+ - `--dry-run`: Show what files would be renamed without actually renaming them
43
+ - `-h, --help`: Display help information
44
+ - `-v, --version`: Display version number
45
+
46
+ ## Environment Variables
47
+
48
+ Create a `.env` file in your project root:
49
+
50
+ ```env
51
+ SUPABASE_URL=your_supabase_url
52
+ SUPABASE_KEY=your_supabase_anon_key
53
+ SUPABASE_BUCKET=your_bucket_name
54
+ UPLOAD_BASE_PATH=/path/to/your/files
55
+ UPLOAD_SOURCES=folder1|folder2|file.pdf
56
+ ```
57
+
58
+ ## File Renaming
59
+
60
+ The tool automatically handles problematic characters by:
61
+
62
+ - Removing accents (á → a, ñ → n, etc.)
63
+ - Replacing special characters with safe alternatives
64
+ - Converting spaces to dashes
65
+ - Removing or replacing symbols like `{}[]~^`|"<>?*:`
66
+ - Handling Korean characters and other Unicode symbols
67
+
68
+ ### Examples
69
+
70
+ | Original | Renamed |
71
+ |----------|---------|
72
+ | `Facturas Importación.pdf` | `Facturas-Importacion.pdf` |
73
+ | `File{with}brackets.pdf` | `File-with-brackets.pdf` |
74
+ | `Document ^& symbols.pdf` | `Document-and-symbols.pdf` |
75
+ | `CI & PL-20221212(멕시코용).xls` | `CI-and-PL-20221212.xls` |
76
+
77
+ ## Logging
78
+
79
+ The tool maintains logs both locally (`upload.log`) and remotely in your Supabase database. Logs include:
80
+
81
+ - Upload status (success/error/skipped)
82
+ - File paths and sanitization changes
83
+ - Error messages and timestamps
84
+ - Rename operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arela/uploader",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "CLI to upload files/directories to Arela",
5
5
  "bin": {
6
6
  "arela": "./src/index.js"
package/src/index.js CHANGED
@@ -27,6 +27,52 @@ const sources = process.env.UPLOAD_SOURCES?.split('|')
27
27
 
28
28
  const supabase = createClient(supabaseUrl, supabaseKey);
29
29
 
30
+ // Enhanced sanitization function specifically for file names
31
+ const sanitizeFileName = (fileName) => {
32
+ // Get file extension
33
+ const ext = path.extname(fileName);
34
+ const nameWithoutExt = path.basename(fileName, ext);
35
+
36
+ // Replace problematic characters with safe alternatives
37
+ let sanitized = nameWithoutExt
38
+ // First, handle common character replacements
39
+ .replace(/[áàâäãåāăą]/gi, 'a')
40
+ .replace(/[éèêëēĕėę]/gi, 'e')
41
+ .replace(/[íìîïīĭį]/gi, 'i')
42
+ .replace(/[óòôöõōŏő]/gi, 'o')
43
+ .replace(/[úùûüūŭů]/gi, 'u')
44
+ .replace(/[ñň]/gi, 'n')
45
+ .replace(/[ç]/gi, 'c')
46
+ .replace(/[ý]/gi, 'y')
47
+ // Handle Korean characters more comprehensively
48
+ .replace(/[멕]/g, 'meok')
49
+ .replace(/[시]/g, 'si')
50
+ .replace(/[코]/g, 'ko')
51
+ .replace(/[용]/g, 'yong')
52
+ .replace(/[가-힣]/g, 'kr') // Replace any remaining Korean chars with 'kr'
53
+ // Normalize unicode and remove diacritics
54
+ .normalize('NFD')
55
+ .replace(/[\u0300-\u036f]/g, '') // Remove diacritics
56
+ // Replace problematic symbols
57
+ .replace(/[\\?%*:|"<>[\]~`^]/g, '-')
58
+ .replace(/[{}]/g, '-')
59
+ .replace(/[&]/g, 'and')
60
+ .replace(/[()]/g, '') // Remove parentheses
61
+ // Clean up spaces and dashes
62
+ .replace(/\s+/g, '-') // Replace spaces with dashes
63
+ .replace(/-+/g, '-') // Replace multiple dashes with single dash
64
+ .replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
65
+ .replace(/^\.+/, '') // Remove leading dots
66
+ .replace(/[^\w.-]/g, ''); // Remove any remaining non-alphanumeric chars except dots and dashes
67
+
68
+ // Ensure the filename is not empty
69
+ if (!sanitized) {
70
+ sanitized = 'unnamed_file';
71
+ }
72
+
73
+ return sanitized + ext;
74
+ };
75
+
30
76
  const sanitizePath = (path) =>
31
77
  path
32
78
  .replace(/[\\?%*:|"<>[\]~]/g, '-')
@@ -189,23 +235,68 @@ const getProcessedPaths = async () => {
189
235
 
190
236
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
191
237
 
192
- const uploadWithRetry = async (uploadFn, maxRetries = 3, delayMs = 1000) => {
238
+ const uploadWithRetry = async (uploadFn, maxRetries = 5, delayMs = 2000) => {
193
239
  let attempt = 0;
240
+ let lastError;
241
+
194
242
  while (attempt < maxRetries) {
195
- const result = await uploadFn();
196
- if (!result.error) return result;
197
- attempt++;
198
- if (attempt < maxRetries) await delay(delayMs);
243
+ try {
244
+ const result = await uploadFn();
245
+ if (!result.error) return result;
246
+ lastError = result.error;
247
+ attempt++;
248
+
249
+ // Exponential backoff with jitter
250
+ if (attempt < maxRetries) {
251
+ const backoffDelay =
252
+ delayMs * Math.pow(2, attempt - 1) + Math.random() * 1000;
253
+ console.log(
254
+ `Retry ${attempt}/${maxRetries} after ${Math.round(backoffDelay)}ms...`,
255
+ );
256
+ await delay(backoffDelay);
257
+ }
258
+ } catch (error) {
259
+ lastError = error;
260
+ attempt++;
261
+
262
+ if (attempt < maxRetries) {
263
+ const backoffDelay =
264
+ delayMs * Math.pow(2, attempt - 1) + Math.random() * 1000;
265
+ console.log(
266
+ `Retry ${attempt}/${maxRetries} after ${Math.round(backoffDelay)}ms due to exception...`,
267
+ );
268
+ await delay(backoffDelay);
269
+ }
270
+ }
199
271
  }
200
- return { error: new Error('Max retries exceeded') };
272
+
273
+ return {
274
+ error: new Error(
275
+ `Max retries exceeded. Last error: ${lastError?.message || 'Unknown error'}`,
276
+ ),
277
+ };
201
278
  };
202
279
 
203
280
  program
204
281
  .name('supabase-uploader')
205
282
  .description('CLI to upload folders from a base path to Supabase Storage')
206
- .version(version)
283
+ .option('-v, --version', 'output the version number')
207
284
  .option('-p, --prefix <prefix>', 'Prefix path in bucket', '')
285
+ .option(
286
+ '-r, --rename-files',
287
+ 'Rename files with problematic characters before uploading',
288
+ )
289
+ .option(
290
+ '--dry-run',
291
+ 'Show what files would be renamed without actually renaming them',
292
+ )
208
293
  .action(async (options) => {
294
+ // Handle version option
295
+ if (options.version) {
296
+ console.log(version);
297
+ process.exit(0);
298
+ }
299
+
209
300
  if (!basePath || !sources || sources.length === 0) {
210
301
  console.error(
211
302
  '⚠️ UPLOAD_BASE_PATH or UPLOAD_SOURCES not defined in environment variables.',
@@ -240,41 +331,92 @@ program
240
331
 
241
332
  for (const file of files) {
242
333
  progressBar.increment();
243
- const content = fs.readFileSync(file);
334
+
335
+ let currentFile = file;
336
+
337
+ // Check if we need to rename the file
338
+ if (options.renameFiles) {
339
+ const originalName = path.basename(file);
340
+ const sanitizedName = sanitizeFileName(originalName);
341
+
342
+ if (originalName !== sanitizedName) {
343
+ const newFilePath = path.join(path.dirname(file), sanitizedName);
344
+
345
+ if (options.dryRun) {
346
+ console.log(`Would rename: ${originalName} → ${sanitizedName}`);
347
+ continue;
348
+ } else {
349
+ try {
350
+ fs.renameSync(file, newFilePath);
351
+ currentFile = newFilePath;
352
+ console.log(`✅ Renamed: ${originalName} → ${sanitizedName}`);
353
+ writeLog(`RENAMED: ${originalName} → ${sanitizedName}`);
354
+ await sendLogToSupabase({
355
+ file: originalName,
356
+ uploadPath: sanitizedName,
357
+ status: 'renamed',
358
+ message: `Renamed from ${originalName}`,
359
+ });
360
+ } catch (renameError) {
361
+ console.error(
362
+ `❌ Failed to rename ${originalName}: ${renameError.message}`,
363
+ );
364
+ writeLog(
365
+ `RENAME_ERROR: ${originalName} | ${renameError.message}`,
366
+ );
367
+ continue;
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ const content = fs.readFileSync(currentFile);
244
374
  const relativePathRaw = path
245
- .relative(basePath, file)
375
+ .relative(basePath, currentFile)
246
376
  .replace(/^[\\/]+/, '')
247
377
  .replace(/\\/g, '/');
378
+
379
+ // Always sanitize the filename for upload path
380
+ const pathParts = relativePathRaw.split('/');
381
+ const originalFileName = pathParts[pathParts.length - 1];
382
+ const sanitizedFileName = sanitizeFileName(originalFileName);
383
+ pathParts[pathParts.length - 1] = sanitizedFileName;
384
+ const sanitizedRelativePath = pathParts.join('/');
385
+
248
386
  const uploadPathRaw = options.prefix
249
- ? path.posix.join(options.prefix, relativePathRaw)
250
- : relativePathRaw;
387
+ ? path.posix.join(options.prefix, sanitizedRelativePath)
388
+ : sanitizedRelativePath;
251
389
  const uploadPath = sanitizePath(uploadPathRaw);
252
390
 
253
- if (uploadPath !== uploadPathRaw) {
254
- writeLog(`SANITIZED: ${uploadPathRaw} → ${uploadPath}`);
391
+ if (
392
+ uploadPath !== uploadPathRaw ||
393
+ originalFileName !== sanitizedFileName
394
+ ) {
395
+ writeLog(`SANITIZED: ${relativePathRaw} → ${uploadPath}`);
255
396
  await sendLogToSupabase({
256
- file,
257
- uploadPath: uploadPathRaw,
397
+ file: currentFile,
398
+ uploadPath: relativePathRaw,
258
399
  status: 'sanitized',
259
- message: `Sanitized to ${uploadPath}`,
400
+ message: `Sanitized to ${uploadPath} (Arela Version: ${version})`,
260
401
  });
261
402
  }
262
403
 
263
404
  if (processedPaths.has(uploadPath)) {
264
- ora().info(`⏭️ Already processed (log): ${file}`);
405
+ ora().info(`⏭️ Already processed (log): ${currentFile}`);
265
406
  continue;
266
407
  }
267
408
 
268
- const contentType = mime.lookup(file) || 'application/octet-stream';
409
+ const contentType =
410
+ mime.lookup(currentFile) || 'application/octet-stream';
269
411
 
270
- const spinner = ora(`Checking ${file}...`).start();
412
+ const spinner = ora(`Checking ${currentFile}...`).start();
271
413
  const exists = await fileExistsInBucket(uploadPath);
272
414
 
273
415
  if (exists) {
274
- spinner.info(`⏭️ Skipped (already exists): ${file}`);
275
- writeLog(`SKIPPED: ${file} -> ${uploadPath}`);
416
+ spinner.info(`⏭️ Skipped (already exists): ${currentFile}`);
417
+ writeLog(`SKIPPED: ${currentFile} -> ${uploadPath}`);
276
418
  await sendLogToSupabase({
277
- file,
419
+ file: currentFile,
278
420
  uploadPath,
279
421
  status: 'skipped',
280
422
  message: 'Already exists in bucket',
@@ -285,17 +427,21 @@ program
285
427
  try {
286
428
  // await delay(5000); // TODO: Remove this delay before production
287
429
 
430
+ spinner.text = `Uploading ${currentFile}...`;
431
+
288
432
  const { error } = await uploadWithRetry(() =>
289
433
  supabase.storage.from(bucket).upload(uploadPath, content, {
290
434
  upsert: true,
291
435
  contentType,
292
436
  metadata: {
293
- originalName: path.basename(file),
437
+ originalName: path.basename(currentFile),
438
+ sanitizedName: path.basename(uploadPath),
294
439
  clientPath: path.posix.join(
295
440
  basePath,
296
441
  folder,
297
- path.relative(sourcePath, file).replace(/\\/g, '/'),
442
+ path.relative(sourcePath, currentFile).replace(/\\/g, '/'),
298
443
  ),
444
+ arelaVersion: version,
299
445
  },
300
446
  }),
301
447
  );
@@ -303,31 +449,39 @@ program
303
449
  if (error) {
304
450
  failureCount++;
305
451
  globalFailure++;
306
- spinner.fail(
307
- `❌ Failed to upload ${file}: ${JSON.stringify(error, null, 2)}`,
308
- );
309
- writeLog(`ERROR: ${file} -> ${uploadPath} | ${error.message}`);
452
+ const errorMsg = error.message || JSON.stringify(error);
453
+ spinner.fail(`❌ Failed to upload ${currentFile}: ${errorMsg}`);
454
+ writeLog(`ERROR: ${currentFile} -> ${uploadPath} | ${errorMsg}`);
310
455
  await sendLogToSupabase({
311
- file,
456
+ file: currentFile,
312
457
  uploadPath,
313
458
  status: 'error',
314
- message: error.message,
459
+ message: errorMsg,
315
460
  });
316
461
  } else {
317
462
  successCount++;
318
463
  globalSuccess++;
319
- spinner.succeed(`✅ Uploaded ${file} -> ${uploadPath}`);
320
- writeLog(`SUCCESS: ${file} -> ${uploadPath}`);
464
+ spinner.succeed(`✅ Uploaded ${currentFile} -> ${uploadPath}`);
465
+ writeLog(`SUCCESS: ${currentFile} -> ${uploadPath}`);
321
466
  await sendLogToSupabase({
322
- file,
467
+ file: currentFile,
323
468
  uploadPath,
324
469
  status: 'success',
325
470
  message: 'Uploaded successfully',
326
471
  });
327
472
  }
328
473
  } catch (err) {
329
- spinner.fail(`❌ Error uploading ${file}: ${err.message}`);
330
- writeLog(`❌ Error uploading ${file}: ${err.message}`);
474
+ failureCount++;
475
+ globalFailure++;
476
+ const errorMsg = err.message || JSON.stringify(err);
477
+ spinner.fail(`❌ Error uploading ${currentFile}: ${errorMsg}`);
478
+ writeLog(`ERROR: ${currentFile} -> ${uploadPath} | ${errorMsg}`);
479
+ await sendLogToSupabase({
480
+ file: currentFile,
481
+ uploadPath,
482
+ status: 'error',
483
+ message: errorMsg,
484
+ });
331
485
  }
332
486
  }
333
487