@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.
- package/README.md +83 -0
- package/package.json +1 -1
- 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
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 =
|
|
238
|
+
const uploadWithRetry = async (uploadFn, maxRetries = 5, delayMs = 2000) => {
|
|
193
239
|
let attempt = 0;
|
|
240
|
+
let lastError;
|
|
241
|
+
|
|
194
242
|
while (attempt < maxRetries) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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,
|
|
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,
|
|
250
|
-
:
|
|
387
|
+
? path.posix.join(options.prefix, sanitizedRelativePath)
|
|
388
|
+
: sanitizedRelativePath;
|
|
251
389
|
const uploadPath = sanitizePath(uploadPathRaw);
|
|
252
390
|
|
|
253
|
-
if (
|
|
254
|
-
|
|
391
|
+
if (
|
|
392
|
+
uploadPath !== uploadPathRaw ||
|
|
393
|
+
originalFileName !== sanitizedFileName
|
|
394
|
+
) {
|
|
395
|
+
writeLog(`SANITIZED: ${relativePathRaw} → ${uploadPath}`);
|
|
255
396
|
await sendLogToSupabase({
|
|
256
|
-
file,
|
|
257
|
-
uploadPath:
|
|
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): ${
|
|
405
|
+
ora().info(`⏭️ Already processed (log): ${currentFile}`);
|
|
265
406
|
continue;
|
|
266
407
|
}
|
|
267
408
|
|
|
268
|
-
const contentType =
|
|
409
|
+
const contentType =
|
|
410
|
+
mime.lookup(currentFile) || 'application/octet-stream';
|
|
269
411
|
|
|
270
|
-
const spinner = ora(`Checking ${
|
|
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): ${
|
|
275
|
-
writeLog(`SKIPPED: ${
|
|
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(
|
|
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,
|
|
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
|
-
|
|
307
|
-
|
|
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:
|
|
459
|
+
message: errorMsg,
|
|
315
460
|
});
|
|
316
461
|
} else {
|
|
317
462
|
successCount++;
|
|
318
463
|
globalSuccess++;
|
|
319
|
-
spinner.succeed(`✅ Uploaded ${
|
|
320
|
-
writeLog(`SUCCESS: ${
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|