@arela/uploader 0.0.8 → 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 +253 -44
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.8",
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, '-')
@@ -122,16 +168,66 @@ const writeLog = (message) => {
122
168
  }
123
169
  };
124
170
 
125
- const getProcessedPaths = () => {
126
- const lines = fs.existsSync(logFilePath)
127
- ? fs.readFileSync(logFilePath, 'utf-8').split('\n')
128
- : [];
171
+ // Modified to fetch from Supabase first, then fallback to local log
172
+ const getProcessedPaths = async () => {
129
173
  const processed = new Set();
130
- for (const line of lines) {
131
- const match = line.match(/(SUCCESS|SKIPPED): .*? -> (.+)/);
132
- if (match) {
133
- const [, , path] = match;
134
- processed.add(path.trim());
174
+
175
+ // Try to fetch from Supabase first
176
+ try {
177
+ const { data, error } = await supabase
178
+ .from('upload_logs')
179
+ .select('path')
180
+ .in('status', ['success', 'skipped']);
181
+
182
+ if (error) {
183
+ console.warn(
184
+ `⚠️ Could not fetch processed paths from Supabase: ${error.message}. Falling back to local log.`,
185
+ );
186
+ // Fallback to local log if Supabase fetch fails
187
+ const lines = fs.existsSync(logFilePath)
188
+ ? fs.readFileSync(logFilePath, 'utf-8').split('\\n')
189
+ : [];
190
+ for (const line of lines) {
191
+ const match = line.match(/(SUCCESS|SKIPPED): .*? -> (.+)/);
192
+ if (match) {
193
+ const [, , path] = match;
194
+ processed.add(path.trim());
195
+ }
196
+ }
197
+ } else if (data) {
198
+ data.forEach((log) => {
199
+ if (log.path) {
200
+ processed.add(log.path.trim());
201
+ }
202
+ });
203
+ // Also read from local log to ensure any paths logged before this change or during a Supabase outage are included
204
+ const lines = fs.existsSync(logFilePath)
205
+ ? fs.readFileSync(logFilePath, 'utf-8').split('\\n')
206
+ : [];
207
+ for (const line of lines) {
208
+ const match = line.match(/(SUCCESS|SKIPPED): .*? -> (.+)/);
209
+ if (match) {
210
+ const [, , pathValue] = match;
211
+ if (pathValue) {
212
+ processed.add(pathValue.trim());
213
+ }
214
+ }
215
+ }
216
+ }
217
+ } catch (e) {
218
+ console.warn(
219
+ `⚠️ Error fetching from Supabase or reading local log: ${e.message}. Proceeding with an empty set of processed paths initially.`,
220
+ );
221
+ // Ensure local log is still attempted if Supabase connection itself fails
222
+ const lines = fs.existsSync(logFilePath)
223
+ ? fs.readFileSync(logFilePath, 'utf-8').split('\\n')
224
+ : [];
225
+ for (const line of lines) {
226
+ const match = line.match(/(SUCCESS|SKIPPED): .*? -> (.+)/);
227
+ if (match) {
228
+ const [, , path] = match;
229
+ processed.add(path.trim());
230
+ }
135
231
  }
136
232
  }
137
233
  return processed;
@@ -139,23 +235,68 @@ const getProcessedPaths = () => {
139
235
 
140
236
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
141
237
 
142
- const uploadWithRetry = async (uploadFn, maxRetries = 3, delayMs = 1000) => {
238
+ const uploadWithRetry = async (uploadFn, maxRetries = 5, delayMs = 2000) => {
143
239
  let attempt = 0;
240
+ let lastError;
241
+
144
242
  while (attempt < maxRetries) {
145
- const result = await uploadFn();
146
- if (!result.error) return result;
147
- attempt++;
148
- 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
+ }
149
271
  }
150
- 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
+ };
151
278
  };
152
279
 
153
280
  program
154
281
  .name('supabase-uploader')
155
282
  .description('CLI to upload folders from a base path to Supabase Storage')
156
- .version(version)
283
+ .option('-v, --version', 'output the version number')
157
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
+ )
158
293
  .action(async (options) => {
294
+ // Handle version option
295
+ if (options.version) {
296
+ console.log(version);
297
+ process.exit(0);
298
+ }
299
+
159
300
  if (!basePath || !sources || sources.length === 0) {
160
301
  console.error(
161
302
  '⚠️ UPLOAD_BASE_PATH or UPLOAD_SOURCES not defined in environment variables.',
@@ -163,7 +304,7 @@ program
163
304
  process.exit(1);
164
305
  }
165
306
 
166
- const processedPaths = getProcessedPaths();
307
+ const processedPaths = await getProcessedPaths();
167
308
  let globalSuccess = 0;
168
309
  let globalFailure = 0;
169
310
 
@@ -190,41 +331,92 @@ program
190
331
 
191
332
  for (const file of files) {
192
333
  progressBar.increment();
193
- 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);
194
374
  const relativePathRaw = path
195
- .relative(basePath, file)
375
+ .relative(basePath, currentFile)
196
376
  .replace(/^[\\/]+/, '')
197
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
+
198
386
  const uploadPathRaw = options.prefix
199
- ? path.posix.join(options.prefix, relativePathRaw)
200
- : relativePathRaw;
387
+ ? path.posix.join(options.prefix, sanitizedRelativePath)
388
+ : sanitizedRelativePath;
201
389
  const uploadPath = sanitizePath(uploadPathRaw);
202
390
 
203
- if (uploadPath !== uploadPathRaw) {
204
- writeLog(`SANITIZED: ${uploadPathRaw} → ${uploadPath}`);
391
+ if (
392
+ uploadPath !== uploadPathRaw ||
393
+ originalFileName !== sanitizedFileName
394
+ ) {
395
+ writeLog(`SANITIZED: ${relativePathRaw} → ${uploadPath}`);
205
396
  await sendLogToSupabase({
206
- file,
207
- uploadPath: uploadPathRaw,
397
+ file: currentFile,
398
+ uploadPath: relativePathRaw,
208
399
  status: 'sanitized',
209
- message: `Sanitized to ${uploadPath}`,
400
+ message: `Sanitized to ${uploadPath} (Arela Version: ${version})`,
210
401
  });
211
402
  }
212
403
 
213
404
  if (processedPaths.has(uploadPath)) {
214
- ora().info(`⏭️ Already processed (log): ${file}`);
405
+ ora().info(`⏭️ Already processed (log): ${currentFile}`);
215
406
  continue;
216
407
  }
217
408
 
218
- const contentType = mime.lookup(file) || 'application/octet-stream';
409
+ const contentType =
410
+ mime.lookup(currentFile) || 'application/octet-stream';
219
411
 
220
- const spinner = ora(`Checking ${file}...`).start();
412
+ const spinner = ora(`Checking ${currentFile}...`).start();
221
413
  const exists = await fileExistsInBucket(uploadPath);
222
414
 
223
415
  if (exists) {
224
- spinner.info(`⏭️ Skipped (already exists): ${file}`);
225
- writeLog(`SKIPPED: ${file} -> ${uploadPath}`);
416
+ spinner.info(`⏭️ Skipped (already exists): ${currentFile}`);
417
+ writeLog(`SKIPPED: ${currentFile} -> ${uploadPath}`);
226
418
  await sendLogToSupabase({
227
- file,
419
+ file: currentFile,
228
420
  uploadPath,
229
421
  status: 'skipped',
230
422
  message: 'Already exists in bucket',
@@ -235,12 +427,21 @@ program
235
427
  try {
236
428
  // await delay(5000); // TODO: Remove this delay before production
237
429
 
430
+ spinner.text = `Uploading ${currentFile}...`;
431
+
238
432
  const { error } = await uploadWithRetry(() =>
239
433
  supabase.storage.from(bucket).upload(uploadPath, content, {
240
434
  upsert: true,
241
435
  contentType,
242
436
  metadata: {
243
- originalName: path.basename(file),
437
+ originalName: path.basename(currentFile),
438
+ sanitizedName: path.basename(uploadPath),
439
+ clientPath: path.posix.join(
440
+ basePath,
441
+ folder,
442
+ path.relative(sourcePath, currentFile).replace(/\\/g, '/'),
443
+ ),
444
+ arelaVersion: version,
244
445
  },
245
446
  }),
246
447
  );
@@ -248,31 +449,39 @@ program
248
449
  if (error) {
249
450
  failureCount++;
250
451
  globalFailure++;
251
- spinner.fail(
252
- `❌ Failed to upload ${file}: ${JSON.stringify(error, null, 2)}`,
253
- );
254
- 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}`);
255
455
  await sendLogToSupabase({
256
- file,
456
+ file: currentFile,
257
457
  uploadPath,
258
458
  status: 'error',
259
- message: error.message,
459
+ message: errorMsg,
260
460
  });
261
461
  } else {
262
462
  successCount++;
263
463
  globalSuccess++;
264
- spinner.succeed(`✅ Uploaded ${file} -> ${uploadPath}`);
265
- writeLog(`SUCCESS: ${file} -> ${uploadPath}`);
464
+ spinner.succeed(`✅ Uploaded ${currentFile} -> ${uploadPath}`);
465
+ writeLog(`SUCCESS: ${currentFile} -> ${uploadPath}`);
266
466
  await sendLogToSupabase({
267
- file,
467
+ file: currentFile,
268
468
  uploadPath,
269
469
  status: 'success',
270
470
  message: 'Uploaded successfully',
271
471
  });
272
472
  }
273
473
  } catch (err) {
274
- spinner.fail(`❌ Error uploading ${file}: ${err.message}`);
275
- 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
+ });
276
485
  }
277
486
  }
278
487