locai 2.1.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/README.md +928 -0
- package/dist/cli.js +718 -0
- package/dist/commands/review.js +357 -0
- package/dist/commands/translator.js +29 -0
- package/dist/config/index.js +52 -0
- package/dist/core/adapters/base-adapter.js +1 -0
- package/dist/core/adapters/factory.js +44 -0
- package/dist/core/adapters/json-adapter.js +20 -0
- package/dist/core/adapters/po-adapter.js +50 -0
- package/dist/core/adapters/properties-adapter.js +31 -0
- package/dist/core/adapters/yaml-adapter.js +24 -0
- package/dist/core/context-processor.js +247 -0
- package/dist/core/detection/framework-detector.js +56 -0
- package/dist/core/detection/structure-detector.js +30 -0
- package/dist/core/fallback-provider.js +442 -0
- package/dist/core/orchestrator.js +438 -0
- package/dist/core/pipeline/Pipeline.js +48 -0
- package/dist/core/pipeline/PipelineStep.js +13 -0
- package/dist/core/pipeline/context.js +21 -0
- package/dist/core/pipeline/steps/CacheReadStep.js +80 -0
- package/dist/core/pipeline/steps/CacheWriteStep.js +32 -0
- package/dist/core/pipeline/steps/ConfidenceCheckStep.js +67 -0
- package/dist/core/pipeline/steps/GlossaryPostStep.js +17 -0
- package/dist/core/pipeline/steps/GlossaryPreStep.js +20 -0
- package/dist/core/pipeline/steps/InputValidationStep.js +26 -0
- package/dist/core/pipeline/steps/QualityCheckStep.js +48 -0
- package/dist/core/pipeline/steps/ToneCheckStep.js +58 -0
- package/dist/core/pipeline/steps/TranslationStep.js +65 -0
- package/dist/core/pipeline/steps/VectorCacheReadStep.js +84 -0
- package/dist/core/pipeline/steps/VectorCacheWriteStep.js +42 -0
- package/dist/core/pipeline/steps/__tests__/ToneCheckStep.test.js +100 -0
- package/dist/core/provider-factory.js +139 -0
- package/dist/providers/base-provider.js +230 -0
- package/dist/providers/dashscope.js +128 -0
- package/dist/providers/deepseek.js +111 -0
- package/dist/providers/gemini.js +135 -0
- package/dist/providers/openai.js +203 -0
- package/dist/providers/xai.js +111 -0
- package/dist/services/embedding-provider.js +50 -0
- package/dist/services/translation-service.js +609 -0
- package/dist/services/vector-store.js +79 -0
- package/dist/utils/ai-context-analyzer.js +460 -0
- package/dist/utils/confidence-scorer.js +277 -0
- package/dist/utils/error-helper.js +386 -0
- package/dist/utils/file-manager.js +357 -0
- package/dist/utils/glossary-manager.js +194 -0
- package/dist/utils/graceful-shutdown.js +118 -0
- package/dist/utils/input-validator.js +663 -0
- package/dist/utils/logger.js +374 -0
- package/dist/utils/object-transformer.js +177 -0
- package/dist/utils/progress-tracker.js +277 -0
- package/dist/utils/prompt-templates.js +415 -0
- package/dist/utils/quality/__tests__/tone-verifier.test.js +91 -0
- package/dist/utils/quality/base-checker.js +73 -0
- package/dist/utils/quality/code-block-checker.js +214 -0
- package/dist/utils/quality/html-tag-checker.js +64 -0
- package/dist/utils/quality/index.js +220 -0
- package/dist/utils/quality/length-checker.js +140 -0
- package/dist/utils/quality/markdown-checker.js +220 -0
- package/dist/utils/quality/placeholder-checker.js +142 -0
- package/dist/utils/quality/punctuation-checker.js +50 -0
- package/dist/utils/quality/quote-balance-checker.js +143 -0
- package/dist/utils/quality/special-characters-checker.js +229 -0
- package/dist/utils/quality/style-guide-checker.js +192 -0
- package/dist/utils/quality/text-sanitizer.js +120 -0
- package/dist/utils/quality/tone-verifier.js +91 -0
- package/dist/utils/rate-limiter.js +398 -0
- package/dist/utils/retry-helper.js +166 -0
- package/dist/utils/state-manager.js +162 -0
- package/dist/utils/statistics-manager.js +51 -0
- package/dist/utils/ui-manager.js +98 -0
- package/package.json +80 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { FormatFactory } from "../core/adapters/factory.js";
|
|
4
|
+
/**
|
|
5
|
+
* FileManager - Modern asynchronous file operations
|
|
6
|
+
* Handles multiple file formats via Adapter pattern.
|
|
7
|
+
*/
|
|
8
|
+
class FileManager {
|
|
9
|
+
/**
|
|
10
|
+
* Default options for file operations
|
|
11
|
+
*/
|
|
12
|
+
static defaultOptions = {
|
|
13
|
+
atomic: true,
|
|
14
|
+
createMissingDirs: true,
|
|
15
|
+
backupFiles: true,
|
|
16
|
+
backupDir: "./backups",
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
jsonIndent: 4,
|
|
19
|
+
};
|
|
20
|
+
static options = null;
|
|
21
|
+
/**
|
|
22
|
+
* Configure global options for file operations
|
|
23
|
+
* @param options - File operation options
|
|
24
|
+
*/
|
|
25
|
+
static configure(options) {
|
|
26
|
+
if (!options)
|
|
27
|
+
return;
|
|
28
|
+
this.options = {
|
|
29
|
+
...this.defaultOptions,
|
|
30
|
+
...options,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get current configuration
|
|
35
|
+
* @returns Current configuration
|
|
36
|
+
*/
|
|
37
|
+
static getConfig() {
|
|
38
|
+
return this.options || this.defaultOptions;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Find locale files in the specified directory
|
|
42
|
+
* @param localesDir - Directory containing locale files
|
|
43
|
+
* @param sourceLang - Source language code
|
|
44
|
+
* @returns Array of file paths
|
|
45
|
+
*/
|
|
46
|
+
static async findLocaleFiles(localesDir, sourceLang) {
|
|
47
|
+
try {
|
|
48
|
+
// Find any file with the source language name (ignoring extension)
|
|
49
|
+
const files = await fs.readdir(localesDir);
|
|
50
|
+
const sourceFiles = files
|
|
51
|
+
.filter((file) => file.startsWith(`${sourceLang}.`))
|
|
52
|
+
.map((file) => path.join(localesDir, file));
|
|
53
|
+
if (sourceFiles.length === 0) {
|
|
54
|
+
throw new Error(`Source language file not found for '${sourceLang}' in ${localesDir}`);
|
|
55
|
+
}
|
|
56
|
+
// Return the first match, or maybe prioritize JSON?
|
|
57
|
+
// For now, return all matches, but usually there's only one.
|
|
58
|
+
return sourceFiles;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err.code === "ENOENT") {
|
|
62
|
+
throw new Error(`Locales directory not found: ${localesDir}`);
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Source language file search error: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read structured data file asynchronously (JSON, YAML, etc.)
|
|
69
|
+
* @param filePath - Path to the file
|
|
70
|
+
* @param options - Options for reading
|
|
71
|
+
* @returns Parsed data
|
|
72
|
+
*/
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
static async readFile(filePath, options = {}) {
|
|
75
|
+
const config = { ...this.getConfig(), ...options };
|
|
76
|
+
try {
|
|
77
|
+
const content = await fs.readFile(filePath, config.encoding);
|
|
78
|
+
const adapter = options.format && options.format !== "auto"
|
|
79
|
+
? FormatFactory.getAdapterByFormat(options.format)
|
|
80
|
+
: FormatFactory.getAdapter(filePath);
|
|
81
|
+
return await adapter.parse(content);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
throw new Error(`File read error (${filePath}): ${err.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Write data to file asynchronously
|
|
89
|
+
* @param filePath - Path to write the file
|
|
90
|
+
* @param data - Data to write
|
|
91
|
+
* @param options - Options for writing
|
|
92
|
+
* @returns Success status
|
|
93
|
+
*/
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
static async writeFile(filePath, data, options = {}) {
|
|
96
|
+
const config = { ...this.getConfig(), ...options };
|
|
97
|
+
try {
|
|
98
|
+
// Create target directory if it doesn't exist
|
|
99
|
+
const dir = path.dirname(filePath);
|
|
100
|
+
if (config.createMissingDirs) {
|
|
101
|
+
await this.ensureDir(dir);
|
|
102
|
+
}
|
|
103
|
+
// Create backup directory if needed
|
|
104
|
+
if (config.backupFiles && config.backupDir) {
|
|
105
|
+
await this.ensureDir(config.backupDir);
|
|
106
|
+
}
|
|
107
|
+
// Create backup of existing file
|
|
108
|
+
if (config.backupFiles && config.backupDir) {
|
|
109
|
+
try {
|
|
110
|
+
await fs.access(filePath);
|
|
111
|
+
const backupPath = path.join(config.backupDir, `${path.basename(filePath)}.${Date.now()}.bak`);
|
|
112
|
+
await fs.copyFile(filePath, backupPath);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// Ignore if file doesn't exist
|
|
116
|
+
if (err.code !== "ENOENT") {
|
|
117
|
+
console.error(`Backup failed (${filePath}): ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Serialize content
|
|
122
|
+
const adapter = options.format && options.format !== "auto"
|
|
123
|
+
? FormatFactory.getAdapterByFormat(options.format)
|
|
124
|
+
: FormatFactory.getAdapter(filePath);
|
|
125
|
+
const content = await adapter.serialize(data, {
|
|
126
|
+
indent: options.indent || config.jsonIndent,
|
|
127
|
+
compact: config.compact,
|
|
128
|
+
});
|
|
129
|
+
// Use atomic write if configured
|
|
130
|
+
if (config.atomic) {
|
|
131
|
+
const tempFile = this._generateTempFilePath(filePath);
|
|
132
|
+
let tempFileCreated = false;
|
|
133
|
+
try {
|
|
134
|
+
await fs.writeFile(tempFile, content, config.encoding);
|
|
135
|
+
tempFileCreated = true;
|
|
136
|
+
await fs.rename(tempFile, filePath);
|
|
137
|
+
tempFileCreated = false;
|
|
138
|
+
}
|
|
139
|
+
catch (renameError) {
|
|
140
|
+
if (tempFileCreated) {
|
|
141
|
+
try {
|
|
142
|
+
await fs.unlink(tempFile);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* ignore cleanup error */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`Atomic write failed (${filePath}): ${renameError.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
await fs.writeFile(filePath, content, config.encoding);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
throw new Error(`File write error (${filePath}): ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Legacy method aliases for backward compatibility
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
+
static async readJSON(filePath, options = {}) {
|
|
163
|
+
return this.readFile(filePath, { ...options, format: "json" });
|
|
164
|
+
}
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
|
+
static async writeJSON(filePath, data, options = {}) {
|
|
167
|
+
return this.writeFile(filePath, data, { ...options, format: "json" });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Generate a unique temporary file path
|
|
171
|
+
*/
|
|
172
|
+
static _generateTempFilePath(filePath) {
|
|
173
|
+
const timestamp = Date.now();
|
|
174
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
175
|
+
return `${filePath}.tmp.${timestamp}.${random}`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Ensure directory exists
|
|
179
|
+
*/
|
|
180
|
+
static async ensureDir(dir) {
|
|
181
|
+
try {
|
|
182
|
+
await fs.mkdir(dir, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (err.code !== "EEXIST") {
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Scan for locale files
|
|
192
|
+
*/
|
|
193
|
+
static async scanLocaleFiles(localesDir, pattern = /.*/) {
|
|
194
|
+
try {
|
|
195
|
+
const files = await fs.readdir(localesDir);
|
|
196
|
+
return files
|
|
197
|
+
.filter((file) => pattern.test(file))
|
|
198
|
+
.map((file) => path.join(localesDir, file));
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
throw new Error(`Error scanning locale directory: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Get file modification time
|
|
206
|
+
*/
|
|
207
|
+
static async getModifiedTime(filePath) {
|
|
208
|
+
try {
|
|
209
|
+
const stats = await fs.stat(filePath);
|
|
210
|
+
return stats.mtime;
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
throw new Error(`Error getting file stats: ${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Check if file exists
|
|
218
|
+
*/
|
|
219
|
+
static async exists(filePath) {
|
|
220
|
+
try {
|
|
221
|
+
await fs.access(filePath);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Delete file
|
|
230
|
+
* @param filePath - Path to the file
|
|
231
|
+
* @param options - Options for deletion
|
|
232
|
+
* @returns Success status
|
|
233
|
+
*/
|
|
234
|
+
static async deleteFile(filePath, options = {}) {
|
|
235
|
+
const config = { ...this.getConfig(), ...options };
|
|
236
|
+
try {
|
|
237
|
+
// Create backup before deletion if backups are enabled
|
|
238
|
+
if (config.backupFiles && config.backupDir) {
|
|
239
|
+
try {
|
|
240
|
+
await fs.access(filePath);
|
|
241
|
+
await this.ensureDir(config.backupDir);
|
|
242
|
+
const backupPath = path.join(config.backupDir, `${path.basename(filePath)}.deleted.${Date.now()}.bak`);
|
|
243
|
+
await fs.copyFile(filePath, backupPath);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
console.error(`Backup failed (${filePath}): ${err.message}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
await fs.unlink(filePath);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
throw new Error(`File deletion error (${filePath}): ${err.message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Validate JSON file structure
|
|
258
|
+
* @param filePath - Path to the JSON file
|
|
259
|
+
* @returns Validation result
|
|
260
|
+
*/
|
|
261
|
+
static async validateJSONFile(filePath) {
|
|
262
|
+
try {
|
|
263
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
264
|
+
JSON.parse(content); // Throws if invalid
|
|
265
|
+
return {
|
|
266
|
+
valid: true,
|
|
267
|
+
filePath,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
return {
|
|
272
|
+
valid: false,
|
|
273
|
+
filePath,
|
|
274
|
+
error: error.message,
|
|
275
|
+
position: this._extractErrorPosition(error),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Extract error position from JSON parse error
|
|
281
|
+
* @param error - JSON parse error
|
|
282
|
+
* @returns Error position details
|
|
283
|
+
*/
|
|
284
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
285
|
+
static _extractErrorPosition(error) {
|
|
286
|
+
const message = error.message;
|
|
287
|
+
const positionMatch = message.match(/position (\d+)/);
|
|
288
|
+
const lineMatch = message.match(/line (\d+)/);
|
|
289
|
+
if (positionMatch || lineMatch) {
|
|
290
|
+
return {
|
|
291
|
+
position: positionMatch ? parseInt(positionMatch[1]) : null,
|
|
292
|
+
line: lineMatch ? parseInt(lineMatch[1]) : null,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Validate translation value can be safely added to JSON
|
|
299
|
+
* @param key - Translation key
|
|
300
|
+
* @param value - Translation value
|
|
301
|
+
* @returns Validation result
|
|
302
|
+
*/
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
static validateTranslationValue(key, value) {
|
|
305
|
+
try {
|
|
306
|
+
// Test if value can be part of valid JSON
|
|
307
|
+
const testObject = { [key]: value };
|
|
308
|
+
JSON.stringify(testObject);
|
|
309
|
+
JSON.parse(JSON.stringify(testObject)); // Round-trip test
|
|
310
|
+
return {
|
|
311
|
+
valid: true,
|
|
312
|
+
key,
|
|
313
|
+
value,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return {
|
|
318
|
+
valid: false,
|
|
319
|
+
key,
|
|
320
|
+
value,
|
|
321
|
+
error: `JSON validation failed: ${error.message}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* List files in a directory
|
|
327
|
+
* @param dirPath - Directory path
|
|
328
|
+
* @returns Array of file paths
|
|
329
|
+
*/
|
|
330
|
+
static async listFiles(dirPath) {
|
|
331
|
+
try {
|
|
332
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
333
|
+
const files = entries
|
|
334
|
+
.filter((entry) => entry.isFile())
|
|
335
|
+
.map((entry) => path.join(dirPath, entry.name));
|
|
336
|
+
return files;
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
throw new Error(`Directory read error (${dirPath}): ${err.message}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Ensure directory exists, create if it doesn't
|
|
344
|
+
* @param dirPath - Directory path
|
|
345
|
+
* @returns True if directory exists or was created
|
|
346
|
+
*/
|
|
347
|
+
static async ensureDirectoryExists(dirPath) {
|
|
348
|
+
try {
|
|
349
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
throw new Error(`Failed to create directory (${dirPath}): ${err.message}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export { FileManager };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glossary Manager
|
|
3
|
+
* Manages brand terminology and ensures consistent translations
|
|
4
|
+
*/
|
|
5
|
+
class GlossaryManager {
|
|
6
|
+
glossary;
|
|
7
|
+
enabled;
|
|
8
|
+
caseSensitive;
|
|
9
|
+
preserveFormatting;
|
|
10
|
+
constructor(config = {}) {
|
|
11
|
+
this.glossary = config.glossary || {};
|
|
12
|
+
this.enabled = config.enabled !== false;
|
|
13
|
+
this.caseSensitive = config.caseSensitive !== false;
|
|
14
|
+
this.preserveFormatting = config.preserveFormatting !== false;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Apply glossary terms to source text before translation
|
|
18
|
+
* Replaces terms with protected tokens to preserve them
|
|
19
|
+
*/
|
|
20
|
+
protectTerms(text, sourceLang, targetLang) {
|
|
21
|
+
if (!this.enabled || !text || typeof text !== "string") {
|
|
22
|
+
return { protectedText: text, termMap: new Map() };
|
|
23
|
+
}
|
|
24
|
+
const termMap = new Map();
|
|
25
|
+
let protectedText = text;
|
|
26
|
+
let tokenIndex = 0;
|
|
27
|
+
// Get applicable glossary entries
|
|
28
|
+
const entries = this.getApplicableEntries(sourceLang, targetLang);
|
|
29
|
+
// Sort by length (longest first) to avoid partial matches
|
|
30
|
+
const sortedEntries = entries.sort((a, b) => b.term.length - a.term.length);
|
|
31
|
+
for (const entry of sortedEntries) {
|
|
32
|
+
const { term, translation, caseSensitive } = entry;
|
|
33
|
+
const isCaseSensitive = caseSensitive ?? this.caseSensitive;
|
|
34
|
+
// Create regex for finding the term
|
|
35
|
+
const flags = isCaseSensitive ? "g" : "gi";
|
|
36
|
+
const escapedTerm = this.escapeRegex(term);
|
|
37
|
+
const regex = new RegExp(`\\b${escapedTerm}\\b`, flags);
|
|
38
|
+
// Find all matches
|
|
39
|
+
const matches = protectedText.match(regex);
|
|
40
|
+
if (matches) {
|
|
41
|
+
matches.forEach((match) => {
|
|
42
|
+
const token = `__GLOSSARY_TOKEN_${tokenIndex}__`;
|
|
43
|
+
tokenIndex++;
|
|
44
|
+
// Preserve original formatting if needed
|
|
45
|
+
const preservedTranslation = this.preserveFormatting
|
|
46
|
+
? this.matchFormatting(match, translation)
|
|
47
|
+
: translation;
|
|
48
|
+
termMap.set(token, {
|
|
49
|
+
original: match,
|
|
50
|
+
translation: preservedTranslation,
|
|
51
|
+
term: term,
|
|
52
|
+
});
|
|
53
|
+
// Replace with token
|
|
54
|
+
protectedText = protectedText.replace(match, token);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { protectedText, termMap };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Restore protected terms in translated text
|
|
62
|
+
*/
|
|
63
|
+
restoreTerms(translatedText, termMap) {
|
|
64
|
+
if (!this.enabled || !termMap || termMap.size === 0) {
|
|
65
|
+
return translatedText;
|
|
66
|
+
}
|
|
67
|
+
let restoredText = translatedText;
|
|
68
|
+
// Replace tokens with translations
|
|
69
|
+
for (const [token, data] of termMap.entries()) {
|
|
70
|
+
restoredText = restoredText.replace(new RegExp(token, "g"), data.translation);
|
|
71
|
+
}
|
|
72
|
+
return restoredText;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Get applicable glossary entries for language pair
|
|
76
|
+
*/
|
|
77
|
+
getApplicableEntries(sourceLang, targetLang) {
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (const [term, config] of Object.entries(this.glossary)) {
|
|
81
|
+
// Simple format: { "API": "API" }
|
|
82
|
+
if (typeof config === "string") {
|
|
83
|
+
entries.push({
|
|
84
|
+
term,
|
|
85
|
+
translation: config,
|
|
86
|
+
caseSensitive: this.caseSensitive,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Advanced format: { "API": { translation: "API", caseSensitive: true } }
|
|
90
|
+
else if (typeof config === "object" && config.translation) {
|
|
91
|
+
entries.push({
|
|
92
|
+
term,
|
|
93
|
+
translation: config.translation,
|
|
94
|
+
caseSensitive: config.caseSensitive ?? this.caseSensitive,
|
|
95
|
+
languages: config.languages,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// Language-specific format
|
|
99
|
+
else if (typeof config === "object" && config[targetLang]) {
|
|
100
|
+
entries.push({
|
|
101
|
+
term,
|
|
102
|
+
translation: config[targetLang],
|
|
103
|
+
caseSensitive: this.caseSensitive,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return entries;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Match formatting (capitalization) from original to translation
|
|
111
|
+
*/
|
|
112
|
+
matchFormatting(original, translation) {
|
|
113
|
+
// All caps
|
|
114
|
+
if (original === original.toUpperCase()) {
|
|
115
|
+
return translation.toUpperCase();
|
|
116
|
+
}
|
|
117
|
+
// Title case (first letter uppercase)
|
|
118
|
+
if (original[0] === original[0].toUpperCase() &&
|
|
119
|
+
original.slice(1) === original.slice(1).toLowerCase()) {
|
|
120
|
+
return translation.charAt(0).toUpperCase() + translation.slice(1).toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
// First char uppercase
|
|
123
|
+
if (original[0] === original[0].toUpperCase()) {
|
|
124
|
+
return translation.charAt(0).toUpperCase() + translation.slice(1);
|
|
125
|
+
}
|
|
126
|
+
return translation;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Escape special regex characters
|
|
130
|
+
*/
|
|
131
|
+
escapeRegex(string) {
|
|
132
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Validate glossary configuration
|
|
136
|
+
*/
|
|
137
|
+
validate() {
|
|
138
|
+
const errors = [];
|
|
139
|
+
const warnings = [];
|
|
140
|
+
if (!this.glossary || typeof this.glossary !== "object") {
|
|
141
|
+
errors.push("Glossary must be an object");
|
|
142
|
+
return { valid: false, errors, warnings };
|
|
143
|
+
}
|
|
144
|
+
for (const [term, config] of Object.entries(this.glossary)) {
|
|
145
|
+
if (!term || typeof term !== "string") {
|
|
146
|
+
errors.push(`Invalid term: ${term}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Validate simple format
|
|
150
|
+
if (typeof config === "string" && !config) {
|
|
151
|
+
warnings.push(`Empty translation for term: ${term}`);
|
|
152
|
+
}
|
|
153
|
+
// Validate object format
|
|
154
|
+
if (typeof config === "object") {
|
|
155
|
+
if (!config.translation && !Object.keys(config).some((key) => key.length === 2)) {
|
|
156
|
+
errors.push(`Invalid glossary entry for term: ${term}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
valid: errors.length === 0,
|
|
162
|
+
errors,
|
|
163
|
+
warnings,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Get statistics about glossary
|
|
168
|
+
*/
|
|
169
|
+
getStats() {
|
|
170
|
+
const terms = Object.keys(this.glossary);
|
|
171
|
+
return {
|
|
172
|
+
totalTerms: terms.length,
|
|
173
|
+
enabled: this.enabled,
|
|
174
|
+
caseSensitive: this.caseSensitive,
|
|
175
|
+
preserveFormatting: this.preserveFormatting,
|
|
176
|
+
terms: terms,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Load glossary from file
|
|
181
|
+
*/
|
|
182
|
+
static async loadFromFile(filePath) {
|
|
183
|
+
try {
|
|
184
|
+
const { readFile } = await import("fs/promises");
|
|
185
|
+
const content = await readFile(filePath, "utf8");
|
|
186
|
+
const glossary = JSON.parse(content);
|
|
187
|
+
return new GlossaryManager({ glossary, enabled: true });
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
throw new Error(`Failed to load glossary from ${filePath}: ${error.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export default GlossaryManager;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import rateLimiter from "./rate-limiter.js";
|
|
2
|
+
class GracefulShutdown {
|
|
3
|
+
options;
|
|
4
|
+
isShuttingDown;
|
|
5
|
+
shutdownCallbacks;
|
|
6
|
+
logger;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.options = {
|
|
9
|
+
shutdownTimeout: 5000,
|
|
10
|
+
waitForPending: true,
|
|
11
|
+
...options,
|
|
12
|
+
};
|
|
13
|
+
this.isShuttingDown = false;
|
|
14
|
+
this.shutdownCallbacks = [];
|
|
15
|
+
this.logger = options.logger || console;
|
|
16
|
+
}
|
|
17
|
+
registerCallback(callback) {
|
|
18
|
+
if (typeof callback === "function") {
|
|
19
|
+
this.shutdownCallbacks.push(callback);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
unregisterCallback(callback) {
|
|
23
|
+
if (typeof callback === "function") {
|
|
24
|
+
const index = this.shutdownCallbacks.indexOf(callback);
|
|
25
|
+
if (index > -1) {
|
|
26
|
+
this.shutdownCallbacks.splice(index, 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
init() {
|
|
31
|
+
process.on("SIGTERM", () => this.handleSignal("SIGTERM"));
|
|
32
|
+
process.on("SIGINT", () => this.handleSignal("SIGINT"));
|
|
33
|
+
process.on("uncaughtException", (err) => {
|
|
34
|
+
this.logger.error("Uncaught Exception:", err);
|
|
35
|
+
this.shutdown(1);
|
|
36
|
+
});
|
|
37
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
38
|
+
this.logger.error("Unhandled Rejection at:", promise, "reason:", reason);
|
|
39
|
+
this.shutdown(1);
|
|
40
|
+
});
|
|
41
|
+
this.logger.info("Graceful shutdown handlers initialized");
|
|
42
|
+
}
|
|
43
|
+
async handleSignal(signal) {
|
|
44
|
+
this.logger.info(`Received ${signal}, initiating graceful shutdown...`);
|
|
45
|
+
await this.shutdown(0);
|
|
46
|
+
}
|
|
47
|
+
async shutdown(exitCode = 0) {
|
|
48
|
+
if (this.isShuttingDown) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.isShuttingDown = true;
|
|
52
|
+
this.logger.info("Starting graceful shutdown...");
|
|
53
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
this.logger.warn(`Shutdown timeout (${this.options.shutdownTimeout}ms) exceeded, forcing exit`);
|
|
56
|
+
resolve();
|
|
57
|
+
}, this.options.shutdownTimeout);
|
|
58
|
+
});
|
|
59
|
+
const shutdownProcess = this.performShutdown();
|
|
60
|
+
try {
|
|
61
|
+
await Promise.race([shutdownProcess, timeoutPromise]);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
this.logger.error("Error during shutdown:", error);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
process.exit(exitCode);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async performShutdown() {
|
|
71
|
+
const startTime = Date.now();
|
|
72
|
+
try {
|
|
73
|
+
this.logger.info("Executing shutdown callbacks...");
|
|
74
|
+
for (const callback of this.shutdownCallbacks) {
|
|
75
|
+
try {
|
|
76
|
+
await callback();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
this.logger.warn("Error in shutdown callback:", error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (this.options.waitForPending) {
|
|
83
|
+
this.logger.info("Waiting for pending operations...");
|
|
84
|
+
await this.waitForPendingOperations();
|
|
85
|
+
}
|
|
86
|
+
const duration = Date.now() - startTime;
|
|
87
|
+
this.logger.info(`Graceful shutdown completed in ${duration}ms`);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.logger.error("Error during shutdown process:", error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async waitForPendingOperations() {
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
const providerStatus = rateLimiter.getStatus();
|
|
96
|
+
let hasPending = false;
|
|
97
|
+
for (const [provider, status] of Object.entries(providerStatus)) {
|
|
98
|
+
if (status.queueSize > 0 || status.processing > 0) {
|
|
99
|
+
hasPending = true;
|
|
100
|
+
this.logger.info(`Waiting for ${status.queueSize} queued and ${status.processing} processing operations for ${provider}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (hasPending) {
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
105
|
+
try {
|
|
106
|
+
rateLimiter.clearAllQueues();
|
|
107
|
+
this.logger.info("Forced queue cleanup completed");
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
this.logger.warn("Error during forced queue cleanup:", error.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const gracefulShutdown = new GracefulShutdown();
|
|
116
|
+
// Can't auto-init if we want to test or configure it, but existing code did this.
|
|
117
|
+
gracefulShutdown.init();
|
|
118
|
+
export default gracefulShutdown;
|