i18ntk 2.6.0 → 3.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/CHANGELOG.md +62 -16
- package/README.md +55 -19
- package/main/i18ntk-sizing.js +471 -218
- package/main/i18ntk-translate.js +833 -0
- package/main/i18ntk-validate.js +26 -14
- package/main/manage/commands/CommandRouter.js +7 -1
- package/main/manage/commands/TranslateCommand.js +463 -0
- package/main/manage/commands/ValidateCommand.js +25 -13
- package/main/manage/index.js +11 -5
- package/package.json +14 -3
- package/settings/settings-cli.js +75 -29
- package/settings/settings-manager.js +109 -1
- package/ui-locales/de.json +5 -2
- package/ui-locales/en.json +5 -2
- package/ui-locales/es.json +5 -2
- package/ui-locales/fr.json +5 -2
- package/ui-locales/ja.json +5 -2
- package/ui-locales/ru.json +7 -4
- package/ui-locales/zh.json +5 -2
- package/utils/config-manager.js +20 -4
- package/utils/security.js +4 -3
- package/utils/translate/api.js +168 -0
- package/utils/translate/cli.js +95 -0
- package/utils/translate/placeholder.js +153 -0
- package/utils/translate/protection.js +243 -0
- package/utils/translate/report.js +117 -0
- package/utils/translate/traverse.js +148 -0
- package/utils/validation-risk.js +175 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const { URL } = require('url');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
6
|
+
const DEFAULT_RETRY_COUNT = 3;
|
|
7
|
+
const DEFAULT_RETRY_DELAY = 1000;
|
|
8
|
+
const MAX_BACKOFF_DELAY = 30000;
|
|
9
|
+
|
|
10
|
+
function httpGet(urlString, timeout = 15000) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const url = new URL(urlString);
|
|
13
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
14
|
+
const req = client.get(urlString, { timeout }, (res) => {
|
|
15
|
+
let data = '';
|
|
16
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
17
|
+
res.on('end', () => {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(data);
|
|
20
|
+
resolve({ ok: true, data: parsed, status: res.statusCode });
|
|
21
|
+
} catch (e) {
|
|
22
|
+
resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
req.on('error', (e) => {
|
|
27
|
+
resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
|
|
28
|
+
});
|
|
29
|
+
req.on('timeout', () => {
|
|
30
|
+
req.destroy();
|
|
31
|
+
resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractTranslation(result) {
|
|
37
|
+
if (result && Array.isArray(result) && result[0]) {
|
|
38
|
+
return result[0]
|
|
39
|
+
.filter((part) => part && typeof part[0] === 'string')
|
|
40
|
+
.map((part) => part[0])
|
|
41
|
+
.join('')
|
|
42
|
+
.trim() || null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function detectRateLimitError(result) {
|
|
48
|
+
if (!result.ok && result.status === 429) return true;
|
|
49
|
+
if (result.ok && result.status === 429) return true;
|
|
50
|
+
if (!result.ok && result.error === 'TimeoutError') return false;
|
|
51
|
+
if (result.data && (result.data.error || result.data.error_description)) return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function translateText(text, targetLang, options = {}) {
|
|
56
|
+
const {
|
|
57
|
+
sourceLang = 'en',
|
|
58
|
+
retryCount = DEFAULT_RETRY_COUNT,
|
|
59
|
+
retryDelay = DEFAULT_RETRY_DELAY,
|
|
60
|
+
customFn,
|
|
61
|
+
timeout = 15000,
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
if (!text || text.trim().length === 0) return { ok: true, translated: text };
|
|
65
|
+
|
|
66
|
+
if (typeof customFn === 'function') {
|
|
67
|
+
try {
|
|
68
|
+
const translated = await customFn(text, { sourceLang, targetLang });
|
|
69
|
+
return { ok: true, translated: translated || text };
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { ok: false, error: 'CustomFnError', message: e.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const params = new URLSearchParams({
|
|
76
|
+
client: 'gtx',
|
|
77
|
+
sl: sourceLang,
|
|
78
|
+
tl: targetLang,
|
|
79
|
+
dt: 't',
|
|
80
|
+
q: text,
|
|
81
|
+
});
|
|
82
|
+
const url = `https://translate.googleapis.com/translate_a/single?${params.toString()}`;
|
|
83
|
+
|
|
84
|
+
let lastError = null;
|
|
85
|
+
for (let attempt = 0; attempt < retryCount; attempt++) {
|
|
86
|
+
if (attempt > 0) {
|
|
87
|
+
const delay = Math.min(retryDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_DELAY);
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result = await httpGet(url, timeout);
|
|
92
|
+
|
|
93
|
+
if (result.ok) {
|
|
94
|
+
const translated = extractTranslation(result.data);
|
|
95
|
+
if (translated !== null && translated !== text) {
|
|
96
|
+
return { ok: true, translated };
|
|
97
|
+
}
|
|
98
|
+
if (translated === text) {
|
|
99
|
+
return { ok: true, translated: text };
|
|
100
|
+
}
|
|
101
|
+
if (result.status === 429) {
|
|
102
|
+
lastError = { error: 'RateLimited', message: 'Google Translate rate limit hit' };
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (detectRateLimitError(result)) {
|
|
108
|
+
lastError = { error: 'RateLimited', message: 'Rate limit detected' };
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lastError = { error: result.error || 'UnknownError', message: result.message || 'Request failed' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ok: false, translated: null, ...lastError };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function translateBatch(batch, targetLang, options = {}) {
|
|
119
|
+
const {
|
|
120
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
121
|
+
onProgress,
|
|
122
|
+
onError,
|
|
123
|
+
} = options;
|
|
124
|
+
|
|
125
|
+
const results = new Array(batch.length).fill(null);
|
|
126
|
+
let idx = 0;
|
|
127
|
+
let completed = 0;
|
|
128
|
+
|
|
129
|
+
async function worker() {
|
|
130
|
+
while (idx < batch.length) {
|
|
131
|
+
const i = idx++;
|
|
132
|
+
const item = batch[i];
|
|
133
|
+
const value = typeof item === 'string' ? item : item.value;
|
|
134
|
+
const result = await translateText(value, targetLang, options);
|
|
135
|
+
|
|
136
|
+
if (result.ok) {
|
|
137
|
+
results[i] = result.translated;
|
|
138
|
+
} else {
|
|
139
|
+
results[i] = value;
|
|
140
|
+
if (typeof onError === 'function') {
|
|
141
|
+
onError({ index: i, item, error: result.error, message: result.message });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
completed++;
|
|
146
|
+
if (typeof onProgress === 'function') {
|
|
147
|
+
onProgress({ completed, total: batch.length, index: i, ok: result.ok });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const workerCount = Math.min(concurrency, batch.length);
|
|
153
|
+
const workers = Array.from({ length: workerCount }, () => worker());
|
|
154
|
+
await Promise.all(workers);
|
|
155
|
+
|
|
156
|
+
return results;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
httpGet,
|
|
161
|
+
extractTranslation,
|
|
162
|
+
detectRateLimitError,
|
|
163
|
+
translateText,
|
|
164
|
+
translateBatch,
|
|
165
|
+
DEFAULT_CONCURRENCY,
|
|
166
|
+
DEFAULT_RETRY_COUNT,
|
|
167
|
+
DEFAULT_RETRY_DELAY,
|
|
168
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { ask } = require('../cli');
|
|
2
|
+
|
|
3
|
+
const PLACEHOLDER_WARNING = [
|
|
4
|
+
'',
|
|
5
|
+
'============================================================',
|
|
6
|
+
' WARNING: DYNAMIC PLACEHOLDER TOKENS DETECTED',
|
|
7
|
+
'============================================================',
|
|
8
|
+
'',
|
|
9
|
+
' Auto Translate can preserve placeholders while translating',
|
|
10
|
+
' only the text around tokens like:',
|
|
11
|
+
'',
|
|
12
|
+
' {name} {{count}} %d %s :param ${var}',
|
|
13
|
+
'',
|
|
14
|
+
' Sending placeholders to a translation provider can corrupt',
|
|
15
|
+
' runtime substitution in your application.',
|
|
16
|
+
'',
|
|
17
|
+
' You have three choices for strings containing placeholders:',
|
|
18
|
+
'',
|
|
19
|
+
' PRESERVE - Translate text segments and reinsert placeholders',
|
|
20
|
+
' SKIP - Copy verbatim; manually translate later',
|
|
21
|
+
' SEND - Translate anyway with masking',
|
|
22
|
+
'',
|
|
23
|
+
'============================================================',
|
|
24
|
+
].join('\n');
|
|
25
|
+
|
|
26
|
+
async function confirmGlobalChoice() {
|
|
27
|
+
console.log(PLACEHOLDER_WARNING);
|
|
28
|
+
console.log('');
|
|
29
|
+
console.log(' What should we do with ALL strings that contain');
|
|
30
|
+
console.log(' dynamic placeholder tokens?');
|
|
31
|
+
console.log('');
|
|
32
|
+
console.log(' [p] PRESERVE all - Translate text around placeholders (recommended)');
|
|
33
|
+
console.log(' [s] SKIP all - Copy verbatim, translate nothing with placeholders');
|
|
34
|
+
console.log(' [t] SEND all - Translate everything with placeholder masking');
|
|
35
|
+
console.log(' [i] ASK each - Decide individually for each key');
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
while (true) {
|
|
39
|
+
const answer = await ask(' Choice [p/s/t/i]: ');
|
|
40
|
+
const lower = answer.toLowerCase().trim();
|
|
41
|
+
if (lower === '' || lower === 'p' || lower === 'preserve') return { strategy: 'preserve', interactive: false };
|
|
42
|
+
if (lower === 's' || lower === 'skip') return { strategy: 'skip', interactive: false };
|
|
43
|
+
if (lower === 't' || lower === 'send') return { strategy: 'send', interactive: false };
|
|
44
|
+
if (lower === 'i' || lower === 'ask' || lower === 'interactive') return { strategy: 'preserve', interactive: true };
|
|
45
|
+
console.log(' Please enter p, s, t, or i.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function confirmPerKey(keyPath, value, placeholders) {
|
|
50
|
+
const displayVal = value.length > 60 ? value.substring(0, 57) + '...' : value;
|
|
51
|
+
console.log('');
|
|
52
|
+
console.log(` Key: ${keyPath}`);
|
|
53
|
+
console.log(` Value: "${displayVal}"`);
|
|
54
|
+
console.log(` Placeholders: ${placeholders.join(', ')}`);
|
|
55
|
+
console.log('');
|
|
56
|
+
|
|
57
|
+
while (true) {
|
|
58
|
+
const answer = await ask(' [p]reserve / [s]kip / [t]ranslate masked / s[k]ip all / [a]ll preserve? ');
|
|
59
|
+
const lower = answer.toLowerCase().trim();
|
|
60
|
+
if (lower === '' || lower === 'p' || lower === 'preserve') return 'preserve';
|
|
61
|
+
if (lower === 's' || lower === 'skip') return 'skip';
|
|
62
|
+
if (lower === 't' || lower === 'translate') return 'send';
|
|
63
|
+
if (lower === 'k' || lower === 'skipall') return 'skip-all';
|
|
64
|
+
if (lower === 'a' || lower === 'all') return 'preserve-all';
|
|
65
|
+
console.log(' Please enter p, s, t, k, or a.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function previewSkipped(skippedLeaves) {
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log('============================================================');
|
|
72
|
+
console.log(' DRY-RUN: Keys that would be skipped (placeholders found):');
|
|
73
|
+
console.log('============================================================');
|
|
74
|
+
console.log('');
|
|
75
|
+
if (skippedLeaves.length === 0) {
|
|
76
|
+
console.log(' No keys would be skipped.');
|
|
77
|
+
} else {
|
|
78
|
+
for (const leaf of skippedLeaves) {
|
|
79
|
+
const displayVal = leaf.value.length > 60 ? leaf.value.substring(0, 57) + '...' : leaf.value;
|
|
80
|
+
console.log(` ${leaf.keyPath}`);
|
|
81
|
+
console.log(` "${displayVal}"`);
|
|
82
|
+
console.log(` Placeholders: ${leaf.placeholders.join(', ')}`);
|
|
83
|
+
console.log('');
|
|
84
|
+
}
|
|
85
|
+
console.log(` Total keys that would be skipped: ${skippedLeaves.length}`);
|
|
86
|
+
}
|
|
87
|
+
console.log('');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
PLACEHOLDER_WARNING,
|
|
92
|
+
confirmGlobalChoice,
|
|
93
|
+
confirmPerKey,
|
|
94
|
+
previewSkipped,
|
|
95
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const DEFAULT_PLACEHOLDER_PATTERNS = [
|
|
2
|
+
/\{\{[^}]+\}\}/g, // {{variable}} - double curly (Handlebars, Mustache)
|
|
3
|
+
/\{[a-zA-Z_]\w*\}/g, // {name} - single curly (i18next, Python format named)
|
|
4
|
+
/\{\d+\}/g, // {0} - indexed curly
|
|
5
|
+
/%\d?\$?[sd]/g, // %d, %s, %1$s, %2$d (printf-style)
|
|
6
|
+
/:[a-zA-Z_]\w*/g, // :param - colon-style (Rails, Swift)
|
|
7
|
+
/%\{[a-zA-Z_]\w*\}/g, // %{name} - Ruby/Perl named
|
|
8
|
+
/%\([a-zA-Z_]\w*\)[sd]/g, // %(name)s - Python named format (with type)
|
|
9
|
+
/\$\{[a-zA-Z_]\w*\}/g, // ${variable} - JS template literal style
|
|
10
|
+
/<[a-zA-Z_]\w*>/g, // <name> - XML/HTML-style
|
|
11
|
+
/@[a-zA-Z_]\w*/g, // @param - Java/Spring-style
|
|
12
|
+
/&[a-zA-Z_]\w*;?/g, // & HTML entity style (careful, broad match)
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function compilePatterns(customPatterns) {
|
|
16
|
+
const patterns = [...DEFAULT_PLACEHOLDER_PATTERNS];
|
|
17
|
+
if (customPatterns) {
|
|
18
|
+
const customs = Array.isArray(customPatterns) ? customPatterns : [customPatterns];
|
|
19
|
+
for (const pat of customs) {
|
|
20
|
+
if (typeof pat === 'string') {
|
|
21
|
+
try {
|
|
22
|
+
patterns.push(new RegExp(pat, 'g'));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.warn('Invalid custom regex pattern ignored:', pat, e.message);
|
|
25
|
+
}
|
|
26
|
+
} else if (pat instanceof RegExp) {
|
|
27
|
+
if (!pat.global) {
|
|
28
|
+
patterns.push(new RegExp(pat.source, 'g'));
|
|
29
|
+
} else {
|
|
30
|
+
patterns.push(pat);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return patterns;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function detectPlaceholders(value, customPatterns) {
|
|
39
|
+
if (!value || typeof value !== 'string') return [];
|
|
40
|
+
const patterns = compilePatterns(customPatterns);
|
|
41
|
+
const found = new Set();
|
|
42
|
+
for (const pattern of patterns) {
|
|
43
|
+
pattern.lastIndex = 0;
|
|
44
|
+
const matches = value.match(pattern);
|
|
45
|
+
if (matches) {
|
|
46
|
+
for (const m of matches) found.add(m);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Array.from(found);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasPlaceholders(value, customPatterns) {
|
|
53
|
+
if (!value || typeof value !== 'string') return false;
|
|
54
|
+
const patterns = compilePatterns(customPatterns);
|
|
55
|
+
for (const pattern of patterns) {
|
|
56
|
+
pattern.lastIndex = 0;
|
|
57
|
+
if (pattern.test(value)) return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function splitByPlaceholders(value, customPatterns) {
|
|
63
|
+
if (!value || typeof value !== 'string') {
|
|
64
|
+
return [{ type: 'text', value: value || '' }];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const patterns = compilePatterns(customPatterns);
|
|
68
|
+
const matches = [];
|
|
69
|
+
|
|
70
|
+
for (const pattern of patterns) {
|
|
71
|
+
pattern.lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = pattern.exec(value)) !== null) {
|
|
74
|
+
if (!match[0]) {
|
|
75
|
+
pattern.lastIndex++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
matches.push({
|
|
79
|
+
start: match.index,
|
|
80
|
+
end: match.index + match[0].length,
|
|
81
|
+
value: match[0],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (matches.length === 0) {
|
|
87
|
+
return [{ type: 'text', value }];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
matches.sort((a, b) => {
|
|
91
|
+
if (a.start !== b.start) return a.start - b.start;
|
|
92
|
+
return (b.end - b.start) - (a.end - a.start);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const accepted = [];
|
|
96
|
+
for (const match of matches) {
|
|
97
|
+
const overlaps = accepted.some((item) => match.start < item.end && match.end > item.start);
|
|
98
|
+
if (!overlaps) accepted.push(match);
|
|
99
|
+
}
|
|
100
|
+
accepted.sort((a, b) => a.start - b.start);
|
|
101
|
+
|
|
102
|
+
const segments = [];
|
|
103
|
+
let cursor = 0;
|
|
104
|
+
for (const match of accepted) {
|
|
105
|
+
if (match.start > cursor) {
|
|
106
|
+
segments.push({ type: 'text', value: value.slice(cursor, match.start) });
|
|
107
|
+
}
|
|
108
|
+
segments.push({ type: 'placeholder', value: match.value });
|
|
109
|
+
cursor = match.end;
|
|
110
|
+
}
|
|
111
|
+
if (cursor < value.length) {
|
|
112
|
+
segments.push({ type: 'text', value: value.slice(cursor) });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return segments;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function maskPlaceholders(value, customPatterns) {
|
|
119
|
+
if (!value || typeof value !== 'string') return { masked: value, map: new Map() };
|
|
120
|
+
const patterns = compilePatterns(customPatterns);
|
|
121
|
+
const map = new Map();
|
|
122
|
+
let idx = 0;
|
|
123
|
+
let masked = value;
|
|
124
|
+
for (const pattern of patterns) {
|
|
125
|
+
pattern.lastIndex = 0;
|
|
126
|
+
masked = masked.replace(pattern, (match) => {
|
|
127
|
+
const ph = `\uE000${idx}\uE001`;
|
|
128
|
+
map.set(ph, match);
|
|
129
|
+
idx++;
|
|
130
|
+
return ph;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { masked, map };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function unmaskPlaceholders(value, map) {
|
|
137
|
+
if (!map || map.size === 0) return value;
|
|
138
|
+
let result = value;
|
|
139
|
+
map.forEach((original, placeholder) => {
|
|
140
|
+
result = result.split(placeholder).join(original);
|
|
141
|
+
});
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
DEFAULT_PLACEHOLDER_PATTERNS,
|
|
147
|
+
compilePatterns,
|
|
148
|
+
detectPlaceholders,
|
|
149
|
+
hasPlaceholders,
|
|
150
|
+
splitByPlaceholders,
|
|
151
|
+
maskPlaceholders,
|
|
152
|
+
unmaskPlaceholders,
|
|
153
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const SecurityUtils = require('../security');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PROTECTION_FILE = 'i18ntk-auto-translate.json';
|
|
5
|
+
const TOKEN_PREFIX = '__I18NTK_KEEP_';
|
|
6
|
+
|
|
7
|
+
function defaultProtectionConfig() {
|
|
8
|
+
return {
|
|
9
|
+
version: 1,
|
|
10
|
+
description: 'Auto Translate protection rules. Terms are masked before translation and restored after translation. Keys and values are copied from the source without translation.',
|
|
11
|
+
terms: [],
|
|
12
|
+
keys: [],
|
|
13
|
+
values: [],
|
|
14
|
+
patterns: []
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveProtectionFile(filePath, cwd = process.cwd()) {
|
|
19
|
+
const requested = filePath || DEFAULT_PROTECTION_FILE;
|
|
20
|
+
const resolved = path.isAbsolute(requested)
|
|
21
|
+
? path.resolve(requested)
|
|
22
|
+
: path.resolve(cwd, requested);
|
|
23
|
+
const root = path.resolve(cwd);
|
|
24
|
+
|
|
25
|
+
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
|
26
|
+
throw new Error(`Protection file must be inside the project: ${requested}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeList(value) {
|
|
33
|
+
if (!Array.isArray(value)) return [];
|
|
34
|
+
return value
|
|
35
|
+
.filter(item => typeof item === 'string')
|
|
36
|
+
.map(item => item.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function compilePatterns(patterns) {
|
|
41
|
+
const compiled = [];
|
|
42
|
+
for (const pattern of patterns) {
|
|
43
|
+
try {
|
|
44
|
+
compiled.push(new RegExp(pattern, 'g'));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.warn(`Invalid Auto Translate protection pattern ignored: ${pattern} (${error.message})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return compiled;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeProtectionConfig(config = {}, filePath = null) {
|
|
53
|
+
const terms = normalizeList(config.terms);
|
|
54
|
+
const keys = normalizeList(config.keys);
|
|
55
|
+
const values = normalizeList(config.values);
|
|
56
|
+
const patterns = normalizeList(config.patterns);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
enabled: true,
|
|
60
|
+
filePath,
|
|
61
|
+
terms,
|
|
62
|
+
keys,
|
|
63
|
+
values,
|
|
64
|
+
patterns,
|
|
65
|
+
compiledPatterns: compilePatterns(patterns)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createProtectionFile(filePath, options = {}) {
|
|
70
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
71
|
+
const dir = path.dirname(resolved);
|
|
72
|
+
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
|
|
73
|
+
SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!SecurityUtils.safeExistsSync(resolved, dir)) {
|
|
77
|
+
SecurityUtils.safeWriteFileSync(
|
|
78
|
+
resolved,
|
|
79
|
+
JSON.stringify(defaultProtectionConfig(), null, 2) + '\n',
|
|
80
|
+
dir,
|
|
81
|
+
'utf8'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return resolved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readProtectionFile(filePath, options = {}) {
|
|
89
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
90
|
+
const raw = SecurityUtils.safeReadFileSync(resolved, path.dirname(resolved), 'utf8');
|
|
91
|
+
if (!raw) return defaultProtectionConfig();
|
|
92
|
+
return JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveProtectionFile(filePath, config, options = {}) {
|
|
96
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
97
|
+
const dir = path.dirname(resolved);
|
|
98
|
+
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
|
|
99
|
+
SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
const nextConfig = {
|
|
102
|
+
...defaultProtectionConfig(),
|
|
103
|
+
...(config || {}),
|
|
104
|
+
terms: normalizeList(config?.terms),
|
|
105
|
+
keys: normalizeList(config?.keys),
|
|
106
|
+
values: normalizeList(config?.values),
|
|
107
|
+
patterns: normalizeList(config?.patterns)
|
|
108
|
+
};
|
|
109
|
+
SecurityUtils.safeWriteFileSync(resolved, JSON.stringify(nextConfig, null, 2) + '\n', dir, 'utf8');
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadProtectionConfig(filePath, options = {}) {
|
|
114
|
+
if (options.enabled === false) {
|
|
115
|
+
return normalizeProtectionConfig({}, null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
119
|
+
const dir = path.dirname(resolved);
|
|
120
|
+
|
|
121
|
+
if (!SecurityUtils.safeExistsSync(resolved, dir)) {
|
|
122
|
+
if (options.create) {
|
|
123
|
+
createProtectionFile(resolved, options);
|
|
124
|
+
} else {
|
|
125
|
+
return normalizeProtectionConfig({}, resolved);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const raw = SecurityUtils.safeReadFileSync(resolved, dir, 'utf8');
|
|
130
|
+
if (!raw) {
|
|
131
|
+
return normalizeProtectionConfig({}, resolved);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(`Invalid Auto Translate protection JSON at ${resolved}: ${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return normalizeProtectionConfig(parsed, resolved);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function escapeRegExp(value) {
|
|
145
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function keyMatchesRule(keyPath, rule) {
|
|
149
|
+
if (rule === keyPath) return true;
|
|
150
|
+
if (!rule.includes('*')) return false;
|
|
151
|
+
const regex = new RegExp(`^${rule.split('*').map(escapeRegExp).join('.*')}$`);
|
|
152
|
+
return regex.test(keyPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function shouldPreserveWholeValue(keyPath, value, protection) {
|
|
156
|
+
if (!protection || protection.enabled === false) return false;
|
|
157
|
+
if (protection.keys.some(rule => keyMatchesRule(keyPath, rule))) return true;
|
|
158
|
+
const valueText = String(value);
|
|
159
|
+
return protection.values.includes(valueText) || protection.terms.includes(valueText);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function addReplacement(replacements, original) {
|
|
163
|
+
if (!original) return;
|
|
164
|
+
if (replacements.some(item => item.original === original)) return;
|
|
165
|
+
replacements.push({ original });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function collectReplacements(value, protection) {
|
|
169
|
+
const text = String(value);
|
|
170
|
+
const replacements = [];
|
|
171
|
+
|
|
172
|
+
for (const term of protection.terms || []) {
|
|
173
|
+
if (text.includes(term)) {
|
|
174
|
+
addReplacement(replacements, term);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const pattern of protection.compiledPatterns || []) {
|
|
179
|
+
pattern.lastIndex = 0;
|
|
180
|
+
let match;
|
|
181
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
182
|
+
addReplacement(replacements, match[0]);
|
|
183
|
+
if (match[0] === '') pattern.lastIndex++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return replacements.sort((a, b) => b.original.length - a.original.length);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function protectText(value, protection) {
|
|
191
|
+
if (!protection || protection.enabled === false || typeof value !== 'string') {
|
|
192
|
+
return { value, map: new Map(), count: 0 };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const replacements = collectReplacements(value, protection);
|
|
196
|
+
if (replacements.length === 0) {
|
|
197
|
+
return { value, map: new Map(), count: 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let protectedValue = value;
|
|
201
|
+
const map = new Map();
|
|
202
|
+
replacements.forEach((replacement, index) => {
|
|
203
|
+
const token = `${TOKEN_PREFIX}${index}__`;
|
|
204
|
+
map.set(token, replacement.original);
|
|
205
|
+
protectedValue = protectedValue.split(replacement.original).join(token);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { value: protectedValue, map, count: map.size };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function restoreText(value, map) {
|
|
212
|
+
if (!(map instanceof Map) || map.size === 0 || typeof value !== 'string') return value;
|
|
213
|
+
let restored = value;
|
|
214
|
+
for (const [token, original] of map.entries()) {
|
|
215
|
+
restored = restored.split(token).join(original);
|
|
216
|
+
}
|
|
217
|
+
return restored;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hasProtectionRules(protection) {
|
|
221
|
+
return Boolean(
|
|
222
|
+
protection &&
|
|
223
|
+
(
|
|
224
|
+
protection.terms.length ||
|
|
225
|
+
protection.keys.length ||
|
|
226
|
+
protection.values.length ||
|
|
227
|
+
protection.patterns.length
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
DEFAULT_PROTECTION_FILE,
|
|
234
|
+
createProtectionFile,
|
|
235
|
+
defaultProtectionConfig,
|
|
236
|
+
hasProtectionRules,
|
|
237
|
+
loadProtectionConfig,
|
|
238
|
+
protectText,
|
|
239
|
+
readProtectionFile,
|
|
240
|
+
restoreText,
|
|
241
|
+
saveProtectionFile,
|
|
242
|
+
shouldPreserveWholeValue
|
|
243
|
+
};
|