@arela/uploader 1.1.3 → 1.2.0
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/.claude/worktrees/agent-multi-profile/.env.template +224 -0
- package/.claude/worktrees/agent-multi-profile/.prettierrc +13 -0
- package/.claude/worktrees/agent-multi-profile/README.md +405 -0
- package/.claude/worktrees/agent-multi-profile/package-lock.json +7096 -0
- package/.claude/worktrees/agent-multi-profile/package.json +78 -0
- package/.claude/worktrees/agent-multi-profile/scripts/cleanup-ds-store.js +109 -0
- package/.claude/worktrees/agent-multi-profile/scripts/cleanup-system-files.js +69 -0
- package/.claude/worktrees/agent-multi-profile/scripts/scoring-compare.js +243 -0
- package/.claude/worktrees/agent-multi-profile/scripts/scoring-phase4-check.js +96 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/phase-7-features.test.js +415 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/signal-handling.test.js +275 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/smart-watch-integration.test.js +554 -0
- package/.claude/worktrees/agent-multi-profile/scripts/tests/watch-service-integration.test.js +584 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/AgentCommand.js +229 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/AgentInitCommand.js +316 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/DatastageCommand.js +164 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/GDriveSyncCommand.js +475 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/IdentifyCommand.js +708 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/PollWorkerCommand.js +169 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/PropagateCommand.js +636 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/PushCommand.js +743 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/ScanCommand.js +722 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/UploadCommand.js +587 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/WatchCommand.js +1342 -0
- package/.claude/worktrees/agent-multi-profile/src/commands/WorkerCommand.js +337 -0
- package/.claude/worktrees/agent-multi-profile/src/config/config.js +862 -0
- package/.claude/worktrees/agent-multi-profile/src/document-type-shared.js +131 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/_pedimento-shared-extractors.js +348 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/doda-pdf.js +121 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/doda-xml.js +118 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/factura-inter-agencia.js +186 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/facturas-comerciales.js +233 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo-xml.js +372 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo.js +108 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-simplificado.js +76 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/proforma.js +29 -0
- package/.claude/worktrees/agent-multi-profile/src/document-types/support-document.js +200 -0
- package/.claude/worktrees/agent-multi-profile/src/errors/ErrorHandler.js +278 -0
- package/.claude/worktrees/agent-multi-profile/src/errors/ErrorTypes.js +104 -0
- package/.claude/worktrees/agent-multi-profile/src/file-detection.js +338 -0
- package/.claude/worktrees/agent-multi-profile/src/index.js +890 -0
- package/.claude/worktrees/agent-multi-profile/src/scoring/db-matcher-adapter.js +98 -0
- package/.claude/worktrees/agent-multi-profile/src/scoring/matchers-seed.js +386 -0
- package/.claude/worktrees/agent-multi-profile/src/scoring/scoring-engine.js +251 -0
- package/.claude/worktrees/agent-multi-profile/src/services/AdvancedFilterService.js +505 -0
- package/.claude/worktrees/agent-multi-profile/src/services/AutoProcessingService.js +749 -0
- package/.claude/worktrees/agent-multi-profile/src/services/BenchmarkingService.js +381 -0
- package/.claude/worktrees/agent-multi-profile/src/services/DatabaseService.js +2173 -0
- package/.claude/worktrees/agent-multi-profile/src/services/DatastageApiService.js +240 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ErrorMonitor.js +275 -0
- package/.claude/worktrees/agent-multi-profile/src/services/GoogleDriveService.js +217 -0
- package/.claude/worktrees/agent-multi-profile/src/services/LoggingService.js +649 -0
- package/.claude/worktrees/agent-multi-profile/src/services/MonitoringService.js +401 -0
- package/.claude/worktrees/agent-multi-profile/src/services/PerformanceOptimizer.js +511 -0
- package/.claude/worktrees/agent-multi-profile/src/services/PipelineApiService.js +274 -0
- package/.claude/worktrees/agent-multi-profile/src/services/PipelineJobRunner.js +389 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ProfileManager.js +164 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ReportingService.js +511 -0
- package/.claude/worktrees/agent-multi-profile/src/services/ScanApiService.js +775 -0
- package/.claude/worktrees/agent-multi-profile/src/services/SignalHandler.js +255 -0
- package/.claude/worktrees/agent-multi-profile/src/services/SmartWatchDatabaseService.js +527 -0
- package/.claude/worktrees/agent-multi-profile/src/services/WatchService.js +783 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/ApiUploadService.js +676 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/BaseUploadService.js +36 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/MultiApiUploadService.js +233 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/SupabaseUploadService.js +148 -0
- package/.claude/worktrees/agent-multi-profile/src/services/upload/UploadServiceFactory.js +100 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/CleanupManager.js +262 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/FileOperations.js +192 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/FileSanitizer.js +99 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/PathDetector.js +198 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/PathNormalizer.js +274 -0
- package/.claude/worktrees/agent-multi-profile/src/utils/WatchEventHandler.js +522 -0
- package/.claude/worktrees/agent-multi-profile/supabase/migrations/001_create_initial_schema.sql +366 -0
- package/.claude/worktrees/agent-multi-profile/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/IdentifyCommand.test.js +570 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/PropagateCommand.test.js +568 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/PushCommand.test.js +754 -0
- package/.claude/worktrees/agent-multi-profile/tests/commands/ScanCommand.test.js +382 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/factura-inter-agencia.test.js +218 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-completo-xml-matcher.test.js +271 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-simplificado-matcher.test.js +185 -0
- package/.claude/worktrees/agent-multi-profile/tests/unit/scoring-engine.test.js +221 -0
- package/README.md +65 -0
- package/package.json +1 -1
- package/src/commands/AgentCommand.js +210 -0
- package/src/commands/AgentInitCommand.js +316 -0
- package/src/commands/PollWorkerCommand.js +11 -322
- package/src/config/config.js +44 -6
- package/src/document-type-shared.js +1 -1
- package/src/document-types/pedimento-completo.js +8 -1
- package/src/file-detection.js +9 -0
- package/src/index.js +55 -0
- package/src/scoring/scoring-engine.js +1 -1
- package/src/services/LoggingService.js +35 -0
- package/src/services/PipelineApiService.js +7 -1
- package/src/services/PipelineJobRunner.js +389 -0
- package/src/services/ProfileManager.js +164 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import cliProgress from 'cli-progress';
|
|
2
|
+
import { globby, globbyStream } from 'globby';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Transform } from 'stream';
|
|
5
|
+
import { pipeline } from 'stream/promises';
|
|
6
|
+
|
|
7
|
+
import logger from '../services/LoggingService.js';
|
|
8
|
+
|
|
9
|
+
import appConfig from '../config/config.js';
|
|
10
|
+
import ErrorHandler from '../errors/ErrorHandler.js';
|
|
11
|
+
import { ConfigurationError } from '../errors/ErrorTypes.js';
|
|
12
|
+
import FileOperations from '../utils/FileOperations.js';
|
|
13
|
+
import PathNormalizer from '../utils/PathNormalizer.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scan Command Handler
|
|
17
|
+
* Handles the optimized arela scan command with streaming support
|
|
18
|
+
*/
|
|
19
|
+
export class ScanCommand {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.errorHandler = new ErrorHandler(logger);
|
|
22
|
+
this.scanApiService = null; // Will be initialized in execute
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Execute the scan command
|
|
27
|
+
* @param {Object} options - Command options
|
|
28
|
+
* @param {boolean} options.countFirst - Count files first for percentage-based progress
|
|
29
|
+
* @param {string} options.api - API target: 'default', 'agencia', or 'cliente'
|
|
30
|
+
* @param {boolean} options.stream - Use streaming file discovery (default: true, use --no-stream to disable)
|
|
31
|
+
* @param {Function} options.onProgress - Optional callback for progress updates (percent, message)
|
|
32
|
+
*/
|
|
33
|
+
async execute(options = {}) {
|
|
34
|
+
const startTime = Date.now();
|
|
35
|
+
this.onProgress = options.onProgress || null;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Validate scan configuration
|
|
39
|
+
appConfig.validateScanConfig();
|
|
40
|
+
|
|
41
|
+
// Determine API target
|
|
42
|
+
const apiTarget = options.api || 'default';
|
|
43
|
+
|
|
44
|
+
// Import ScanApiService dynamically and initialize with target
|
|
45
|
+
const { default: ScanApiService } = await import(
|
|
46
|
+
'../services/ScanApiService.js'
|
|
47
|
+
);
|
|
48
|
+
this.scanApiService = new ScanApiService(apiTarget);
|
|
49
|
+
|
|
50
|
+
const scanConfig = appConfig.getScanConfig();
|
|
51
|
+
// Ensure basePath is absolute for scan operations
|
|
52
|
+
// '*' is a wildcard sentinel for cross-drive pipelines — no real basePath
|
|
53
|
+
const rawBasePath = appConfig.getBasePath();
|
|
54
|
+
const basePath =
|
|
55
|
+
rawBasePath === '*' ? '*' : PathNormalizer.toAbsolutePath(rawBasePath);
|
|
56
|
+
|
|
57
|
+
logger.info('🔍 Starting arela scan command');
|
|
58
|
+
logger.info(`🎯 API Target: ${apiTarget}`);
|
|
59
|
+
logger.info(`📦 Company: ${scanConfig.companySlug}`);
|
|
60
|
+
logger.info(`🖥️ Server: ${scanConfig.serverId}`);
|
|
61
|
+
logger.info(`📂 Base Path: ${basePath}`);
|
|
62
|
+
logger.info(`📊 Directory Level: ${scanConfig.directoryLevel}`);
|
|
63
|
+
logger.info(
|
|
64
|
+
`🔄 File Discovery: ${options.stream !== false ? 'Streaming (globbyStream)' : 'Synchronous (globby)'}`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Step 1: Discover directories at specified level
|
|
68
|
+
logger.info('\n🔍 Discovering directories...');
|
|
69
|
+
const directories = await this.#discoverDirectories(
|
|
70
|
+
basePath,
|
|
71
|
+
scanConfig.directoryLevel,
|
|
72
|
+
);
|
|
73
|
+
logger.info(
|
|
74
|
+
`📁 Found ${directories.length} director${directories.length === 1 ? 'y' : 'ies'} to scan`,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Step 2: Register instances for each directory
|
|
78
|
+
logger.info('\n📝 Registering scan instances...');
|
|
79
|
+
const registrations = [];
|
|
80
|
+
for (const dir of directories) {
|
|
81
|
+
// dir.path is already absolute from #discoverDirectories
|
|
82
|
+
// Use the absolute path as basePathLabel (simplifies everything!)
|
|
83
|
+
const absolutePath = dir.path;
|
|
84
|
+
|
|
85
|
+
const registration = await this.scanApiService.registerInstance({
|
|
86
|
+
companySlug: scanConfig.companySlug,
|
|
87
|
+
serverId: scanConfig.serverId,
|
|
88
|
+
basePathFull: absolutePath,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
registrations.push({ ...registration, directory: dir });
|
|
92
|
+
|
|
93
|
+
if (registration.existed) {
|
|
94
|
+
logger.info(` ✓ ${dir.label || 'root'}: ${registration.tableName}`);
|
|
95
|
+
} else {
|
|
96
|
+
logger.success(
|
|
97
|
+
` ✓ ${dir.label || 'root'}: ${registration.tableName} (new)`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Optional: Count files first for percentage-based progress
|
|
103
|
+
let totalFiles = null;
|
|
104
|
+
if (options.countFirst) {
|
|
105
|
+
logger.info('\n🔢 Counting files...');
|
|
106
|
+
totalFiles = await this.#countFiles(
|
|
107
|
+
basePath,
|
|
108
|
+
scanConfig,
|
|
109
|
+
options.stream !== false,
|
|
110
|
+
);
|
|
111
|
+
logger.info(`📊 Found ${totalFiles.toLocaleString()} files to scan`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Step 3: Stream files and upload stats for each directory
|
|
115
|
+
logger.info('\n🚀 Starting file scan...');
|
|
116
|
+
let totalStats = {
|
|
117
|
+
filesScanned: 0,
|
|
118
|
+
filesInserted: 0,
|
|
119
|
+
filesSkipped: 0,
|
|
120
|
+
totalSize: 0,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Report initial progress
|
|
124
|
+
this.#reportProgress(
|
|
125
|
+
0,
|
|
126
|
+
`Starting scan of ${registrations.length} directories`,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < registrations.length; i++) {
|
|
130
|
+
const reg = registrations[i];
|
|
131
|
+
logger.info(`\n📂 Scanning: ${reg.directory.label || 'root'}`);
|
|
132
|
+
this.#reportProgress(
|
|
133
|
+
Math.round((i / registrations.length) * 100),
|
|
134
|
+
`Scanning directory ${i + 1}/${registrations.length}: ${reg.directory.label || 'root'}`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const stats = await this.#streamScanDirectory(
|
|
138
|
+
reg.directory.path,
|
|
139
|
+
scanConfig,
|
|
140
|
+
reg.tableName,
|
|
141
|
+
null, // Don't use percentage for individual directories
|
|
142
|
+
options.stream !== false, // Use streaming by default, --no-stream sets stream=false
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Step 4: Complete scan for this directory
|
|
146
|
+
await this.scanApiService.completeScan({
|
|
147
|
+
tableName: reg.tableName,
|
|
148
|
+
totalFiles: stats.filesScanned,
|
|
149
|
+
totalSizeBytes: stats.totalSize,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
totalStats.filesScanned += stats.filesScanned;
|
|
153
|
+
totalStats.filesInserted += stats.filesInserted;
|
|
154
|
+
totalStats.filesSkipped += stats.filesSkipped;
|
|
155
|
+
totalStats.totalSize += stats.totalSize;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
159
|
+
const filesPerSec = (totalStats.filesScanned / duration).toFixed(2);
|
|
160
|
+
|
|
161
|
+
// Report completion
|
|
162
|
+
this.#reportProgress(
|
|
163
|
+
100,
|
|
164
|
+
`Scan completed: ${totalStats.filesScanned} files in ${duration}s`,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
logger.success('\n✅ Scan completed successfully!');
|
|
168
|
+
logger.info(`\n📊 Scan Statistics:`);
|
|
169
|
+
logger.info(` Directories scanned: ${registrations.length}`);
|
|
170
|
+
logger.info(
|
|
171
|
+
` Files scanned: ${totalStats.filesScanned.toLocaleString()}`,
|
|
172
|
+
);
|
|
173
|
+
logger.info(
|
|
174
|
+
` Files inserted: ${totalStats.filesInserted.toLocaleString()}`,
|
|
175
|
+
);
|
|
176
|
+
logger.info(
|
|
177
|
+
` Files skipped: ${totalStats.filesSkipped.toLocaleString()} (excluded patterns)`,
|
|
178
|
+
);
|
|
179
|
+
logger.info(` Total size: ${this.#formatBytes(totalStats.totalSize)}`);
|
|
180
|
+
logger.info(` Duration: ${duration}s`);
|
|
181
|
+
logger.info(` Throughput: ${filesPerSec} files/sec`);
|
|
182
|
+
logger.info(`\n📋 Tables created:`);
|
|
183
|
+
for (const reg of registrations) {
|
|
184
|
+
logger.info(` - ${reg.tableName}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
tables: registrations.map((r) => r.tableName),
|
|
190
|
+
stats: totalStats,
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
this.errorHandler.handleError(error, 'scan');
|
|
194
|
+
return {
|
|
195
|
+
success: false,
|
|
196
|
+
error: error.message,
|
|
197
|
+
stats: {
|
|
198
|
+
filesScanned: 0,
|
|
199
|
+
filesInserted: 0,
|
|
200
|
+
filesSkipped: 0,
|
|
201
|
+
totalSize: 0,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Discover directories at specified level
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
async #discoverDirectories(basePath, level) {
|
|
212
|
+
// Get sources, defaults to ['.'] if not configured
|
|
213
|
+
const sources = appConfig.getUploadSources();
|
|
214
|
+
const isDefaultSource = sources.length === 1 && sources[0] === '.';
|
|
215
|
+
|
|
216
|
+
if (level === 0) {
|
|
217
|
+
// Level 0: Create one entry per source
|
|
218
|
+
return sources.map((source) => {
|
|
219
|
+
let sourcePath;
|
|
220
|
+
if (source === '.') {
|
|
221
|
+
sourcePath = basePath;
|
|
222
|
+
} else if (source.startsWith('..') || path.isAbsolute(source)) {
|
|
223
|
+
// Source is a relative-to-CWD path (e.g., from pipeline UI) or absolute
|
|
224
|
+
sourcePath = PathNormalizer.toAbsolutePath(source);
|
|
225
|
+
} else {
|
|
226
|
+
// Source is a subdirectory of basePath
|
|
227
|
+
sourcePath = path.resolve(basePath, source);
|
|
228
|
+
}
|
|
229
|
+
// Label is relative path for display purposes only
|
|
230
|
+
const label = source === '.' ? '' : source;
|
|
231
|
+
return { path: sourcePath, label };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// For level > 0: First discover directories at the base path, then combine with sources
|
|
236
|
+
const fs = await import('fs/promises');
|
|
237
|
+
const directories = [];
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const fs = await import('fs/promises');
|
|
241
|
+
|
|
242
|
+
// Step 1: Discover directories at the specified level from base path
|
|
243
|
+
const levelDirs = await this.#getDirectoriesAtLevel(basePath, level, '');
|
|
244
|
+
|
|
245
|
+
// Step 2: For each discovered directory, create entries for each source
|
|
246
|
+
for (const levelDir of levelDirs) {
|
|
247
|
+
for (const source of sources) {
|
|
248
|
+
if (source === '.') {
|
|
249
|
+
// Source is current directory, use discovered path as-is
|
|
250
|
+
directories.push(levelDir);
|
|
251
|
+
} else {
|
|
252
|
+
// Resolve source: if it starts with ".." it's relative to CWD, not levelDir
|
|
253
|
+
const combinedPath =
|
|
254
|
+
source.startsWith('..') || path.isAbsolute(source)
|
|
255
|
+
? PathNormalizer.toAbsolutePath(source)
|
|
256
|
+
: path.resolve(levelDir.path, source);
|
|
257
|
+
|
|
258
|
+
// Only add if the combined path actually exists
|
|
259
|
+
try {
|
|
260
|
+
const stats = await fs.stat(combinedPath);
|
|
261
|
+
if (stats.isDirectory()) {
|
|
262
|
+
// Label for display
|
|
263
|
+
const label = levelDir.label
|
|
264
|
+
? `${levelDir.label}/${source}`
|
|
265
|
+
: source;
|
|
266
|
+
directories.push({
|
|
267
|
+
path: combinedPath,
|
|
268
|
+
label,
|
|
269
|
+
});
|
|
270
|
+
} else {
|
|
271
|
+
logger.debug(`⏭️ Skipping ${combinedPath} (not a directory)`);
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
logger.debug(`⏭️ Skipping ${combinedPath} (does not exist)`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
logger.warn(`⚠️ Could not discover directories: ${error.message}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return directories;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Recursively get directories at specified level
|
|
288
|
+
* @private
|
|
289
|
+
*/
|
|
290
|
+
async #getDirectoriesAtLevel(
|
|
291
|
+
basePath,
|
|
292
|
+
targetLevel,
|
|
293
|
+
currentPath,
|
|
294
|
+
currentLevel = 0,
|
|
295
|
+
) {
|
|
296
|
+
const fs = await import('fs/promises');
|
|
297
|
+
const fullPath = path.join(basePath, currentPath);
|
|
298
|
+
|
|
299
|
+
if (currentLevel === targetLevel) {
|
|
300
|
+
// Label is the relative path for display
|
|
301
|
+
const label = currentPath || '';
|
|
302
|
+
return [{ path: fullPath, label }];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const directories = [];
|
|
306
|
+
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
307
|
+
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (entry.isDirectory()) {
|
|
310
|
+
const subPath = path.join(currentPath, entry.name);
|
|
311
|
+
const subDirs = await this.#getDirectoriesAtLevel(
|
|
312
|
+
basePath,
|
|
313
|
+
targetLevel,
|
|
314
|
+
subPath,
|
|
315
|
+
currentLevel + 1,
|
|
316
|
+
);
|
|
317
|
+
directories.push(...subDirs);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return directories;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Count files for percentage-based progress
|
|
326
|
+
* @private
|
|
327
|
+
* @param {string} basePath - Base path to count from
|
|
328
|
+
* @param {Object} scanConfig - Scan configuration
|
|
329
|
+
* @param {boolean} useStream - Use streaming (globbyStream) or sync (globby) approach
|
|
330
|
+
*/
|
|
331
|
+
async #countFiles(basePath, scanConfig, useStream = true) {
|
|
332
|
+
const sources = appConfig.getUploadSources();
|
|
333
|
+
let totalCount = 0;
|
|
334
|
+
|
|
335
|
+
for (const source of sources) {
|
|
336
|
+
const sourcePath = path.resolve(basePath, source);
|
|
337
|
+
|
|
338
|
+
if (useStream) {
|
|
339
|
+
// Streaming approach
|
|
340
|
+
const files = await globbyStream('**/*', {
|
|
341
|
+
cwd: sourcePath,
|
|
342
|
+
onlyFiles: true,
|
|
343
|
+
absolute: true,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
for await (const file of files) {
|
|
347
|
+
if (!this.#shouldExcludeFile(file, scanConfig.excludePatterns)) {
|
|
348
|
+
totalCount++;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
// Synchronous approach (original method)
|
|
353
|
+
const files = await globby([`${sourcePath}/**/*`], { onlyFiles: true });
|
|
354
|
+
for (const file of files) {
|
|
355
|
+
if (!this.#shouldExcludeFile(file, scanConfig.excludePatterns)) {
|
|
356
|
+
totalCount++;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return totalCount;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Stream files from a single directory and upload stats in batches
|
|
367
|
+
* @private
|
|
368
|
+
* @param {string} dirPath - Directory path to scan
|
|
369
|
+
* @param {Object} scanConfig - Scan configuration
|
|
370
|
+
* @param {string} tableName - Target table name
|
|
371
|
+
* @param {number|null} totalFiles - Total files count for progress (optional)
|
|
372
|
+
* @param {boolean} useStream - Use streaming (globbyStream) or sync (globby) approach
|
|
373
|
+
*/
|
|
374
|
+
async #streamScanDirectory(
|
|
375
|
+
dirPath,
|
|
376
|
+
scanConfig,
|
|
377
|
+
tableName,
|
|
378
|
+
totalFiles = null,
|
|
379
|
+
useStream = true,
|
|
380
|
+
) {
|
|
381
|
+
// For directory-level scanning, we scan the directory directly
|
|
382
|
+
const batchSize = scanConfig.batchSize || 2000;
|
|
383
|
+
const scanTimestamp = new Date().toISOString();
|
|
384
|
+
|
|
385
|
+
let filesScanned = 0;
|
|
386
|
+
let filesInserted = 0;
|
|
387
|
+
let filesSkipped = 0;
|
|
388
|
+
let totalSize = 0;
|
|
389
|
+
let currentBatch = [];
|
|
390
|
+
|
|
391
|
+
// Create progress bar
|
|
392
|
+
const progressBar = this.#createProgressBar(totalFiles);
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
if (useStream) {
|
|
396
|
+
// Streaming approach: use globbyStream with stats option
|
|
397
|
+
const fileStream = globbyStream('**/*', {
|
|
398
|
+
cwd: dirPath,
|
|
399
|
+
onlyFiles: true,
|
|
400
|
+
absolute: true,
|
|
401
|
+
stats: true, // Get file stats during discovery
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Process each file from stream
|
|
405
|
+
for await (const entry of fileStream) {
|
|
406
|
+
// globby with stats:true returns {path, stats} objects
|
|
407
|
+
const filePath = typeof entry === 'string' ? entry : entry.path;
|
|
408
|
+
const stats = typeof entry === 'object' ? entry.stats : null;
|
|
409
|
+
|
|
410
|
+
const result = await this.#processFileEntry(
|
|
411
|
+
filePath,
|
|
412
|
+
stats,
|
|
413
|
+
dirPath,
|
|
414
|
+
scanConfig,
|
|
415
|
+
scanTimestamp,
|
|
416
|
+
tableName,
|
|
417
|
+
currentBatch,
|
|
418
|
+
batchSize,
|
|
419
|
+
progressBar,
|
|
420
|
+
totalFiles,
|
|
421
|
+
{ filesScanned, filesInserted, filesSkipped, totalSize },
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
filesScanned = result.filesScanned;
|
|
425
|
+
filesInserted = result.filesInserted;
|
|
426
|
+
filesSkipped = result.filesSkipped;
|
|
427
|
+
totalSize = result.totalSize;
|
|
428
|
+
currentBatch = result.currentBatch;
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
// Synchronous approach: use globby (original method from UploadCommand)
|
|
432
|
+
logger.debug('Using synchronous file discovery (globby)...');
|
|
433
|
+
const files = await globby([`${dirPath}/**/*`], { onlyFiles: true });
|
|
434
|
+
logger.debug(`Found ${files.length} files to process`);
|
|
435
|
+
|
|
436
|
+
for (const filePath of files) {
|
|
437
|
+
const result = await this.#processFileEntry(
|
|
438
|
+
filePath,
|
|
439
|
+
null, // Stats will be fetched manually
|
|
440
|
+
dirPath,
|
|
441
|
+
scanConfig,
|
|
442
|
+
scanTimestamp,
|
|
443
|
+
tableName,
|
|
444
|
+
currentBatch,
|
|
445
|
+
batchSize,
|
|
446
|
+
progressBar,
|
|
447
|
+
totalFiles,
|
|
448
|
+
{ filesScanned, filesInserted, filesSkipped, totalSize },
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
filesScanned = result.filesScanned;
|
|
452
|
+
filesInserted = result.filesInserted;
|
|
453
|
+
filesSkipped = result.filesSkipped;
|
|
454
|
+
totalSize = result.totalSize;
|
|
455
|
+
currentBatch = result.currentBatch;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
} catch (error) {
|
|
459
|
+
logger.error(`❌ Error scanning directory: ${error.message}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Upload remaining files
|
|
463
|
+
if (currentBatch.length > 0) {
|
|
464
|
+
const inserted = await this.#uploadBatch(tableName, currentBatch);
|
|
465
|
+
filesInserted += inserted;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
progressBar.stop();
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
filesScanned,
|
|
472
|
+
filesInserted,
|
|
473
|
+
filesSkipped,
|
|
474
|
+
totalSize,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Process a single file entry (used by both streaming and sync approaches)
|
|
480
|
+
* @private
|
|
481
|
+
*/
|
|
482
|
+
async #processFileEntry(
|
|
483
|
+
filePath,
|
|
484
|
+
stats,
|
|
485
|
+
dirPath,
|
|
486
|
+
scanConfig,
|
|
487
|
+
scanTimestamp,
|
|
488
|
+
tableName,
|
|
489
|
+
currentBatch,
|
|
490
|
+
batchSize,
|
|
491
|
+
progressBar,
|
|
492
|
+
totalFiles,
|
|
493
|
+
counters,
|
|
494
|
+
) {
|
|
495
|
+
let { filesScanned, filesInserted, filesSkipped, totalSize } = counters;
|
|
496
|
+
|
|
497
|
+
// Check if file should be excluded
|
|
498
|
+
if (this.#shouldExcludeFile(filePath, scanConfig.excludePatterns)) {
|
|
499
|
+
filesSkipped++;
|
|
500
|
+
return {
|
|
501
|
+
filesScanned,
|
|
502
|
+
filesInserted,
|
|
503
|
+
filesSkipped,
|
|
504
|
+
totalSize,
|
|
505
|
+
currentBatch,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Get file stats (use from globby or fetch manually)
|
|
510
|
+
const fileStats = stats || FileOperations.getFileStats(filePath);
|
|
511
|
+
if (!fileStats) {
|
|
512
|
+
logger.debug(`⚠️ Could not read stats: ${filePath}`);
|
|
513
|
+
filesSkipped++;
|
|
514
|
+
return {
|
|
515
|
+
filesScanned,
|
|
516
|
+
filesInserted,
|
|
517
|
+
filesSkipped,
|
|
518
|
+
totalSize,
|
|
519
|
+
currentBatch,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Normalize file record
|
|
524
|
+
const record = this.#normalizeFileRecord(
|
|
525
|
+
filePath,
|
|
526
|
+
fileStats,
|
|
527
|
+
dirPath,
|
|
528
|
+
scanTimestamp,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
currentBatch.push(record);
|
|
532
|
+
filesScanned++;
|
|
533
|
+
totalSize += record.sizeBytes;
|
|
534
|
+
|
|
535
|
+
// Update progress
|
|
536
|
+
if (totalFiles) {
|
|
537
|
+
progressBar.update(filesScanned);
|
|
538
|
+
} else {
|
|
539
|
+
// Show throughput instead of percentage
|
|
540
|
+
const elapsed = (Date.now() - progressBar.startTime) / 1000;
|
|
541
|
+
const rate = (filesScanned / elapsed).toFixed(1);
|
|
542
|
+
progressBar.update(filesScanned, { rate });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Upload batch when full
|
|
546
|
+
if (currentBatch.length >= batchSize) {
|
|
547
|
+
const inserted = await this.#uploadBatch(tableName, currentBatch);
|
|
548
|
+
filesInserted += inserted;
|
|
549
|
+
currentBatch = [];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
filesScanned,
|
|
554
|
+
filesInserted,
|
|
555
|
+
filesSkipped,
|
|
556
|
+
totalSize,
|
|
557
|
+
currentBatch,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Upload a batch of file records
|
|
563
|
+
* @private
|
|
564
|
+
*/
|
|
565
|
+
async #uploadBatch(tableName, records) {
|
|
566
|
+
try {
|
|
567
|
+
const result = await this.scanApiService.batchInsertStats(
|
|
568
|
+
tableName,
|
|
569
|
+
records,
|
|
570
|
+
);
|
|
571
|
+
return result.inserted;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
logger.error(`❌ Failed to upload batch: ${error.message}`);
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Normalize file record for database insertion
|
|
580
|
+
* Stores paths with forward slashes for consistency but keeps them absolute
|
|
581
|
+
* Sets likelySimplificado to true if file is a PDF and filename contains 'simp'
|
|
582
|
+
* Sets likelyInterAgencia to true if filename matches an inter-agency CFDI
|
|
583
|
+
* pattern (e.g. SICINGR*), so the API forces these XML/PDF through detection
|
|
584
|
+
* even though they lack the 'simp/pedim/covefact' heuristic.
|
|
585
|
+
* @private
|
|
586
|
+
*/
|
|
587
|
+
#normalizeFileRecord(filePath, fileStats, basePath, scanTimestamp) {
|
|
588
|
+
const fileName = path.basename(filePath);
|
|
589
|
+
const fileExtension = path.extname(filePath).toLowerCase().replace('.', '');
|
|
590
|
+
|
|
591
|
+
// Normalize separators to forward slashes for consistency
|
|
592
|
+
const directoryPath = PathNormalizer.normalizeSeparators(
|
|
593
|
+
path.dirname(filePath),
|
|
594
|
+
);
|
|
595
|
+
const relativePath = PathNormalizer.getRelativePath(filePath, basePath);
|
|
596
|
+
const absolutePath = PathNormalizer.normalizeSeparators(filePath);
|
|
597
|
+
|
|
598
|
+
// Determine if this file is potentially a pedimento (simplificado, completo, or CoveFact).
|
|
599
|
+
// PDFs whose filename contains 'simp', 'pedim' or 'covefact' (case-insensitive)
|
|
600
|
+
// are flagged so the identify stage prioritizes them. The column name
|
|
601
|
+
// `likely_simplificado` is preserved for backwards compatibility; semantics
|
|
602
|
+
// are broader (any likely pedimento PDF).
|
|
603
|
+
const likelySimplificado =
|
|
604
|
+
fileExtension === 'pdf' && /(simp|pedim|covefact)/i.test(fileName);
|
|
605
|
+
|
|
606
|
+
// Flag inter-agency CFDIs by filename so detection picks them up.
|
|
607
|
+
// Patterns are configurable via SCAN_INTER_AGENCIA_PATTERNS env var
|
|
608
|
+
// (see config.js). Only meaningful for PDF and XML.
|
|
609
|
+
let likelyInterAgencia = false;
|
|
610
|
+
if (fileExtension === 'pdf' || fileExtension === 'xml') {
|
|
611
|
+
const patterns = appConfig.scan.interAgenciaPatterns;
|
|
612
|
+
if (patterns && patterns.length > 0) {
|
|
613
|
+
likelyInterAgencia = patterns.some((re) => re.test(fileName));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
fileName,
|
|
619
|
+
fileExtension,
|
|
620
|
+
directoryPath,
|
|
621
|
+
relativePath,
|
|
622
|
+
absolutePath,
|
|
623
|
+
sizeBytes: Number(fileStats.size),
|
|
624
|
+
modifiedAt: fileStats.mtime.toISOString(),
|
|
625
|
+
scanTimestamp,
|
|
626
|
+
likelySimplificado,
|
|
627
|
+
likelyInterAgencia,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Check if file should be excluded based on patterns
|
|
633
|
+
* @private
|
|
634
|
+
*/
|
|
635
|
+
#shouldExcludeFile(filePath, excludePatterns) {
|
|
636
|
+
const fileName = path.basename(filePath);
|
|
637
|
+
|
|
638
|
+
for (const pattern of excludePatterns) {
|
|
639
|
+
// Convert glob pattern to regex
|
|
640
|
+
const regexPattern = pattern
|
|
641
|
+
.replace(/\./g, '\\.') // Escape dots
|
|
642
|
+
.replace(/\*/g, '.*') // * to .*
|
|
643
|
+
.replace(/\?/g, '.'); // ? to .
|
|
644
|
+
|
|
645
|
+
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
|
646
|
+
if (regex.test(fileName)) {
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Create progress bar
|
|
656
|
+
* @private
|
|
657
|
+
*/
|
|
658
|
+
#createProgressBar(totalFiles) {
|
|
659
|
+
if (totalFiles) {
|
|
660
|
+
// Percentage-based progress
|
|
661
|
+
const bar = new cliProgress.SingleBar(
|
|
662
|
+
{
|
|
663
|
+
format:
|
|
664
|
+
'📊 Scanning |{bar}| {percentage}% | {value}/{total} files | {rate} files/sec',
|
|
665
|
+
barCompleteChar: '\u2588',
|
|
666
|
+
barIncompleteChar: '\u2591',
|
|
667
|
+
hideCursor: true,
|
|
668
|
+
},
|
|
669
|
+
cliProgress.Presets.shades_classic,
|
|
670
|
+
);
|
|
671
|
+
bar.start(totalFiles, 0, { rate: '0.0' });
|
|
672
|
+
bar.startTime = Date.now();
|
|
673
|
+
return bar;
|
|
674
|
+
} else {
|
|
675
|
+
// Throughput-based progress
|
|
676
|
+
const bar = new cliProgress.SingleBar(
|
|
677
|
+
{
|
|
678
|
+
format: '📊 Scanning | {value} files | {rate} files/sec',
|
|
679
|
+
hideCursor: true,
|
|
680
|
+
clearOnComplete: false,
|
|
681
|
+
stopOnComplete: false,
|
|
682
|
+
},
|
|
683
|
+
cliProgress.Presets.legacy,
|
|
684
|
+
);
|
|
685
|
+
bar.start(0, 0, { rate: '0.0' });
|
|
686
|
+
bar.startTime = Date.now();
|
|
687
|
+
return bar;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Format bytes to human-readable size
|
|
693
|
+
* @private
|
|
694
|
+
*/
|
|
695
|
+
#formatBytes(bytes) {
|
|
696
|
+
if (bytes === 0) return '0 Bytes';
|
|
697
|
+
|
|
698
|
+
const k = 1024;
|
|
699
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
700
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
701
|
+
|
|
702
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Report progress to callback if available (for worker mode)
|
|
707
|
+
* @private
|
|
708
|
+
* @param {number} percent - Progress percentage (0-100)
|
|
709
|
+
* @param {string} message - Progress message
|
|
710
|
+
*/
|
|
711
|
+
#reportProgress(percent, message) {
|
|
712
|
+
if (this.onProgress && typeof this.onProgress === 'function') {
|
|
713
|
+
try {
|
|
714
|
+
this.onProgress(percent, message);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
logger.debug(`Progress callback error: ${error.message}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export default new ScanCommand();
|