@arela/uploader 1.0.3 ā 1.0.5
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/.env.local +316 -0
- package/coverage/IdentifyCommand.js.html +1462 -0
- package/coverage/PropagateCommand.js.html +1507 -0
- package/coverage/PushCommand.js.html +1504 -0
- package/coverage/ScanCommand.js.html +1654 -0
- package/coverage/UploadCommand.js.html +1846 -0
- package/coverage/WatchCommand.js.html +4111 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +191 -0
- package/coverage/lcov-report/IdentifyCommand.js.html +1462 -0
- package/coverage/lcov-report/PropagateCommand.js.html +1507 -0
- package/coverage/lcov-report/PushCommand.js.html +1504 -0
- package/coverage/lcov-report/ScanCommand.js.html +1654 -0
- package/coverage/lcov-report/UploadCommand.js.html +1846 -0
- package/coverage/lcov-report/WatchCommand.js.html +4111 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +191 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +1937 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/docs/CROSS_PLATFORM_PATH_HANDLING.md +597 -0
- package/package.json +28 -2
- package/src/commands/IdentifyCommand.js +1 -28
- package/src/commands/PropagateCommand.js +1 -1
- package/src/commands/PushCommand.js +1 -1
- package/src/commands/ScanCommand.js +27 -20
- package/src/config/config.js +27 -48
- package/src/services/ScanApiService.js +4 -5
- package/src/utils/PathNormalizer.js +272 -0
- package/tests/commands/IdentifyCommand.test.js +570 -0
- package/tests/commands/PropagateCommand.test.js +568 -0
- package/tests/commands/PushCommand.test.js +754 -0
- package/tests/commands/ScanCommand.test.js +382 -0
- package/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
|
@@ -10,6 +10,7 @@ import appConfig from '../config/config.js';
|
|
|
10
10
|
import ErrorHandler from '../errors/ErrorHandler.js';
|
|
11
11
|
import { ConfigurationError } from '../errors/ErrorTypes.js';
|
|
12
12
|
import FileOperations from '../utils/FileOperations.js';
|
|
13
|
+
import PathNormalizer from '../utils/PathNormalizer.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Scan Command Handler
|
|
@@ -40,13 +41,13 @@ export class ScanCommand {
|
|
|
40
41
|
this.scanApiService = new ScanApiService();
|
|
41
42
|
|
|
42
43
|
const scanConfig = appConfig.getScanConfig();
|
|
43
|
-
|
|
44
|
+
// Ensure basePath is absolute for scan operations
|
|
45
|
+
const basePath = PathNormalizer.toAbsolutePath(appConfig.getBasePath());
|
|
44
46
|
|
|
45
47
|
logger.info('š Starting arela scan command');
|
|
46
48
|
logger.info(`š¦ Company: ${scanConfig.companySlug}`);
|
|
47
49
|
logger.info(`š„ļø Server: ${scanConfig.serverId}`);
|
|
48
50
|
logger.info(`š Base Path: ${basePath}`);
|
|
49
|
-
logger.info(`š·ļø Label: ${scanConfig.basePathLabel}`);
|
|
50
51
|
logger.info(`š Directory Level: ${scanConfig.directoryLevel}`);
|
|
51
52
|
|
|
52
53
|
// Step 1: Discover directories at specified level
|
|
@@ -63,15 +64,14 @@ export class ScanCommand {
|
|
|
63
64
|
logger.info('\nš Registering scan instances...');
|
|
64
65
|
const registrations = [];
|
|
65
66
|
for (const dir of directories) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// dir.path is already absolute from #discoverDirectories
|
|
68
|
+
// Use the absolute path as basePathLabel (simplifies everything!)
|
|
69
|
+
const absolutePath = dir.path;
|
|
69
70
|
|
|
70
71
|
const registration = await this.scanApiService.registerInstance({
|
|
71
72
|
companySlug: scanConfig.companySlug,
|
|
72
73
|
serverId: scanConfig.serverId,
|
|
73
|
-
|
|
74
|
-
basePathFull: dir.path,
|
|
74
|
+
basePathFull: absolutePath,
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
registrations.push({ ...registration, directory: dir });
|
|
@@ -181,8 +181,8 @@ export class ScanCommand {
|
|
|
181
181
|
return sources.map((source) => {
|
|
182
182
|
const sourcePath =
|
|
183
183
|
source === '.' ? basePath : path.resolve(basePath, source);
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
// Label is relative path for display purposes only
|
|
185
|
+
const label = source === '.' ? '' : source;
|
|
186
186
|
return { path: sourcePath, label };
|
|
187
187
|
});
|
|
188
188
|
}
|
|
@@ -204,20 +204,20 @@ export class ScanCommand {
|
|
|
204
204
|
// Source is current directory, use discovered path as-is
|
|
205
205
|
directories.push(levelDir);
|
|
206
206
|
} else {
|
|
207
|
-
// Append source to
|
|
207
|
+
// Append source to path
|
|
208
208
|
const combinedPath = path.resolve(levelDir.path, source);
|
|
209
|
-
const sourceLabel = source.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
210
|
-
const combinedLabel = levelDir.label
|
|
211
|
-
? `${levelDir.label}_${sourceLabel}`
|
|
212
|
-
: sourceLabel;
|
|
213
209
|
|
|
214
210
|
// Only add if the combined path actually exists
|
|
215
211
|
try {
|
|
216
212
|
const stats = await fs.stat(combinedPath);
|
|
217
213
|
if (stats.isDirectory()) {
|
|
214
|
+
// Label for display
|
|
215
|
+
const label = levelDir.label
|
|
216
|
+
? `${levelDir.label}/${source}`
|
|
217
|
+
: source;
|
|
218
218
|
directories.push({
|
|
219
219
|
path: combinedPath,
|
|
220
|
-
label
|
|
220
|
+
label,
|
|
221
221
|
});
|
|
222
222
|
} else {
|
|
223
223
|
logger.debug(`āļø Skipping ${combinedPath} (not a directory)`);
|
|
@@ -249,8 +249,9 @@ export class ScanCommand {
|
|
|
249
249
|
const fullPath = path.join(basePath, currentPath);
|
|
250
250
|
|
|
251
251
|
if (currentLevel === targetLevel) {
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// Label is the relative path for display
|
|
253
|
+
const label = currentPath || '';
|
|
254
|
+
return [{ path: fullPath, label }];
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
const directories = [];
|
|
@@ -418,20 +419,26 @@ export class ScanCommand {
|
|
|
418
419
|
|
|
419
420
|
/**
|
|
420
421
|
* Normalize file record for database insertion
|
|
422
|
+
* Stores paths with forward slashes for consistency but keeps them absolute
|
|
421
423
|
* @private
|
|
422
424
|
*/
|
|
423
425
|
#normalizeFileRecord(filePath, fileStats, basePath, scanTimestamp) {
|
|
424
426
|
const fileName = path.basename(filePath);
|
|
425
427
|
const fileExtension = path.extname(filePath).toLowerCase().replace('.', '');
|
|
426
|
-
|
|
427
|
-
|
|
428
|
+
|
|
429
|
+
// Normalize separators to forward slashes for consistency
|
|
430
|
+
const directoryPath = PathNormalizer.normalizeSeparators(
|
|
431
|
+
path.dirname(filePath),
|
|
432
|
+
);
|
|
433
|
+
const relativePath = PathNormalizer.getRelativePath(filePath, basePath);
|
|
434
|
+
const absolutePath = PathNormalizer.normalizeSeparators(filePath);
|
|
428
435
|
|
|
429
436
|
return {
|
|
430
437
|
fileName,
|
|
431
438
|
fileExtension,
|
|
432
439
|
directoryPath,
|
|
433
440
|
relativePath,
|
|
434
|
-
absolutePath
|
|
441
|
+
absolutePath,
|
|
435
442
|
sizeBytes: Number(fileStats.size),
|
|
436
443
|
modifiedAt: fileStats.mtime.toISOString(),
|
|
437
444
|
scanTimestamp,
|
package/src/config/config.js
CHANGED
|
@@ -3,6 +3,8 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
|
|
6
|
+
import PathNormalizer from '../utils/PathNormalizer.js';
|
|
7
|
+
|
|
6
8
|
config();
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -32,10 +34,10 @@ class Config {
|
|
|
32
34
|
const __dirname = path.dirname(__filename);
|
|
33
35
|
const packageJsonPath = path.resolve(__dirname, '../../package.json');
|
|
34
36
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
35
|
-
return packageJson.version || '1.0.
|
|
37
|
+
return packageJson.version || '1.0.5';
|
|
36
38
|
} catch (error) {
|
|
37
39
|
console.warn('ā ļø Could not read package.json version, using fallback');
|
|
38
|
-
return '1.0.
|
|
40
|
+
return '1.0.5';
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -251,13 +253,18 @@ class Config {
|
|
|
251
253
|
let basePathLabel = process.env.ARELA_BASE_PATH_LABEL;
|
|
252
254
|
|
|
253
255
|
// Auto-derive basePathLabel from UPLOAD_BASE_PATH if not set
|
|
256
|
+
// IMPORTANT: Always resolve to absolute path for uniqueness
|
|
254
257
|
if (!basePathLabel && process.env.UPLOAD_BASE_PATH) {
|
|
255
258
|
const basePath = process.env.UPLOAD_BASE_PATH;
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
basePathLabel =
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
// Resolve to absolute path (handles ../sample vs ./sample correctly)
|
|
260
|
+
// Note: toAbsolutePath handles Windows paths (O:\...) even on macOS/Linux
|
|
261
|
+
basePathLabel = PathNormalizer.toAbsolutePath(basePath);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// If basePathLabel is provided, ensure it's absolute
|
|
265
|
+
// Use PathNormalizer.isAbsolutePath for cross-platform Windows path detection
|
|
266
|
+
if (basePathLabel && !PathNormalizer.isAbsolutePath(basePathLabel)) {
|
|
267
|
+
basePathLabel = PathNormalizer.toAbsolutePath(basePathLabel);
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
// Parse exclude patterns
|
|
@@ -271,16 +278,21 @@ class Config {
|
|
|
271
278
|
.filter(Boolean);
|
|
272
279
|
|
|
273
280
|
// Generate table name if all components are available
|
|
281
|
+
// Note: This is just for reference; actual table names are generated dynamically
|
|
282
|
+
// in ScanCommand based on discovered directories and levels
|
|
274
283
|
let tableName = null;
|
|
275
284
|
if (companySlug && serverId && basePathLabel) {
|
|
276
|
-
|
|
277
|
-
|
|
285
|
+
tableName = PathNormalizer.generateTableName({
|
|
286
|
+
companySlug,
|
|
287
|
+
serverId,
|
|
288
|
+
basePathLabel,
|
|
289
|
+
});
|
|
278
290
|
}
|
|
279
291
|
|
|
280
292
|
return {
|
|
281
293
|
companySlug,
|
|
282
294
|
serverId,
|
|
283
|
-
basePathLabel,
|
|
295
|
+
basePathFull: basePathLabel, // Renamed for consistency
|
|
284
296
|
tableName,
|
|
285
297
|
excludePatterns,
|
|
286
298
|
batchSize: parseInt(process.env.SCAN_BATCH_SIZE) || 2000,
|
|
@@ -311,38 +323,6 @@ class Config {
|
|
|
311
323
|
};
|
|
312
324
|
}
|
|
313
325
|
|
|
314
|
-
/**
|
|
315
|
-
* Sanitize and generate table name
|
|
316
|
-
* @private
|
|
317
|
-
*/
|
|
318
|
-
#sanitizeTableName(rawName) {
|
|
319
|
-
// Sanitize: lowercase, replace special chars with underscore
|
|
320
|
-
let sanitized = rawName
|
|
321
|
-
.toLowerCase()
|
|
322
|
-
.replace(/[^a-z0-9_]/g, '_')
|
|
323
|
-
.replace(/_+/g, '_')
|
|
324
|
-
.replace(/^_|_$/g, '');
|
|
325
|
-
|
|
326
|
-
const prefix = 'scan_';
|
|
327
|
-
let tableName = prefix + sanitized;
|
|
328
|
-
|
|
329
|
-
// PostgreSQL table name limit is 63 characters
|
|
330
|
-
if (tableName.length > 63) {
|
|
331
|
-
// Simple hash without crypto module
|
|
332
|
-
let hash = 0;
|
|
333
|
-
for (let i = 0; i < rawName.length; i++) {
|
|
334
|
-
const char = rawName.charCodeAt(i);
|
|
335
|
-
hash = (hash << 5) - hash + char;
|
|
336
|
-
hash = hash & hash; // Convert to 32bit integer
|
|
337
|
-
}
|
|
338
|
-
const hashStr = Math.abs(hash).toString(36).substring(0, 8);
|
|
339
|
-
const maxBaseLength = 63 - hashStr.length - 1;
|
|
340
|
-
tableName = tableName.substring(0, maxBaseLength) + '_' + hashStr;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
return tableName;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
326
|
/**
|
|
347
327
|
* Validate scan configuration
|
|
348
328
|
* @throws {Error} If required scan configuration is missing
|
|
@@ -376,9 +356,9 @@ class Config {
|
|
|
376
356
|
);
|
|
377
357
|
}
|
|
378
358
|
|
|
379
|
-
if (!this.scan.
|
|
359
|
+
if (!this.scan.basePathFull) {
|
|
380
360
|
errors.push(
|
|
381
|
-
'Could not determine base path
|
|
361
|
+
'Could not determine base path. Set ARELA_BASE_PATH_LABEL or UPLOAD_BASE_PATH',
|
|
382
362
|
);
|
|
383
363
|
}
|
|
384
364
|
|
|
@@ -394,10 +374,8 @@ class Config {
|
|
|
394
374
|
* @returns {Object} Scan configuration
|
|
395
375
|
*/
|
|
396
376
|
getScanConfig() {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
basePathFull: this.upload.basePath,
|
|
400
|
-
};
|
|
377
|
+
// Return scan config with basePathFull already resolved to absolute path
|
|
378
|
+
return this.scan;
|
|
401
379
|
}
|
|
402
380
|
|
|
403
381
|
/**
|
|
@@ -549,6 +527,7 @@ class Config {
|
|
|
549
527
|
|
|
550
528
|
/**
|
|
551
529
|
* Get base path with validation
|
|
530
|
+
* Returns the path as configured (may be relative for legacy compatibility)
|
|
552
531
|
* @returns {string} Base path for uploads
|
|
553
532
|
* @throws {Error} If base path is not configured
|
|
554
533
|
*/
|
|
@@ -249,7 +249,6 @@ export class ScanApiService {
|
|
|
249
249
|
const result = await this.#request('/api/uploader/scan/register', 'POST', {
|
|
250
250
|
companySlug: config.companySlug,
|
|
251
251
|
serverId: config.serverId,
|
|
252
|
-
basePathLabel: config.basePathLabel,
|
|
253
252
|
basePathFull: config.basePathFull,
|
|
254
253
|
});
|
|
255
254
|
|
|
@@ -327,15 +326,15 @@ export class ScanApiService {
|
|
|
327
326
|
* Get all tables for a specific instance
|
|
328
327
|
* @param {string} companySlug - Company slug
|
|
329
328
|
* @param {string} serverId - Server ID
|
|
330
|
-
* @param {string}
|
|
329
|
+
* @param {string} basePathFull - Base path (absolute)
|
|
331
330
|
* @returns {Promise<Array>} List of tables for the instance
|
|
332
331
|
*/
|
|
333
|
-
async getInstanceTables(companySlug, serverId,
|
|
332
|
+
async getInstanceTables(companySlug, serverId, basePathFull) {
|
|
334
333
|
logger.debug(
|
|
335
|
-
`Fetching instance tables for ${companySlug}/${serverId}/${
|
|
334
|
+
`Fetching instance tables for ${companySlug}/${serverId}/${basePathFull}...`,
|
|
336
335
|
);
|
|
337
336
|
return await this.#request(
|
|
338
|
-
`/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&
|
|
337
|
+
`/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathFull=${encodeURIComponent(basePathFull)}`,
|
|
339
338
|
'GET',
|
|
340
339
|
);
|
|
341
340
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PathNormalizer - Absolute path resolution and table name generation
|
|
7
|
+
*
|
|
8
|
+
* Strategy:
|
|
9
|
+
* 1. Always resolve paths to absolute paths first (resolves ../sample vs ./sample)
|
|
10
|
+
* 2. Store absolute paths in base_path_full
|
|
11
|
+
* 3. Normalize ONLY for table name generation (sanitize special chars)
|
|
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
|
|
18
|
+
*/
|
|
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
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a path to an absolute path
|
|
55
|
+
* This ensures ../sample and ./sample are treated as different paths
|
|
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
|
+
*
|
|
62
|
+
* Examples:
|
|
63
|
+
* - ../sample (from /data/project) -> /data/sample
|
|
64
|
+
* - ./sample (from /data/project) -> /data/project/sample
|
|
65
|
+
* - C:\Users\Documents -> C:\Users\Documents (preserved on any OS)
|
|
66
|
+
* - O:\expediente\archivos -> O:\expediente\archivos (preserved on any OS)
|
|
67
|
+
* - /home/user/docs -> /home/user/docs (on Unix)
|
|
68
|
+
*
|
|
69
|
+
* @param {string} inputPath - Path to resolve
|
|
70
|
+
* @param {string} basePath - Base path for resolving relative paths (defaults to cwd)
|
|
71
|
+
* @returns {string} Absolute path (native format for Unix, preserved for Windows)
|
|
72
|
+
*/
|
|
73
|
+
static toAbsolutePath(inputPath, basePath = null) {
|
|
74
|
+
if (!inputPath) return '';
|
|
75
|
+
|
|
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
|
|
85
|
+
if (path.isAbsolute(inputPath)) {
|
|
86
|
+
return path.normalize(inputPath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Resolve relative path against base
|
|
90
|
+
const base = basePath || process.cwd();
|
|
91
|
+
return path.resolve(base, inputPath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalize path separators to forward slashes for consistency
|
|
96
|
+
* Does NOT remove drive letters or convert to POSIX - just normalizes separators
|
|
97
|
+
*
|
|
98
|
+
* @param {string} inputPath - Path to normalize
|
|
99
|
+
* @returns {string} Path with forward slashes
|
|
100
|
+
*/
|
|
101
|
+
static normalizeSeparators(inputPath) {
|
|
102
|
+
if (!inputPath) return '';
|
|
103
|
+
return inputPath.replace(/\\/g, '/');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get relative path from base with normalized separators
|
|
108
|
+
*
|
|
109
|
+
* @param {string} fullPath - Full absolute path
|
|
110
|
+
* @param {string} basePath - Base absolute path to subtract
|
|
111
|
+
* @returns {string} Relative path with forward slashes
|
|
112
|
+
*/
|
|
113
|
+
static getRelativePath(fullPath, basePath) {
|
|
114
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
115
|
+
return this.normalizeSeparators(relativePath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sanitize an absolute path for use in database table names
|
|
120
|
+
* Converts path separators and special characters to underscores
|
|
121
|
+
* Preserves the path structure for uniqueness
|
|
122
|
+
*
|
|
123
|
+
* Examples:
|
|
124
|
+
* - /data/2023 -> data_2023
|
|
125
|
+
* - C:\Users\Documents -> c_users_documents
|
|
126
|
+
* - /home/user/project/sample -> home_user_project_sample
|
|
127
|
+
*
|
|
128
|
+
* @param {string} absolutePath - Absolute path to sanitize
|
|
129
|
+
* @returns {string} Sanitized string safe for table names
|
|
130
|
+
*/
|
|
131
|
+
static sanitizeForTableName(absolutePath) {
|
|
132
|
+
if (!absolutePath) return '';
|
|
133
|
+
|
|
134
|
+
return absolutePath
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/^[a-z]:/i, '') // Remove Windows drive letter (C:, D:, etc.)
|
|
137
|
+
.replace(/[^a-z0-9]/g, '_') // Replace all non-alphanumeric with underscore
|
|
138
|
+
.replace(/_+/g, '_') // Replace multiple underscores with single
|
|
139
|
+
.replace(/^_|_$/g, ''); // Remove leading/trailing underscores
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate a PostgreSQL-compatible table name from scan configuration
|
|
144
|
+
* Format: scan_<company>_<server>_<sanitized_absolute_path>
|
|
145
|
+
*
|
|
146
|
+
* Uses the absolute path to ensure uniqueness:
|
|
147
|
+
* - ../sample -> /data/sample
|
|
148
|
+
* - ./sample -> /data/project/sample
|
|
149
|
+
* These create different table names!
|
|
150
|
+
*
|
|
151
|
+
* Handles the 63-character PostgreSQL limit by:
|
|
152
|
+
* 1. Trying the full name first
|
|
153
|
+
* 2. If too long, truncating and adding an 8-character hash for uniqueness
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} config - Scan configuration
|
|
156
|
+
* @param {string} config.companySlug - Company identifier
|
|
157
|
+
* @param {string} config.serverId - Server identifier
|
|
158
|
+
* @param {string} config.basePathLabel - Absolute base path
|
|
159
|
+
* @returns {string} PostgreSQL-compatible table name
|
|
160
|
+
*/
|
|
161
|
+
static generateTableName(config) {
|
|
162
|
+
const { companySlug, serverId, basePathLabel } = config;
|
|
163
|
+
|
|
164
|
+
// Sanitize each component (basePathLabel should already be absolute)
|
|
165
|
+
const sanitizedCompany = companySlug
|
|
166
|
+
.toLowerCase()
|
|
167
|
+
.replace(/[^a-z0-9]/g, '_');
|
|
168
|
+
const sanitizedServer = serverId.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
169
|
+
const sanitizedPath = this.sanitizeForTableName(basePathLabel);
|
|
170
|
+
|
|
171
|
+
// Build raw name for hashing (preserve original for uniqueness)
|
|
172
|
+
const rawName = `${companySlug}_${serverId}_${basePathLabel}`;
|
|
173
|
+
|
|
174
|
+
// Build sanitized table name
|
|
175
|
+
let tableName = `scan_${sanitizedCompany}_${sanitizedServer}`;
|
|
176
|
+
|
|
177
|
+
if (sanitizedPath) {
|
|
178
|
+
tableName += `_${sanitizedPath}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// PostgreSQL table name limit is 63 characters
|
|
182
|
+
if (tableName.length > 63) {
|
|
183
|
+
// Generate a hash for uniqueness
|
|
184
|
+
const hash = crypto
|
|
185
|
+
.createHash('md5')
|
|
186
|
+
.update(rawName)
|
|
187
|
+
.digest('hex')
|
|
188
|
+
.substring(0, 8);
|
|
189
|
+
|
|
190
|
+
const basePrefix = 'scan_';
|
|
191
|
+
const prefix = `${basePrefix}${sanitizedCompany}_${sanitizedServer}_`;
|
|
192
|
+
|
|
193
|
+
// Check if even prefix + hash exceeds limit
|
|
194
|
+
if (prefix.length + hash.length > 63) {
|
|
195
|
+
// Company/server names are too long, need to truncate them too
|
|
196
|
+
const maxCompanyServerLength = 63 - basePrefix.length - hash.length - 2; // -2 for underscores
|
|
197
|
+
const halfLength = Math.floor(maxCompanyServerLength / 2);
|
|
198
|
+
const companyLength = halfLength;
|
|
199
|
+
const serverLength = maxCompanyServerLength - companyLength;
|
|
200
|
+
|
|
201
|
+
const truncatedCompany = sanitizedCompany.substring(0, companyLength);
|
|
202
|
+
const truncatedServer = sanitizedServer.substring(0, serverLength);
|
|
203
|
+
|
|
204
|
+
tableName = `${basePrefix}${truncatedCompany}_${truncatedServer}_${hash}`;
|
|
205
|
+
} else {
|
|
206
|
+
// Preserve start and end of path, put hash in middle
|
|
207
|
+
const availableSpace = 63 - prefix.length - hash.length - 2; // -2 for underscores around hash
|
|
208
|
+
|
|
209
|
+
if (availableSpace <= 0 || !sanitizedPath) {
|
|
210
|
+
// If no space for path or path is empty, just use hash
|
|
211
|
+
tableName = `${prefix}${hash}`;
|
|
212
|
+
} else if (sanitizedPath.length <= availableSpace) {
|
|
213
|
+
// Path fits in available space, use it all
|
|
214
|
+
tableName = `${prefix}${sanitizedPath}_${hash}`;
|
|
215
|
+
} else {
|
|
216
|
+
// Need to split: preserve start and end
|
|
217
|
+
const halfSpace = Math.floor(availableSpace / 2);
|
|
218
|
+
const startLength = halfSpace;
|
|
219
|
+
const endLength = availableSpace - startLength;
|
|
220
|
+
|
|
221
|
+
// Extract start and end portions of the sanitized path
|
|
222
|
+
const pathStart = sanitizedPath.substring(0, startLength);
|
|
223
|
+
const pathEnd = sanitizedPath.substring(sanitizedPath.length - endLength);
|
|
224
|
+
|
|
225
|
+
// Build table name with start, hash, and end
|
|
226
|
+
tableName = `${prefix}${pathStart}_${hash}_${pathEnd}`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return tableName;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build an absolute path from base and relative component
|
|
236
|
+
*
|
|
237
|
+
* @param {string} basePath - Absolute base path
|
|
238
|
+
* @param {string} relativePath - Relative path from base (can be empty)
|
|
239
|
+
* @returns {string} Absolute path
|
|
240
|
+
*/
|
|
241
|
+
static buildAbsolutePath(basePath, relativePath = '') {
|
|
242
|
+
if (!relativePath || relativePath === '.') {
|
|
243
|
+
return basePath;
|
|
244
|
+
}
|
|
245
|
+
return path.resolve(basePath, relativePath);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate that a path can be used for table name generation
|
|
250
|
+
*
|
|
251
|
+
* @param {string} pathStr - Path to validate
|
|
252
|
+
* @returns {Object} Validation result { valid: boolean, error?: string }
|
|
253
|
+
*/
|
|
254
|
+
static validatePath(pathStr) {
|
|
255
|
+
if (!pathStr || typeof pathStr !== 'string') {
|
|
256
|
+
return { valid: false, error: 'Path is required and must be a string' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (pathStr.trim() === '') {
|
|
260
|
+
return { valid: false, error: 'Path cannot be empty' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check for null bytes (security)
|
|
264
|
+
if (pathStr.includes('\0')) {
|
|
265
|
+
return { valid: false, error: 'Path cannot contain null bytes' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { valid: true };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export default PathNormalizer;
|