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,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length Validation Checker.
|
|
3
|
+
* Checks if translated text length is within acceptable bounds relative to source text.
|
|
4
|
+
*/
|
|
5
|
+
class LengthChecker {
|
|
6
|
+
defaultConfig;
|
|
7
|
+
constructor() {
|
|
8
|
+
this.defaultConfig = {
|
|
9
|
+
mode: "strict",
|
|
10
|
+
maxDeviation: 0.1,
|
|
11
|
+
minDeviation: -0.1,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check if translated text length is acceptable.
|
|
16
|
+
*/
|
|
17
|
+
checkLength(source, translated, options = {}) {
|
|
18
|
+
try {
|
|
19
|
+
const sourceLength = this.calculateLength(source);
|
|
20
|
+
const translatedLength = this.calculateLength(translated);
|
|
21
|
+
const ratio = sourceLength > 0 ? translatedLength / sourceLength : 1;
|
|
22
|
+
const config = this.getConfig(options);
|
|
23
|
+
const { minRatio, maxRatio } = this.calculateAllowedRange(config);
|
|
24
|
+
if (ratio < minRatio || ratio > maxRatio) {
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
type: "length",
|
|
28
|
+
severity: this.determineSeverity(ratio, minRatio, maxRatio),
|
|
29
|
+
message: this.generateErrorMessage(ratio, config),
|
|
30
|
+
details: {
|
|
31
|
+
mode: config.mode,
|
|
32
|
+
context: config.context,
|
|
33
|
+
targetLang: config.targetLang,
|
|
34
|
+
sourceLength,
|
|
35
|
+
translatedLength,
|
|
36
|
+
ratio: parseFloat(ratio.toFixed(3)),
|
|
37
|
+
allowedRange: {
|
|
38
|
+
min: parseFloat(minRatio.toFixed(2)),
|
|
39
|
+
max: parseFloat(maxRatio.toFixed(2)),
|
|
40
|
+
},
|
|
41
|
+
deviation: parseFloat((ratio - 1).toFixed(3)),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error("Length check failed:", error);
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
type: "length",
|
|
53
|
+
severity: "error",
|
|
54
|
+
message: "Length validation failed due to technical error",
|
|
55
|
+
details: { error: error.message },
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Calculate length of text ignoring whitespace.
|
|
62
|
+
*/
|
|
63
|
+
calculateLength(text) {
|
|
64
|
+
if (!text)
|
|
65
|
+
return 0;
|
|
66
|
+
return text.replace(/\s+/g, "").length;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get configuration for length check.
|
|
70
|
+
*/
|
|
71
|
+
getConfig(options) {
|
|
72
|
+
const lengthControl = options.lengthControl || {};
|
|
73
|
+
const mode = lengthControl.mode || this.defaultConfig.mode;
|
|
74
|
+
const targetLang = options.targetLang || "en";
|
|
75
|
+
const context = options.detectedContext?.category || "general";
|
|
76
|
+
if (mode === "smart") {
|
|
77
|
+
return this.getSmartModeConfig(lengthControl, targetLang, context);
|
|
78
|
+
}
|
|
79
|
+
return this.getStandardModeConfig(lengthControl, mode, targetLang, context);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Get configuration for smart mode.
|
|
83
|
+
*/
|
|
84
|
+
getSmartModeConfig(lengthControl, targetLang, context) {
|
|
85
|
+
const smartRules = lengthControl.rules?.smart || {};
|
|
86
|
+
const langRules = smartRules.byLanguage?.[targetLang] || {};
|
|
87
|
+
const contextRules = smartRules.byContext?.[context] || {};
|
|
88
|
+
const defaultValue = smartRules.default || 0.15;
|
|
89
|
+
return {
|
|
90
|
+
mode: "smart",
|
|
91
|
+
targetLang,
|
|
92
|
+
context,
|
|
93
|
+
maxDeviation: Math.min(langRules.max ?? defaultValue, contextRules.max ?? defaultValue),
|
|
94
|
+
minDeviation: Math.max(langRules.min ?? -defaultValue, contextRules.min ?? -defaultValue),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get configuration for standard mode.
|
|
99
|
+
*/
|
|
100
|
+
getStandardModeConfig(lengthControl, mode, targetLang, context) {
|
|
101
|
+
const deviation = lengthControl.rules?.[mode] || this.defaultConfig.maxDeviation;
|
|
102
|
+
return {
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
mode: mode,
|
|
105
|
+
targetLang,
|
|
106
|
+
context,
|
|
107
|
+
maxDeviation: deviation,
|
|
108
|
+
minDeviation: -deviation,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Calculate allowed length ratio range.
|
|
113
|
+
*/
|
|
114
|
+
calculateAllowedRange(config) {
|
|
115
|
+
return {
|
|
116
|
+
minRatio: 1 + config.minDeviation,
|
|
117
|
+
maxRatio: 1 + config.maxDeviation,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Determine severity of length mismatch.
|
|
122
|
+
*/
|
|
123
|
+
determineSeverity(ratio, minRatio, maxRatio) {
|
|
124
|
+
const deviation = Math.abs(ratio - 1);
|
|
125
|
+
if (deviation > Math.max(Math.abs(minRatio - 1), Math.abs(maxRatio - 1)) * 1.5) {
|
|
126
|
+
return "critical";
|
|
127
|
+
}
|
|
128
|
+
return "warning";
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Generate error message for length mismatch.
|
|
132
|
+
*/
|
|
133
|
+
generateErrorMessage(ratio, config) {
|
|
134
|
+
const percentage = Math.abs((ratio - 1) * 100).toFixed(1);
|
|
135
|
+
const direction = ratio > 1 ? "longer" : "shorter";
|
|
136
|
+
return (`Translation is ${percentage}% ${direction} than source text ` +
|
|
137
|
+
`[${(config.targetLang || "").toUpperCase()}, ${config.context}]`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export default LengthChecker;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown Preservation Checker.
|
|
3
|
+
* Ensures markdown formatting is preserved during translation.
|
|
4
|
+
*/
|
|
5
|
+
class MarkdownChecker {
|
|
6
|
+
markdownPatterns;
|
|
7
|
+
constructor() {
|
|
8
|
+
this.markdownPatterns = {
|
|
9
|
+
// Headers
|
|
10
|
+
headers: /^#{1,6}\s+/gm,
|
|
11
|
+
// Bold
|
|
12
|
+
bold: /\*\*(.+?)\*\*/g,
|
|
13
|
+
// Italic
|
|
14
|
+
italic: /\*(.+?)\*|_(.+?)_/g,
|
|
15
|
+
// Links
|
|
16
|
+
links: /\[([^\]]+)\]\(([^)]+)\)/g,
|
|
17
|
+
// Images
|
|
18
|
+
images: /!\[([^\]]*)\]\(([^)]+)\)/g,
|
|
19
|
+
// Code inline
|
|
20
|
+
inlineCode: /`([^`]+)`/g,
|
|
21
|
+
// Lists
|
|
22
|
+
unorderedLists: /^[*+-]\s+/gm,
|
|
23
|
+
orderedLists: /^\d+\.\s+/gm,
|
|
24
|
+
// Blockquotes
|
|
25
|
+
blockquotes: /^>\s+/gm,
|
|
26
|
+
// Horizontal rules
|
|
27
|
+
horizontalRules: /^(---|\*\*\*|___)$/gm,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Check if markdown is preserved between source and translation.
|
|
32
|
+
*/
|
|
33
|
+
checkMarkdownPreservation(sourceText, translatedText) {
|
|
34
|
+
const issues = [];
|
|
35
|
+
// Check each markdown pattern
|
|
36
|
+
for (const [patternName, pattern] of Object.entries(this.markdownPatterns)) {
|
|
37
|
+
const sourceMatches = (sourceText.match(pattern) || []).length;
|
|
38
|
+
const translatedMatches = (translatedText.match(pattern) || []).length;
|
|
39
|
+
if (sourceMatches !== translatedMatches) {
|
|
40
|
+
issues.push({
|
|
41
|
+
type: "markdown-preservation",
|
|
42
|
+
severity: "error",
|
|
43
|
+
pattern: patternName,
|
|
44
|
+
message: `Markdown ${patternName} count mismatch: source has ${sourceMatches}, translation has ${translatedMatches}`,
|
|
45
|
+
details: {
|
|
46
|
+
sourceCount: sourceMatches,
|
|
47
|
+
translatedCount: translatedMatches,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Check for broken markdown links
|
|
53
|
+
const brokenLinks = this.checkBrokenMarkdownLinks(translatedText);
|
|
54
|
+
issues.push(...brokenLinks);
|
|
55
|
+
return issues;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check for broken markdown links.
|
|
59
|
+
*/
|
|
60
|
+
checkBrokenMarkdownLinks(text) {
|
|
61
|
+
const issues = [];
|
|
62
|
+
const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
63
|
+
const matches = text.matchAll(linkPattern);
|
|
64
|
+
for (const match of matches) {
|
|
65
|
+
const [fullMatch, linkText, url] = match;
|
|
66
|
+
// Check for common issues
|
|
67
|
+
if (!linkText.trim()) {
|
|
68
|
+
issues.push({
|
|
69
|
+
type: "markdown-link",
|
|
70
|
+
severity: "warning",
|
|
71
|
+
message: `Empty link text in: "${fullMatch}"`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (!url.trim()) {
|
|
75
|
+
issues.push({
|
|
76
|
+
type: "markdown-link",
|
|
77
|
+
severity: "error",
|
|
78
|
+
message: `Empty URL in: "${fullMatch}"`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Check for spaces in URL (common translation error)
|
|
82
|
+
if (url.includes(" ")) {
|
|
83
|
+
issues.push({
|
|
84
|
+
type: "markdown-link",
|
|
85
|
+
severity: "warning",
|
|
86
|
+
message: `URL contains spaces: "${url}"`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return issues;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fix markdown preservation issues.
|
|
94
|
+
*/
|
|
95
|
+
fixMarkdownPreservation(sourceText, translatedText) {
|
|
96
|
+
let fixedText = translatedText;
|
|
97
|
+
const foundIssues = [];
|
|
98
|
+
const appliedFixes = [];
|
|
99
|
+
// Extract markdown elements from source
|
|
100
|
+
const sourceMarkdown = this.extractMarkdownElements(sourceText);
|
|
101
|
+
// Try to restore missing markdown
|
|
102
|
+
for (const [type, elements] of Object.entries(sourceMarkdown)) {
|
|
103
|
+
if (elements.length > 0) {
|
|
104
|
+
const result = this.restoreMarkdownElements(fixedText, elements, type);
|
|
105
|
+
fixedText = result.text;
|
|
106
|
+
foundIssues.push(...result.foundIssues);
|
|
107
|
+
// fixes are not really applied in restoreMarkdownElements for now
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Fix broken links
|
|
111
|
+
const linkResult = this.fixBrokenLinks(fixedText);
|
|
112
|
+
fixedText = linkResult.text;
|
|
113
|
+
foundIssues.push(...linkResult.foundIssues);
|
|
114
|
+
appliedFixes.push(...linkResult.appliedFixes);
|
|
115
|
+
return { text: fixedText, foundIssues, appliedFixes };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Extract markdown elements from text.
|
|
119
|
+
*/
|
|
120
|
+
extractMarkdownElements(text) {
|
|
121
|
+
const elements = {
|
|
122
|
+
headers: [],
|
|
123
|
+
bold: [],
|
|
124
|
+
italic: [],
|
|
125
|
+
links: [],
|
|
126
|
+
images: [],
|
|
127
|
+
inlineCode: [],
|
|
128
|
+
lists: [],
|
|
129
|
+
};
|
|
130
|
+
// Extract headers
|
|
131
|
+
const headerMatches = text.matchAll(this.markdownPatterns.headers);
|
|
132
|
+
for (const match of headerMatches) {
|
|
133
|
+
elements.headers.push(match[0]);
|
|
134
|
+
}
|
|
135
|
+
// Extract bold text
|
|
136
|
+
const boldMatches = text.matchAll(this.markdownPatterns.bold);
|
|
137
|
+
for (const match of boldMatches) {
|
|
138
|
+
elements.bold.push(match[0]);
|
|
139
|
+
}
|
|
140
|
+
// Extract links
|
|
141
|
+
const linkMatches = text.matchAll(this.markdownPatterns.links);
|
|
142
|
+
for (const match of linkMatches) {
|
|
143
|
+
elements.links.push({
|
|
144
|
+
full: match[0],
|
|
145
|
+
text: match[1],
|
|
146
|
+
url: match[2],
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// Extract inline code
|
|
150
|
+
const codeMatches = text.matchAll(this.markdownPatterns.inlineCode);
|
|
151
|
+
for (const match of codeMatches) {
|
|
152
|
+
elements.inlineCode.push(match[0]);
|
|
153
|
+
}
|
|
154
|
+
return elements;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Restore markdown elements that were lost in translation.
|
|
158
|
+
*/
|
|
159
|
+
restoreMarkdownElements(text, elements, type) {
|
|
160
|
+
const foundIssues = [];
|
|
161
|
+
const appliedFixes = [];
|
|
162
|
+
const fixedText = text;
|
|
163
|
+
// For now, we'll just report issues
|
|
164
|
+
// Full restoration would require more context about where to place elements
|
|
165
|
+
if (elements.length > 0) {
|
|
166
|
+
const currentCount = (fixedText.match(this.markdownPatterns[type]) || []).length;
|
|
167
|
+
if (currentCount < elements.length) {
|
|
168
|
+
foundIssues.push({
|
|
169
|
+
type: "markdown-missing",
|
|
170
|
+
severity: "warning", // Added severity
|
|
171
|
+
message: `Missing ${elements.length - currentCount} ${type} elements`,
|
|
172
|
+
});
|
|
173
|
+
// Note: Full restoration is complex and context-dependent
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { text: fixedText, foundIssues, appliedFixes };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Fix broken markdown links.
|
|
180
|
+
*/
|
|
181
|
+
fixBrokenLinks(text) {
|
|
182
|
+
const foundIssues = [];
|
|
183
|
+
const appliedFixes = [];
|
|
184
|
+
let fixedText = text;
|
|
185
|
+
// Fix spaces in URLs
|
|
186
|
+
const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
187
|
+
fixedText = fixedText.replace(linkPattern, (match, linkText, url) => {
|
|
188
|
+
let fixedUrl = url;
|
|
189
|
+
let modified = false;
|
|
190
|
+
// Remove spaces from URL
|
|
191
|
+
if (url.includes(" ")) {
|
|
192
|
+
fixedUrl = url.replace(/\s+/g, "-");
|
|
193
|
+
modified = true;
|
|
194
|
+
foundIssues.push({
|
|
195
|
+
type: "markdown-link",
|
|
196
|
+
severity: "warning", // Added severity
|
|
197
|
+
message: `URL had spaces: "${url}"`,
|
|
198
|
+
});
|
|
199
|
+
appliedFixes.push({
|
|
200
|
+
type: "markdown-link",
|
|
201
|
+
message: `Fixed URL spaces: "${url}" → "${fixedUrl}"`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return modified ? `[${linkText}](${fixedUrl})` : match;
|
|
205
|
+
});
|
|
206
|
+
return { text: fixedText, foundIssues, appliedFixes };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Check if text contains markdown.
|
|
210
|
+
*/
|
|
211
|
+
hasMarkdown(text) {
|
|
212
|
+
for (const pattern of Object.values(this.markdownPatterns)) {
|
|
213
|
+
if (pattern.test(text)) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export default MarkdownChecker;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder Consistency Checker.
|
|
3
|
+
* Ensures placeholders like {name}, ${value}, %s are preserved correctly.
|
|
4
|
+
*/
|
|
5
|
+
class PlaceholderChecker {
|
|
6
|
+
/**
|
|
7
|
+
* Check for placeholder mismatches or corruptions.
|
|
8
|
+
*/
|
|
9
|
+
checkPlaceholders(source, translated) {
|
|
10
|
+
const placeholderRegex = /\{[^}]+\}|\$\{[^}]+\}|%[sd]/g;
|
|
11
|
+
const sourcePlaceholders = source.match(placeholderRegex) || [];
|
|
12
|
+
const translatedPlaceholders = translated.match(placeholderRegex) || [];
|
|
13
|
+
const issues = [];
|
|
14
|
+
// Check for missing or extra placeholders
|
|
15
|
+
if (sourcePlaceholders.length !== translatedPlaceholders.length) {
|
|
16
|
+
issues.push({
|
|
17
|
+
type: "placeholder",
|
|
18
|
+
message: "Placeholder count mismatch",
|
|
19
|
+
source: sourcePlaceholders,
|
|
20
|
+
translated: translatedPlaceholders,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// Check for corrupted placeholders (placeholders that were translated/modified)
|
|
24
|
+
for (const sourcePlaceholder of sourcePlaceholders) {
|
|
25
|
+
if (!translated.includes(sourcePlaceholder)) {
|
|
26
|
+
// Look for potential corrupted versions
|
|
27
|
+
const corruptedPattern = this.findCorruptedPlaceholder(translated, sourcePlaceholder);
|
|
28
|
+
if (corruptedPattern) {
|
|
29
|
+
issues.push({
|
|
30
|
+
type: "placeholder",
|
|
31
|
+
message: `Corrupted placeholder detected: expected '${sourcePlaceholder}' but found '${corruptedPattern}'`,
|
|
32
|
+
source: sourcePlaceholder,
|
|
33
|
+
corrupted: corruptedPattern,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
issues.push({
|
|
38
|
+
type: "placeholder",
|
|
39
|
+
message: `Missing placeholder: ${sourcePlaceholder}`,
|
|
40
|
+
source: sourcePlaceholder,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return issues;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Fix corrupted or missing placeholders in translated text.
|
|
49
|
+
*/
|
|
50
|
+
fixPlaceholders(source, translated) {
|
|
51
|
+
const placeholderRegex = /\{[^}]+\}|\$\{[^}]+\}|%[sd]/g;
|
|
52
|
+
const sourcePlaceholders = source.match(placeholderRegex) || [];
|
|
53
|
+
let fixedText = translated;
|
|
54
|
+
const foundIssues = [];
|
|
55
|
+
const appliedFixes = [];
|
|
56
|
+
// First, fix corrupted placeholders
|
|
57
|
+
for (const sourcePlaceholder of sourcePlaceholders) {
|
|
58
|
+
if (!fixedText.includes(sourcePlaceholder)) {
|
|
59
|
+
// Try to find and fix corrupted versions
|
|
60
|
+
const corruptedPattern = this.findCorruptedPlaceholder(fixedText, sourcePlaceholder);
|
|
61
|
+
if (corruptedPattern) {
|
|
62
|
+
foundIssues.push({
|
|
63
|
+
type: "placeholder",
|
|
64
|
+
message: `Corrupted placeholder: ${corruptedPattern} → ${sourcePlaceholder}`,
|
|
65
|
+
});
|
|
66
|
+
// Replace the corrupted version with the correct one
|
|
67
|
+
fixedText = fixedText.replace(corruptedPattern, sourcePlaceholder);
|
|
68
|
+
appliedFixes.push({
|
|
69
|
+
type: "placeholder",
|
|
70
|
+
message: `Fixed corrupted placeholder: ${corruptedPattern} → ${sourcePlaceholder}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Placeholder is completely missing, try to add it
|
|
75
|
+
foundIssues.push({
|
|
76
|
+
type: "placeholder",
|
|
77
|
+
message: `Missing placeholder: ${sourcePlaceholder}`,
|
|
78
|
+
});
|
|
79
|
+
const possiblePosition = this.findBestPlaceholderPosition(fixedText, source, sourcePlaceholder);
|
|
80
|
+
if (possiblePosition !== -1) {
|
|
81
|
+
fixedText = this.insertPlaceholder(fixedText, sourcePlaceholder, possiblePosition);
|
|
82
|
+
appliedFixes.push({
|
|
83
|
+
type: "placeholder",
|
|
84
|
+
message: `Added missing placeholder: ${sourcePlaceholder}`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { text: fixedText, foundIssues, appliedFixes };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Find a corrupted version of a placeholder in translated text.
|
|
94
|
+
*/
|
|
95
|
+
findCorruptedPlaceholder(translated, sourcePlaceholder) {
|
|
96
|
+
// Extract the placeholder name (e.g., "message" from "{message}")
|
|
97
|
+
const placeholderName = sourcePlaceholder.slice(1, -1); // Remove { and }
|
|
98
|
+
// Look for common corruption patterns
|
|
99
|
+
const corruptionPatterns = [
|
|
100
|
+
// Pattern: {mesaj}{message} or {mensaje}{message} (translated + original)
|
|
101
|
+
new RegExp(`\\{[^}]*\\}\\{${placeholderName}\\}`, "g"),
|
|
102
|
+
// Pattern: {mesaj} or {mensaje} (just translated)
|
|
103
|
+
new RegExp(`\\{[^}]*${placeholderName.slice(0, -2)}[^}]*\\}`, "g"),
|
|
104
|
+
// Pattern: Bạn{message} or text{message} (text prefixed to placeholder)
|
|
105
|
+
new RegExp(`[^\\s]{2,}\\{${placeholderName}\\}`, "g"),
|
|
106
|
+
// Pattern: {message}được or {message}text (text suffixed to placeholder)
|
|
107
|
+
new RegExp(`\\{${placeholderName}\\}[^\\s]{2,}`, "g"),
|
|
108
|
+
// Pattern: Any placeholder with similar length but different content
|
|
109
|
+
new RegExp(`\\{[^}]{${Math.max(1, placeholderName.length - 2)},${placeholderName.length + 3}}\\}`, "g"),
|
|
110
|
+
];
|
|
111
|
+
for (const pattern of corruptionPatterns) {
|
|
112
|
+
const matches = translated.match(pattern);
|
|
113
|
+
if (matches && matches.length > 0) {
|
|
114
|
+
// Return the first match that's not the original placeholder
|
|
115
|
+
for (const match of matches) {
|
|
116
|
+
if (match !== sourcePlaceholder) {
|
|
117
|
+
return match;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Find best position to insert a missing placeholder.
|
|
126
|
+
*/
|
|
127
|
+
findBestPlaceholderPosition(translated, source, placeholder) {
|
|
128
|
+
const sourcePosition = source.indexOf(placeholder);
|
|
129
|
+
const sourceWords = source.slice(0, sourcePosition).split(" ").length;
|
|
130
|
+
const translatedWords = translated.split(" ");
|
|
131
|
+
return sourceWords >= translatedWords.length
|
|
132
|
+
? translated.length
|
|
133
|
+
: translatedWords.slice(0, sourceWords).join(" ").length;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Insert placeholder into text at specified position.
|
|
137
|
+
*/
|
|
138
|
+
insertPlaceholder(text, placeholder, position) {
|
|
139
|
+
return text.slice(0, position) + placeholder + text.slice(position);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export default PlaceholderChecker;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Punctuation Consistency Checker.
|
|
3
|
+
* Ensures end punctuation marks are preserved in translation.
|
|
4
|
+
*/
|
|
5
|
+
class PunctuationChecker {
|
|
6
|
+
/**
|
|
7
|
+
* Check for punctuation mismatches.
|
|
8
|
+
*/
|
|
9
|
+
checkPunctuation(source, translated) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
const endPunctuation = /[.!?]$/;
|
|
12
|
+
if (source.match(endPunctuation) && !translated.match(endPunctuation)) {
|
|
13
|
+
issues.push({
|
|
14
|
+
type: "punctuation",
|
|
15
|
+
message: "Missing punctuation mark",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return issues;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fix missing punctuation marks.
|
|
22
|
+
*/
|
|
23
|
+
fixPunctuation(source, translated) {
|
|
24
|
+
const endPunctuation = /[.!?]$/;
|
|
25
|
+
let fixedText = translated;
|
|
26
|
+
const foundIssues = [];
|
|
27
|
+
const appliedFixes = [];
|
|
28
|
+
const sourceEndsWithPunctuation = source.match(endPunctuation);
|
|
29
|
+
const translatedEndsWithPunctuation = translated.match(endPunctuation);
|
|
30
|
+
if (sourceEndsWithPunctuation && !translatedEndsWithPunctuation) {
|
|
31
|
+
foundIssues.push({
|
|
32
|
+
type: "punctuation",
|
|
33
|
+
message: "Missing punctuation mark",
|
|
34
|
+
});
|
|
35
|
+
fixedText = this.addPunctuation(fixedText, sourceEndsWithPunctuation[0]);
|
|
36
|
+
appliedFixes.push({
|
|
37
|
+
type: "punctuation",
|
|
38
|
+
message: `Added punctuation mark: ${sourceEndsWithPunctuation[0]}`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return { text: fixedText, foundIssues, appliedFixes };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Add punctuation to the end of text.
|
|
45
|
+
*/
|
|
46
|
+
addPunctuation(text, punctuation) {
|
|
47
|
+
return text.trim() + punctuation;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export default PunctuationChecker;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote Balance Checker.
|
|
3
|
+
* Ensures quotes (single, double, guillemets, etc.) are balanced in translation.
|
|
4
|
+
*/
|
|
5
|
+
class QuoteBalanceChecker {
|
|
6
|
+
/**
|
|
7
|
+
* Check for unbalanced quotes.
|
|
8
|
+
*/
|
|
9
|
+
checkQuoteBalance(text) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
// Check single quotes
|
|
12
|
+
const singleQuotes = this._countQuotes(text, "'");
|
|
13
|
+
if (singleQuotes.unbalanced) {
|
|
14
|
+
issues.push({
|
|
15
|
+
type: "unbalanced-quotes",
|
|
16
|
+
severity: "error",
|
|
17
|
+
message: `Unbalanced single quotes: ${singleQuotes.count} found`,
|
|
18
|
+
char: "'",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// Check double quotes
|
|
22
|
+
const doubleQuotes = this._countQuotes(text, '"');
|
|
23
|
+
if (doubleQuotes.unbalanced) {
|
|
24
|
+
issues.push({
|
|
25
|
+
type: "unbalanced-quotes",
|
|
26
|
+
severity: "error",
|
|
27
|
+
message: `Unbalanced double quotes: ${doubleQuotes.count} found`,
|
|
28
|
+
char: '"',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// Check guillemets (French quotes)
|
|
32
|
+
const guillemetsOpen = (text.match(/«/g) || []).length;
|
|
33
|
+
const guillemetsClose = (text.match(/»/g) || []).length;
|
|
34
|
+
if (guillemetsOpen !== guillemetsClose) {
|
|
35
|
+
issues.push({
|
|
36
|
+
type: "unbalanced-quotes",
|
|
37
|
+
severity: "error",
|
|
38
|
+
message: `Unbalanced guillemets: ${guillemetsOpen} « vs ${guillemetsClose} »`,
|
|
39
|
+
char: "«»",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// Check German quotes
|
|
43
|
+
const germanQuotesOpen = (text.match(/„/g) || []).length;
|
|
44
|
+
const germanQuotesClose = (text.match(/"/g) || []).length;
|
|
45
|
+
if (germanQuotesOpen !== germanQuotesClose) {
|
|
46
|
+
issues.push({
|
|
47
|
+
type: "unbalanced-quotes",
|
|
48
|
+
severity: "warning",
|
|
49
|
+
message: `Unbalanced German quotes: ${germanQuotesOpen} „ vs ${germanQuotesClose} "`,
|
|
50
|
+
char: '„"', // Escaped quote
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Count occurrences of a quote character.
|
|
57
|
+
*/
|
|
58
|
+
_countQuotes(text, quoteChar) {
|
|
59
|
+
const count = (text.match(new RegExp(`\\${quoteChar}`, "g")) || []).length;
|
|
60
|
+
return {
|
|
61
|
+
count,
|
|
62
|
+
unbalanced: count % 2 !== 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fix unbalanced quotes.
|
|
67
|
+
*/
|
|
68
|
+
fixQuoteBalance(text) {
|
|
69
|
+
let fixedText = text;
|
|
70
|
+
const foundIssues = [];
|
|
71
|
+
const appliedFixes = [];
|
|
72
|
+
// Fix single quotes
|
|
73
|
+
const singleQuoteCheck = this._countQuotes(fixedText, "'");
|
|
74
|
+
if (singleQuoteCheck.unbalanced) {
|
|
75
|
+
foundIssues.push({
|
|
76
|
+
type: "unbalanced-quotes",
|
|
77
|
+
severity: "error", // Added severity
|
|
78
|
+
message: `Found ${singleQuoteCheck.count} single quotes (odd number)`,
|
|
79
|
+
});
|
|
80
|
+
// Try to fix by adding missing closing quote at the end
|
|
81
|
+
if (fixedText.includes("'")) {
|
|
82
|
+
fixedText = fixedText + "'";
|
|
83
|
+
appliedFixes.push({
|
|
84
|
+
type: "quote-balance",
|
|
85
|
+
message: "Added missing closing single quote at the end",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Fix double quotes
|
|
90
|
+
const doubleQuoteCheck = this._countQuotes(fixedText, '"');
|
|
91
|
+
if (doubleQuoteCheck.unbalanced) {
|
|
92
|
+
foundIssues.push({
|
|
93
|
+
type: "unbalanced-quotes",
|
|
94
|
+
severity: "error", // Added severity
|
|
95
|
+
message: `Found ${doubleQuoteCheck.count} double quotes (odd number)`,
|
|
96
|
+
});
|
|
97
|
+
// Try to fix by adding missing closing quote at the end
|
|
98
|
+
if (fixedText.includes('"')) {
|
|
99
|
+
fixedText = fixedText + '"';
|
|
100
|
+
appliedFixes.push({
|
|
101
|
+
type: "quote-balance",
|
|
102
|
+
message: "Added missing closing double quote at the end",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Fix guillemets
|
|
107
|
+
const guillemetsOpen = (fixedText.match(/«/g) || []).length;
|
|
108
|
+
const guillemetsClose = (fixedText.match(/»/g) || []).length;
|
|
109
|
+
if (guillemetsOpen > guillemetsClose) {
|
|
110
|
+
fixedText = fixedText + "»";
|
|
111
|
+
foundIssues.push({
|
|
112
|
+
type: "unbalanced-quotes",
|
|
113
|
+
severity: "error", // Added severity
|
|
114
|
+
message: "Found unbalanced guillemets",
|
|
115
|
+
});
|
|
116
|
+
appliedFixes.push({
|
|
117
|
+
type: "quote-balance",
|
|
118
|
+
message: "Added missing closing guillemet",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { text: fixedText, foundIssues, appliedFixes };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validate that quotes in source and translation match in pattern.
|
|
125
|
+
*/
|
|
126
|
+
validateQuoteConsistency(source, translated) {
|
|
127
|
+
const issues = [];
|
|
128
|
+
// Check if source has quotes but translation doesn't (or vice versa)
|
|
129
|
+
const sourceHasSingleQuotes = source.includes("'");
|
|
130
|
+
const translatedHasSingleQuotes = translated.includes("'");
|
|
131
|
+
const translatedHasDoubleQuotes = translated.includes('"');
|
|
132
|
+
// If source has quotes, translation should maintain similar quoting
|
|
133
|
+
if (sourceHasSingleQuotes && !translatedHasSingleQuotes && !translatedHasDoubleQuotes) {
|
|
134
|
+
issues.push({
|
|
135
|
+
type: "quote-consistency",
|
|
136
|
+
severity: "warning",
|
|
137
|
+
message: "Source has quotes but translation doesn't",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return issues;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export default QuoteBalanceChecker;
|