@arela/uploader 1.0.20 → 1.0.22
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/package.json +2 -1
- package/src/commands/DatastageCommand.js +164 -0
- package/src/commands/GDriveSyncCommand.js +475 -0
- package/src/commands/IdentifyCommand.js +179 -35
- package/src/commands/PollWorkerCommand.js +2 -0
- package/src/commands/ScanCommand.js +6 -3
- package/src/config/config.js +88 -2
- package/src/document-type-shared.js +13 -3
- package/src/document-types/_pedimento-shared-extractors.js +322 -0
- package/src/document-types/pedimento-completo-xml.js +322 -0
- package/src/document-types/pedimento-completo.js +99 -0
- package/src/document-types/pedimento-simplificado.js +37 -287
- package/src/file-detection.js +36 -2
- package/src/index.js +69 -0
- package/src/services/DatabaseService.js +3 -1
- package/src/services/DatastageApiService.js +240 -0
- package/src/services/GoogleDriveService.js +217 -0
- package/src/services/ScanApiService.js +30 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import FormData from 'form-data';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { Agent } from 'http';
|
|
4
|
+
import { Agent as HttpsAgent } from 'https';
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
import appConfig from '../config/config.js';
|
|
9
|
+
import logger from './LoggingService.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Datastage API Service
|
|
13
|
+
* Handles API communication for the arela datastage command:
|
|
14
|
+
* - tracking endpoints under /api/uploader/datastage/*
|
|
15
|
+
* - zip upload endpoint POST /api/datastage (multipart, field: zipFile)
|
|
16
|
+
*/
|
|
17
|
+
export class DatastageApiService {
|
|
18
|
+
/**
|
|
19
|
+
* @param {string|null} apiTarget - 'default'|'agencia'|'cliente'
|
|
20
|
+
*/
|
|
21
|
+
constructor(apiTarget = null) {
|
|
22
|
+
this.apiTarget = apiTarget;
|
|
23
|
+
const apiConfig = appConfig.getApiConfig(apiTarget);
|
|
24
|
+
this.baseUrl = apiConfig.baseUrl;
|
|
25
|
+
this.token = apiConfig.token;
|
|
26
|
+
|
|
27
|
+
const maxApiConnections = parseInt(process.env.MAX_API_CONNECTIONS) || 10;
|
|
28
|
+
const connectionTimeout =
|
|
29
|
+
parseInt(process.env.API_CONNECTION_TIMEOUT) || 300000;
|
|
30
|
+
|
|
31
|
+
this.maxRetries = parseInt(process.env.API_MAX_RETRIES) || 3;
|
|
32
|
+
this.useExponentialBackoff =
|
|
33
|
+
process.env.API_RETRY_EXPONENTIAL_BACKOFF !== 'false';
|
|
34
|
+
this.fixedRetryDelay = parseInt(process.env.API_RETRY_DELAY) || 1000;
|
|
35
|
+
|
|
36
|
+
const agentOpts = {
|
|
37
|
+
keepAlive: true,
|
|
38
|
+
keepAliveMsecs: 30000,
|
|
39
|
+
maxSockets: maxApiConnections,
|
|
40
|
+
maxFreeSockets: Math.ceil(maxApiConnections / 2),
|
|
41
|
+
maxTotalSockets: maxApiConnections + 5,
|
|
42
|
+
timeout: connectionTimeout,
|
|
43
|
+
scheduling: 'fifo',
|
|
44
|
+
};
|
|
45
|
+
this.httpAgent = new Agent(agentOpts);
|
|
46
|
+
this.httpsAgent = new HttpsAgent(agentOpts);
|
|
47
|
+
|
|
48
|
+
logger.debug(
|
|
49
|
+
`🔗 Datastage API Service configured (target=${apiTarget || 'default'})`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#getAgent(url) {
|
|
54
|
+
return url.startsWith('https://') ? this.httpsAgent : this.httpAgent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#isRetryableError(error, response = null) {
|
|
58
|
+
if (
|
|
59
|
+
error?.code === 'ECONNRESET' ||
|
|
60
|
+
error?.code === 'ETIMEDOUT' ||
|
|
61
|
+
error?.code === 'ECONNREFUSED' ||
|
|
62
|
+
error?.code === 'ENOTFOUND' ||
|
|
63
|
+
error?.code === 'EAI_AGAIN'
|
|
64
|
+
) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (response) {
|
|
68
|
+
const s = response.status;
|
|
69
|
+
if (s === 429 || (s >= 500 && s < 600)) return true;
|
|
70
|
+
}
|
|
71
|
+
if (error?.message && error.message.includes('timeout')) return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#calculateBackoff(attempt) {
|
|
76
|
+
if (!this.useExponentialBackoff) {
|
|
77
|
+
const jitter = this.fixedRetryDelay * 0.2 * (Math.random() * 2 - 1);
|
|
78
|
+
return Math.floor(this.fixedRetryDelay + jitter);
|
|
79
|
+
}
|
|
80
|
+
const baseDelay = 1000;
|
|
81
|
+
const maxDelay = 16000;
|
|
82
|
+
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
|
83
|
+
const jitter = delay * 0.2 * (Math.random() * 2 - 1);
|
|
84
|
+
return Math.floor(delay + jitter);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#sleep(ms) {
|
|
88
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #requestJson(endpoint, method = 'GET', body = null, headers = {}) {
|
|
92
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
93
|
+
const options = {
|
|
94
|
+
method,
|
|
95
|
+
headers: {
|
|
96
|
+
'x-api-key': this.token,
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
...headers,
|
|
99
|
+
},
|
|
100
|
+
agent: this.#getAgent(url),
|
|
101
|
+
};
|
|
102
|
+
if (body) options.body = JSON.stringify(body);
|
|
103
|
+
|
|
104
|
+
let lastError;
|
|
105
|
+
let lastResponse = null;
|
|
106
|
+
const retries = this.maxRetries;
|
|
107
|
+
|
|
108
|
+
for (let attempt = 1; attempt <= retries + 1; attempt++) {
|
|
109
|
+
try {
|
|
110
|
+
const response = await fetch(url, options);
|
|
111
|
+
lastResponse = response;
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const errorText = await response.text();
|
|
114
|
+
let errorMessage = `API ${method} ${endpoint} failed: ${response.status} ${response.statusText}`;
|
|
115
|
+
try {
|
|
116
|
+
const j = JSON.parse(errorText);
|
|
117
|
+
errorMessage = j.message || errorMessage;
|
|
118
|
+
} catch {
|
|
119
|
+
errorMessage = errorText || errorMessage;
|
|
120
|
+
}
|
|
121
|
+
const err = new Error(errorMessage);
|
|
122
|
+
err.status = response.status;
|
|
123
|
+
if (this.#isRetryableError(err, response) && attempt <= retries) {
|
|
124
|
+
const d = this.#calculateBackoff(attempt);
|
|
125
|
+
logger.warn(
|
|
126
|
+
`Retrying ${method} ${endpoint} (attempt ${attempt}/${retries + 1}) in ${d}ms: ${errorMessage}`,
|
|
127
|
+
);
|
|
128
|
+
await this.#sleep(d);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
return await response.json();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
lastError = error;
|
|
136
|
+
if (this.#isRetryableError(error, lastResponse) && attempt <= retries) {
|
|
137
|
+
const d = this.#calculateBackoff(attempt);
|
|
138
|
+
logger.warn(
|
|
139
|
+
`Retrying ${method} ${endpoint} (attempt ${attempt}/${retries + 1}) in ${d}ms: ${error.message}`,
|
|
140
|
+
);
|
|
141
|
+
await this.#sleep(d);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw lastError;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Tracking endpoints ---
|
|
151
|
+
|
|
152
|
+
async registerUpload({
|
|
153
|
+
absolutePath,
|
|
154
|
+
fileName,
|
|
155
|
+
sizeBytes,
|
|
156
|
+
fileModifiedAt,
|
|
157
|
+
sourceDirectory,
|
|
158
|
+
}) {
|
|
159
|
+
return this.#requestJson('/api/uploader/datastage/register', 'POST', {
|
|
160
|
+
absolutePath,
|
|
161
|
+
fileName,
|
|
162
|
+
sizeBytes,
|
|
163
|
+
fileModifiedAt,
|
|
164
|
+
sourceDirectory,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getPending(sourceDirectory = null) {
|
|
169
|
+
const qs = sourceDirectory
|
|
170
|
+
? `?sourceDirectory=${encodeURIComponent(sourceDirectory)}`
|
|
171
|
+
: '';
|
|
172
|
+
return this.#requestJson(`/api/uploader/datastage/pending${qs}`, 'GET');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async getStats(sourceDirectory = null) {
|
|
176
|
+
const qs = sourceDirectory
|
|
177
|
+
? `?sourceDirectory=${encodeURIComponent(sourceDirectory)}`
|
|
178
|
+
: '';
|
|
179
|
+
return this.#requestJson(`/api/uploader/datastage/stats${qs}`, 'GET');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async markUploaded(id, { datastageId, folio }) {
|
|
183
|
+
return this.#requestJson(
|
|
184
|
+
`/api/uploader/datastage/${id}/mark-uploaded`,
|
|
185
|
+
'PATCH',
|
|
186
|
+
{ datastageId, folio },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async markFailed(id, error) {
|
|
191
|
+
return this.#requestJson(
|
|
192
|
+
`/api/uploader/datastage/${id}/mark-failed`,
|
|
193
|
+
'PATCH',
|
|
194
|
+
{ error: String(error || 'unknown') },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Zip upload ---
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Upload a single zip file to POST /api/datastage (multipart, field name 'zipFile').
|
|
202
|
+
* Returns the created Datastage row { id, folio, ... }.
|
|
203
|
+
*/
|
|
204
|
+
async uploadZip(localPath) {
|
|
205
|
+
const url = `${this.baseUrl}/api/datastage`;
|
|
206
|
+
const form = new FormData();
|
|
207
|
+
const fileName = path.basename(localPath);
|
|
208
|
+
form.append('zipFile', fs.createReadStream(localPath), {
|
|
209
|
+
filename: fileName,
|
|
210
|
+
contentType: 'application/zip',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const response = await fetch(url, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: {
|
|
216
|
+
'x-api-key': this.token,
|
|
217
|
+
...form.getHeaders(),
|
|
218
|
+
},
|
|
219
|
+
body: form,
|
|
220
|
+
agent: this.#getAgent(url),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const text = await response.text();
|
|
225
|
+
let msg = `Datastage upload failed: ${response.status} ${response.statusText}`;
|
|
226
|
+
try {
|
|
227
|
+
const j = JSON.parse(text);
|
|
228
|
+
msg = j.message || msg;
|
|
229
|
+
} catch {
|
|
230
|
+
msg = text || msg;
|
|
231
|
+
}
|
|
232
|
+
const err = new Error(msg);
|
|
233
|
+
err.status = response.status;
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
236
|
+
return await response.json();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default DatastageApiService;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { google } from 'googleapis';
|
|
3
|
+
|
|
4
|
+
import appConfig from '../config/config.js';
|
|
5
|
+
import PathNormalizer from '../utils/PathNormalizer.js';
|
|
6
|
+
import logger from './LoggingService.js';
|
|
7
|
+
|
|
8
|
+
const NATIVE_DOC_PREFIX = 'application/vnd.google-apps.';
|
|
9
|
+
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
|
10
|
+
const SHORTCUT_MIME = 'application/vnd.google-apps.shortcut';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_FILE_FIELDS =
|
|
13
|
+
'id,name,mimeType,modifiedTime,size,md5Checksum,parents,trashed,shortcutDetails';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Google Drive Service
|
|
17
|
+
* Thin wrapper around googleapis Drive v3 with Service Account auth.
|
|
18
|
+
* Read-only by design (scope: drive.readonly).
|
|
19
|
+
*/
|
|
20
|
+
export class GoogleDriveService {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.config = appConfig.getGDriveConfig();
|
|
23
|
+
this.drive = null;
|
|
24
|
+
this.commonParams = {
|
|
25
|
+
supportsAllDrives: true,
|
|
26
|
+
includeItemsFromAllDrives: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Lazy-init Drive v3 client.
|
|
32
|
+
* @returns {import('googleapis').drive_v3.Drive}
|
|
33
|
+
*/
|
|
34
|
+
async getClient() {
|
|
35
|
+
if (this.drive) return this.drive;
|
|
36
|
+
|
|
37
|
+
let credentials = null;
|
|
38
|
+
let keyFile = null;
|
|
39
|
+
|
|
40
|
+
if (this.config.serviceAccountJson) {
|
|
41
|
+
try {
|
|
42
|
+
credentials = JSON.parse(this.config.serviceAccountJson);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`GDRIVE_SERVICE_ACCOUNT_JSON is not valid JSON: ${err.message}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
} else if (this.config.serviceAccountFile) {
|
|
49
|
+
keyFile = PathNormalizer.toAbsolutePath(this.config.serviceAccountFile);
|
|
50
|
+
if (!fs.existsSync(keyFile)) {
|
|
51
|
+
throw new Error(`Service account file not found: ${keyFile}`);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'No Google Drive credentials configured (GDRIVE_SERVICE_ACCOUNT_FILE or GDRIVE_SERVICE_ACCOUNT_JSON)',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const auth = new google.auth.GoogleAuth({
|
|
60
|
+
credentials,
|
|
61
|
+
keyFile,
|
|
62
|
+
scopes: ['https://www.googleapis.com/auth/drive.readonly'],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.drive = google.drive({ version: 'v3', auth });
|
|
66
|
+
return this.drive;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get metadata for a single file/folder.
|
|
71
|
+
*/
|
|
72
|
+
async getFile(fileId, fields = DEFAULT_FILE_FIELDS) {
|
|
73
|
+
const drive = await this.getClient();
|
|
74
|
+
const res = await drive.files.get({
|
|
75
|
+
fileId,
|
|
76
|
+
fields,
|
|
77
|
+
...this.commonParams,
|
|
78
|
+
});
|
|
79
|
+
return res.data;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* List direct children of a folder, paginated.
|
|
84
|
+
* Yields each child file metadata one at a time.
|
|
85
|
+
* @param {string} folderId
|
|
86
|
+
* @param {Object} [options]
|
|
87
|
+
* @param {string} [options.fields]
|
|
88
|
+
* @param {number} [options.pageSize]
|
|
89
|
+
* @returns {AsyncGenerator<Object>}
|
|
90
|
+
*/
|
|
91
|
+
async *listChildren(folderId, options = {}) {
|
|
92
|
+
const drive = await this.getClient();
|
|
93
|
+
const fields = options.fields || DEFAULT_FILE_FIELDS;
|
|
94
|
+
const pageSize = options.pageSize || this.config.pageSize || 1000;
|
|
95
|
+
|
|
96
|
+
let pageToken = undefined;
|
|
97
|
+
|
|
98
|
+
do {
|
|
99
|
+
const res = await this.#withRetry(() =>
|
|
100
|
+
drive.files.list({
|
|
101
|
+
q: `'${folderId}' in parents and trashed = false`,
|
|
102
|
+
fields: `nextPageToken, files(${fields})`,
|
|
103
|
+
pageSize,
|
|
104
|
+
pageToken,
|
|
105
|
+
orderBy: 'folder,name',
|
|
106
|
+
...this.commonParams,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const files = res.data.files || [];
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
yield file;
|
|
113
|
+
}
|
|
114
|
+
pageToken = res.data.nextPageToken;
|
|
115
|
+
} while (pageToken);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Download a file's binary content to a writable stream.
|
|
120
|
+
* Returns total bytes written.
|
|
121
|
+
* @param {string} fileId
|
|
122
|
+
* @param {import('stream').Writable} destStream
|
|
123
|
+
* @returns {Promise<number>}
|
|
124
|
+
*/
|
|
125
|
+
async downloadFile(fileId, destStream) {
|
|
126
|
+
const drive = await this.getClient();
|
|
127
|
+
|
|
128
|
+
const res = await this.#withRetry(() =>
|
|
129
|
+
drive.files.get(
|
|
130
|
+
{
|
|
131
|
+
fileId,
|
|
132
|
+
alt: 'media',
|
|
133
|
+
...this.commonParams,
|
|
134
|
+
},
|
|
135
|
+
{ responseType: 'stream' },
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return await new Promise((resolve, reject) => {
|
|
140
|
+
let bytes = 0;
|
|
141
|
+
res.data.on('data', (chunk) => {
|
|
142
|
+
bytes += chunk.length;
|
|
143
|
+
});
|
|
144
|
+
res.data.on('error', reject);
|
|
145
|
+
destStream.on('error', reject);
|
|
146
|
+
destStream.on('finish', () => resolve(bytes));
|
|
147
|
+
res.data.pipe(destStream);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Static helpers
|
|
153
|
+
*/
|
|
154
|
+
static isFolder(file) {
|
|
155
|
+
return file?.mimeType === FOLDER_MIME;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static isShortcut(file) {
|
|
159
|
+
return file?.mimeType === SHORTCUT_MIME;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
static isNativeGoogleDoc(file) {
|
|
163
|
+
return (
|
|
164
|
+
typeof file?.mimeType === 'string' &&
|
|
165
|
+
file.mimeType.startsWith(NATIVE_DOC_PREFIX) &&
|
|
166
|
+
!GoogleDriveService.isFolder(file) &&
|
|
167
|
+
!GoogleDriveService.isShortcut(file)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Retry wrapper with exponential backoff for transient errors.
|
|
173
|
+
* @private
|
|
174
|
+
*/
|
|
175
|
+
async #withRetry(fn, maxAttempts = 5) {
|
|
176
|
+
let attempt = 0;
|
|
177
|
+
let lastErr;
|
|
178
|
+
|
|
179
|
+
while (attempt < maxAttempts) {
|
|
180
|
+
try {
|
|
181
|
+
return await fn();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
lastErr = err;
|
|
184
|
+
const status = err?.code || err?.response?.status;
|
|
185
|
+
const reason =
|
|
186
|
+
err?.errors?.[0]?.reason ||
|
|
187
|
+
err?.response?.data?.error?.errors?.[0]?.reason;
|
|
188
|
+
|
|
189
|
+
const retryable =
|
|
190
|
+
status === 429 ||
|
|
191
|
+
status === 500 ||
|
|
192
|
+
status === 502 ||
|
|
193
|
+
status === 503 ||
|
|
194
|
+
status === 504 ||
|
|
195
|
+
reason === 'rateLimitExceeded' ||
|
|
196
|
+
reason === 'userRateLimitExceeded' ||
|
|
197
|
+
reason === 'backendError';
|
|
198
|
+
|
|
199
|
+
if (!retryable || attempt === maxAttempts - 1) {
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const delayMs =
|
|
204
|
+
Math.min(60_000, 2 ** attempt * 1000) + Math.random() * 500;
|
|
205
|
+
logger.warn(
|
|
206
|
+
`⚠️ Drive API ${status || ''} ${reason || ''} — retry ${attempt + 1}/${maxAttempts} in ${Math.round(delayMs)}ms`,
|
|
207
|
+
);
|
|
208
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
209
|
+
attempt += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
throw lastErr;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default GoogleDriveService;
|
|
@@ -375,6 +375,20 @@ export class ScanApiService {
|
|
|
375
375
|
* @param {boolean} allTypes - When true, fetch all supported file types instead of just likely-simplificado PDFs
|
|
376
376
|
* @returns {Promise<Object>} { data: Array, hasMore: boolean }
|
|
377
377
|
*/
|
|
378
|
+
/**
|
|
379
|
+
* Get a single file record by ID (for single-file identify mode).
|
|
380
|
+
* @param {string} tableName - Scan table name (with or without cli. prefix)
|
|
381
|
+
* @param {string} fileId - UUID of the file record
|
|
382
|
+
* @returns {Promise<{ id: string, file_name: string, file_extension: string, absolute_path: string }>}
|
|
383
|
+
*/
|
|
384
|
+
async getFileRecord(tableName, fileId) {
|
|
385
|
+
const cleanTable = tableName.replace(/^cli\./, '');
|
|
386
|
+
const url = `/api/uploader/scan/file-record?tableName=${encodeURIComponent(cleanTable)}&fileId=${encodeURIComponent(fileId)}`;
|
|
387
|
+
const result = await this.#request(url, 'GET');
|
|
388
|
+
logger.debug(`Fetched file record ${fileId} from ${cleanTable}`);
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
|
|
378
392
|
async fetchPdfsForDetection(
|
|
379
393
|
tableName,
|
|
380
394
|
offset = 0,
|
|
@@ -398,6 +412,22 @@ export class ScanApiService {
|
|
|
398
412
|
return result;
|
|
399
413
|
}
|
|
400
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Reset detection_attempts to 0 for undetected files so they can be re-processed.
|
|
417
|
+
* @param {string} tableName - Target scan table name
|
|
418
|
+
* @param {string|null} absolutePath - If provided, reset only this specific file
|
|
419
|
+
* @returns {Promise<{ reset: number }>}
|
|
420
|
+
*/
|
|
421
|
+
async resetDetectionAttempts(tableName, absolutePath = null) {
|
|
422
|
+
let url = `/api/uploader/scan/reset-detection-attempts?tableName=${encodeURIComponent(tableName)}`;
|
|
423
|
+
if (absolutePath) {
|
|
424
|
+
url += `&absolutePath=${encodeURIComponent(absolutePath)}`;
|
|
425
|
+
}
|
|
426
|
+
const result = await this.#request(url, 'PATCH');
|
|
427
|
+
logger.debug(`Reset ${result.reset} detection attempt(s) in ${tableName}`);
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
|
|
401
431
|
/**
|
|
402
432
|
* Batch update detection results
|
|
403
433
|
* @param {string} tableName - Target table name
|