i18ntk 3.3.0 → 4.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 +84 -16
- package/README.md +160 -15
- package/SECURITY.md +16 -8
- package/main/i18ntk-backup.js +370 -73
- package/main/i18ntk-scanner.js +190 -49
- package/main/i18ntk-sizing.js +241 -79
- package/main/i18ntk-usage.js +221 -46
- package/main/i18ntk-validate.js +114 -5
- package/main/manage/commands/FixerCommand.js +23 -21
- package/main/manage/index.js +13 -7
- package/main/manage/services/FileManagementService.js +12 -6
- package/package.json +46 -2
- package/runtime/i18ntk.d.ts +22 -16
- package/runtime/index.d.ts +9 -7
- package/runtime/index.js +246 -50
- package/ui-locales/en.json +1 -1
- package/utils/translate/protection.js +153 -7
- package/utils/watch-locales.js +194 -36
package/runtime/index.js
CHANGED
|
@@ -11,13 +11,32 @@ const { envManager } = require('../utils/env-manager');
|
|
|
11
11
|
let configManager = null;
|
|
12
12
|
try { configManager = require('../utils/config-manager'); } catch (_) { /* optional */ }
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
function createState(options = {}) {
|
|
15
|
+
return {
|
|
16
|
+
baseDir: resolveBaseDir(options.baseDir),
|
|
17
|
+
language: options.language || 'en',
|
|
18
|
+
fallbackLanguage: options.fallbackLanguage || 'en',
|
|
19
|
+
keySeparator: options.keySeparator || '.',
|
|
20
|
+
cache: new Map(),
|
|
21
|
+
lazy: options.lazy === true,
|
|
22
|
+
keyManifest: new Map(),
|
|
23
|
+
loadedFiles: new Set(),
|
|
24
|
+
eagerLoadedLanguages: new Set(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const singletonState = {
|
|
15
29
|
baseDir: null, // absolute path to locales dir (e.g., ./locales)
|
|
16
30
|
language: 'en',
|
|
17
31
|
fallbackLanguage: 'en',
|
|
18
|
-
keySeparator: '.',
|
|
19
|
-
cache: new Map(), // lang -> merged translations object
|
|
20
|
-
|
|
32
|
+
keySeparator: '.',
|
|
33
|
+
cache: new Map(), // lang -> merged translations object
|
|
34
|
+
lazy: false,
|
|
35
|
+
keyManifest: new Map(), // lang -> Map: keyName -> filePath
|
|
36
|
+
loadedFiles: new Set(), // tracks loaded files in lazy mode
|
|
37
|
+
eagerLoadedLanguages: new Set(),
|
|
38
|
+
};
|
|
39
|
+
let singletonInitialized = false;
|
|
21
40
|
|
|
22
41
|
// --- Utilities ---
|
|
23
42
|
function stripBOMAndComments(s) {
|
|
@@ -98,12 +117,12 @@ function resolveBaseDir(explicitBaseDir) {
|
|
|
98
117
|
}
|
|
99
118
|
}
|
|
100
119
|
|
|
101
|
-
function listJsonFilesRecursively(dir) {
|
|
120
|
+
function listJsonFilesRecursively(dir, baseDir = dir) {
|
|
102
121
|
const results = [];
|
|
103
122
|
const stack = [dir];
|
|
104
123
|
while (stack.length) {
|
|
105
124
|
const d = stack.pop();
|
|
106
|
-
if (!SecurityUtils.safeExistsSync(d)) continue;
|
|
125
|
+
if (!SecurityUtils.safeExistsSync(d, baseDir)) continue;
|
|
107
126
|
try {
|
|
108
127
|
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
109
128
|
const full = path.join(d, entry.name);
|
|
@@ -120,6 +139,87 @@ function listJsonFilesRecursively(dir) {
|
|
|
120
139
|
return results;
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
function loadKeyManifestFromDir(baseDir) {
|
|
143
|
+
if (!baseDir || typeof baseDir !== 'string') return new Map();
|
|
144
|
+
const validatedBase = SecurityUtils.validatePath(baseDir, path.dirname(baseDir));
|
|
145
|
+
const baseStat = SecurityUtils.safeStatSync(validatedBase, path.dirname(validatedBase));
|
|
146
|
+
const baseRoot = baseStat && baseStat.isFile() ? path.dirname(validatedBase) : validatedBase;
|
|
147
|
+
const files = baseStat && baseStat.isFile() ? [validatedBase] : listJsonFilesRecursively(validatedBase, validatedBase);
|
|
148
|
+
const manifest = new Map();
|
|
149
|
+
const MAX_SIZE = 100 * 1024;
|
|
150
|
+
let currentSize = 0;
|
|
151
|
+
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
let validated;
|
|
154
|
+
try { validated = SecurityUtils.validatePath(file, baseRoot); } catch (_) { continue; }
|
|
155
|
+
const rel = path.relative(baseRoot, validated);
|
|
156
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const data = readJsonSafe(validated);
|
|
160
|
+
if (!data || typeof data !== 'object') continue;
|
|
161
|
+
|
|
162
|
+
for (const key of Object.keys(data)) {
|
|
163
|
+
if (manifest.has(key)) continue;
|
|
164
|
+
|
|
165
|
+
const entrySize = JSON.stringify(key).length + JSON.stringify(validated).length + 5;
|
|
166
|
+
if (currentSize + entrySize > MAX_SIZE) break;
|
|
167
|
+
|
|
168
|
+
manifest.set(key, validated);
|
|
169
|
+
currentSize += entrySize;
|
|
170
|
+
}
|
|
171
|
+
} catch (_) { continue; }
|
|
172
|
+
|
|
173
|
+
if (currentSize >= MAX_SIZE) break;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return manifest;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getLanguagePath(baseDir, lang) {
|
|
180
|
+
const langDir = path.join(baseDir, lang);
|
|
181
|
+
const langFile = path.join(baseDir, `${lang}.json`);
|
|
182
|
+
const langDirStat = SecurityUtils.safeStatSync(langDir, path.dirname(langDir));
|
|
183
|
+
if (langDirStat && langDirStat.isDirectory()) return langDir;
|
|
184
|
+
const langFileStat = SecurityUtils.safeStatSync(langFile, path.dirname(langFile));
|
|
185
|
+
if (langFileStat && langFileStat.isFile()) return langFile;
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getManifestForLanguage(runtimeState, lang) {
|
|
190
|
+
if (!runtimeState.keyManifest) runtimeState.keyManifest = new Map();
|
|
191
|
+
if (runtimeState.keyManifest.has(lang)) return runtimeState.keyManifest.get(lang);
|
|
192
|
+
|
|
193
|
+
const languagePath = getLanguagePath(runtimeState.baseDir, lang);
|
|
194
|
+
const manifest = languagePath ? loadKeyManifestFromDir(languagePath) : new Map();
|
|
195
|
+
runtimeState.keyManifest.set(lang, manifest);
|
|
196
|
+
return manifest;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function loadFileLazy(runtimeState, filePath, lang) {
|
|
200
|
+
const baseDir = runtimeState.baseDir;
|
|
201
|
+
if (!baseDir) throw new Error('baseDir not initialized');
|
|
202
|
+
|
|
203
|
+
let validatedPath;
|
|
204
|
+
try { validatedPath = SecurityUtils.validatePath(filePath, baseDir); } catch (e) {
|
|
205
|
+
throw new Error(`Invalid file path for lazy load: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const rel = path.relative(baseDir, validatedPath);
|
|
209
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
210
|
+
throw new Error(`File outside base directory: ${filePath}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const data = readJsonSafe(validatedPath);
|
|
214
|
+
if (data && typeof data === 'object') {
|
|
215
|
+
let cacheEntry = runtimeState.cache.get(lang);
|
|
216
|
+
if (!cacheEntry) cacheEntry = {};
|
|
217
|
+
deepMerge(cacheEntry, data);
|
|
218
|
+
runtimeState.cache.set(lang, cacheEntry);
|
|
219
|
+
}
|
|
220
|
+
return data;
|
|
221
|
+
}
|
|
222
|
+
|
|
123
223
|
function readLanguageFromBase(baseDir, lang) {
|
|
124
224
|
const merged = {};
|
|
125
225
|
const langFile = path.join(baseDir, `${lang}.json`);
|
|
@@ -128,7 +228,7 @@ function readLanguageFromBase(baseDir, lang) {
|
|
|
128
228
|
// Prefer folder if exists, otherwise single file
|
|
129
229
|
const langDirStat = SecurityUtils.safeStatSync(langDir, path.dirname(langDir));
|
|
130
230
|
if (langDirStat && langDirStat.isDirectory()) {
|
|
131
|
-
const files = listJsonFilesRecursively(langDir);
|
|
231
|
+
const files = listJsonFilesRecursively(langDir, langDir);
|
|
132
232
|
for (const file of files) {
|
|
133
233
|
try {
|
|
134
234
|
const data = readJsonSafe(file);
|
|
@@ -150,10 +250,19 @@ function readLanguageFromBase(baseDir, lang) {
|
|
|
150
250
|
return merged;
|
|
151
251
|
}
|
|
152
252
|
|
|
153
|
-
function
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
253
|
+
function getTranslationsForState(runtimeState, lang) {
|
|
254
|
+
if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
|
|
255
|
+
|
|
256
|
+
if (runtimeState.lazy) {
|
|
257
|
+
if (runtimeState.cache.has(lang)) return runtimeState.cache.get(lang);
|
|
258
|
+
runtimeState.cache.set(lang, {});
|
|
259
|
+
getManifestForLanguage(runtimeState, lang);
|
|
260
|
+
return runtimeState.cache.get(lang);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (runtimeState.cache.has(lang)) return runtimeState.cache.get(lang);
|
|
264
|
+
const data = readLanguageFromBase(runtimeState.baseDir, lang);
|
|
265
|
+
runtimeState.cache.set(lang, data);
|
|
157
266
|
return data;
|
|
158
267
|
}
|
|
159
268
|
|
|
@@ -165,15 +274,42 @@ function interpolate(template, params) {
|
|
|
165
274
|
}
|
|
166
275
|
|
|
167
276
|
// Resolve a dotted key path from an object
|
|
168
|
-
function resolveKey(obj, key, sep = '.') {
|
|
169
|
-
if (!obj || typeof obj !== 'object') return undefined;
|
|
170
|
-
if (!key || typeof key !== 'string') return undefined;
|
|
277
|
+
function resolveKey(obj, key, sep = '.', runtimeState = null, lang = null) {
|
|
278
|
+
if (!obj || typeof obj !== 'object') return undefined;
|
|
279
|
+
if (!key || typeof key !== 'string') return undefined;
|
|
171
280
|
const parts = key.split(sep);
|
|
172
281
|
let cur = obj;
|
|
173
282
|
for (const p of parts) {
|
|
174
283
|
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) {
|
|
175
284
|
cur = cur[p];
|
|
176
285
|
} else {
|
|
286
|
+
if (runtimeState && lang && runtimeState.lazy) {
|
|
287
|
+
const manifest = getManifestForLanguage(runtimeState, lang);
|
|
288
|
+
for (let i = parts.length; i > 0; i--) {
|
|
289
|
+
const prefix = parts.slice(0, i).join(sep);
|
|
290
|
+
const filePath = manifest.get(prefix);
|
|
291
|
+
const loadedFileKey = `${lang}\0${filePath}`;
|
|
292
|
+
if (filePath && !runtimeState.loadedFiles.has(loadedFileKey)) {
|
|
293
|
+
runtimeState.loadedFiles.add(loadedFileKey);
|
|
294
|
+
try {
|
|
295
|
+
loadFileLazy(runtimeState, filePath, lang);
|
|
296
|
+
} catch (_) {
|
|
297
|
+
// stale manifest entry — already marked as loaded to prevent retry
|
|
298
|
+
}
|
|
299
|
+
const langData = runtimeState.cache.get(lang);
|
|
300
|
+
return resolveKey(langData, key, sep, runtimeState, lang);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!runtimeState.eagerLoadedLanguages.has(lang)) {
|
|
304
|
+
const fullData = readLanguageFromBase(runtimeState.baseDir, lang);
|
|
305
|
+
const langData = runtimeState.cache.get(lang) || {};
|
|
306
|
+
deepMerge(langData, fullData);
|
|
307
|
+
runtimeState.cache.set(lang, langData);
|
|
308
|
+
runtimeState.eagerLoadedLanguages.add(lang);
|
|
309
|
+
return resolveKey(langData, key, sep, runtimeState, lang);
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
177
313
|
return undefined;
|
|
178
314
|
}
|
|
179
315
|
}
|
|
@@ -182,62 +318,110 @@ function resolveKey(obj, key, sep = '.') {
|
|
|
182
318
|
|
|
183
319
|
// --- Public API ---
|
|
184
320
|
function initRuntime(options = {}) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
321
|
+
const runtimeState = createState(options);
|
|
322
|
+
preload(runtimeState, options.preload);
|
|
323
|
+
|
|
324
|
+
if (!singletonInitialized) {
|
|
325
|
+
singletonState.baseDir = runtimeState.baseDir;
|
|
326
|
+
singletonState.language = runtimeState.language;
|
|
327
|
+
singletonState.fallbackLanguage = runtimeState.fallbackLanguage;
|
|
328
|
+
singletonState.keySeparator = runtimeState.keySeparator;
|
|
329
|
+
singletonState.lazy = runtimeState.lazy;
|
|
330
|
+
singletonState.keyManifest = new Map();
|
|
331
|
+
singletonState.loadedFiles = new Set();
|
|
332
|
+
singletonState.eagerLoadedLanguages = new Set();
|
|
333
|
+
singletonState.cache.clear();
|
|
334
|
+
preload(singletonState, options.preload);
|
|
335
|
+
singletonInitialized = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return createRuntime(runtimeState);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function preload(runtimeState, shouldPreload) {
|
|
342
|
+
if (shouldPreload === true) {
|
|
343
|
+
if (runtimeState.lazy) {
|
|
344
|
+
getManifestForLanguage(runtimeState, runtimeState.language);
|
|
345
|
+
if (!runtimeState.cache.has(runtimeState.language)) {
|
|
346
|
+
runtimeState.cache.set(runtimeState.language, {});
|
|
347
|
+
}
|
|
348
|
+
if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
|
|
349
|
+
getManifestForLanguage(runtimeState, runtimeState.fallbackLanguage);
|
|
350
|
+
if (!runtimeState.cache.has(runtimeState.fallbackLanguage)) {
|
|
351
|
+
runtimeState.cache.set(runtimeState.fallbackLanguage, {});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
getTranslationsForState(runtimeState, runtimeState.language);
|
|
356
|
+
if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
|
|
357
|
+
getTranslationsForState(runtimeState, runtimeState.fallbackLanguage);
|
|
358
|
+
}
|
|
195
359
|
}
|
|
196
360
|
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function createRuntime(runtimeState) {
|
|
364
|
+
const runtimeTranslate = (key, params) => translateWithState(runtimeState, key, params);
|
|
197
365
|
return {
|
|
198
|
-
t:
|
|
199
|
-
translate,
|
|
200
|
-
setLanguage,
|
|
201
|
-
getLanguage,
|
|
202
|
-
getAvailableLanguages,
|
|
203
|
-
refresh,
|
|
366
|
+
t: runtimeTranslate,
|
|
367
|
+
translate: runtimeTranslate,
|
|
368
|
+
setLanguage: (lang) => setLanguageForState(runtimeState, lang),
|
|
369
|
+
getLanguage: () => getLanguageForState(runtimeState),
|
|
370
|
+
getAvailableLanguages: () => getAvailableLanguagesForState(runtimeState),
|
|
371
|
+
refresh: (lang) => refreshForState(runtimeState, lang),
|
|
204
372
|
};
|
|
205
373
|
}
|
|
206
374
|
|
|
207
375
|
function translate(key, params = {}) {
|
|
208
|
-
|
|
209
|
-
|
|
376
|
+
return translateWithState(singletonState, key, params);
|
|
377
|
+
}
|
|
210
378
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
379
|
+
function translateWithState(runtimeState, key, params = {}) {
|
|
380
|
+
const langData = getTranslationsForState(runtimeState, runtimeState.language);
|
|
381
|
+
let value = resolveKey(langData, key, runtimeState.keySeparator, runtimeState, runtimeState.language);
|
|
382
|
+
|
|
383
|
+
if (typeof value === 'undefined' && runtimeState.fallbackLanguage) {
|
|
384
|
+
const fbData = getTranslationsForState(runtimeState, runtimeState.fallbackLanguage);
|
|
385
|
+
value = resolveKey(fbData, key, runtimeState.keySeparator, runtimeState, runtimeState.fallbackLanguage);
|
|
386
|
+
}
|
|
215
387
|
|
|
216
388
|
if (typeof value === 'string') return interpolate(value, params);
|
|
217
389
|
return typeof value === 'undefined' ? key : value;
|
|
218
390
|
}
|
|
219
391
|
|
|
220
392
|
function setLanguage(lang) {
|
|
393
|
+
setLanguageForState(singletonState, lang);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function setLanguageForState(runtimeState, lang) {
|
|
221
397
|
if (!lang || typeof lang !== 'string') return;
|
|
222
|
-
|
|
398
|
+
runtimeState.language = lang;
|
|
223
399
|
}
|
|
224
400
|
|
|
225
401
|
function getLanguage() {
|
|
226
|
-
return
|
|
402
|
+
return getLanguageForState(singletonState);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getLanguageForState(runtimeState) {
|
|
406
|
+
return runtimeState.language;
|
|
227
407
|
}
|
|
228
408
|
|
|
229
409
|
function getAvailableLanguages() {
|
|
410
|
+
return getAvailableLanguagesForState(singletonState);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function getAvailableLanguagesForState(runtimeState) {
|
|
230
414
|
const langs = new Set();
|
|
231
|
-
if (!
|
|
232
|
-
if (!SecurityUtils.safeExistsSync(
|
|
415
|
+
if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
|
|
416
|
+
if (!SecurityUtils.safeExistsSync(runtimeState.baseDir, path.dirname(runtimeState.baseDir))) return ['en'];
|
|
233
417
|
try {
|
|
234
|
-
for (const entry of fs.readdirSync(
|
|
418
|
+
for (const entry of fs.readdirSync(runtimeState.baseDir, { withFileTypes: true })) {
|
|
235
419
|
if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
|
|
236
420
|
langs.add(entry.name.replace(/\.json$/i, ''));
|
|
237
421
|
} else if (entry.isDirectory()) {
|
|
238
422
|
const lang = entry.name;
|
|
239
|
-
const idx = path.join(
|
|
240
|
-
if (SecurityUtils.safeExistsSync(idx)) langs.add(lang);
|
|
423
|
+
const idx = path.join(runtimeState.baseDir, lang, `${lang}.json`);
|
|
424
|
+
if (SecurityUtils.safeExistsSync(idx, path.dirname(idx))) langs.add(lang);
|
|
241
425
|
else langs.add(lang); // be permissive
|
|
242
426
|
}
|
|
243
427
|
}
|
|
@@ -248,13 +432,24 @@ function getAvailableLanguages() {
|
|
|
248
432
|
return Array.from(langs.size ? langs : new Set(['en']));
|
|
249
433
|
}
|
|
250
434
|
|
|
251
|
-
function refresh(lang
|
|
252
|
-
|
|
253
|
-
if (lang !== state.fallbackLanguage && state.cache.has(state.fallbackLanguage)) {
|
|
254
|
-
// do nothing; keep or clear on demand
|
|
255
|
-
}
|
|
435
|
+
function refresh(lang) {
|
|
436
|
+
refreshForState(singletonState, lang);
|
|
256
437
|
}
|
|
257
438
|
|
|
439
|
+
function refreshForState(runtimeState, lang = runtimeState.language) {
|
|
440
|
+
if (runtimeState.cache.has(lang)) runtimeState.cache.delete(lang);
|
|
441
|
+
if (runtimeState.eagerLoadedLanguages) runtimeState.eagerLoadedLanguages.delete(lang);
|
|
442
|
+
if (runtimeState.keyManifest) runtimeState.keyManifest.delete(lang);
|
|
443
|
+
if (runtimeState.loadedFiles) {
|
|
444
|
+
for (const fileKey of Array.from(runtimeState.loadedFiles)) {
|
|
445
|
+
if (fileKey.startsWith(`${lang}\0`)) runtimeState.loadedFiles.delete(fileKey);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (lang !== runtimeState.fallbackLanguage && runtimeState.cache.has(runtimeState.fallbackLanguage)) {
|
|
449
|
+
// do nothing; keep or clear on demand
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
258
453
|
module.exports = {
|
|
259
454
|
initRuntime,
|
|
260
455
|
translate,
|
|
@@ -262,5 +457,6 @@ module.exports = {
|
|
|
262
457
|
setLanguage,
|
|
263
458
|
getLanguage,
|
|
264
459
|
getAvailableLanguages,
|
|
265
|
-
refresh,
|
|
266
|
-
|
|
460
|
+
refresh,
|
|
461
|
+
loadKeyManifest: (baseDir) => loadKeyManifestFromDir(baseDir || singletonState.baseDir),
|
|
462
|
+
};
|
package/ui-locales/en.json
CHANGED
|
@@ -1209,7 +1209,7 @@
|
|
|
1209
1209
|
},
|
|
1210
1210
|
"operations": {
|
|
1211
1211
|
"completed": "✅ Operation completed successfully!",
|
|
1212
|
-
"deleteReportsTitle": "🗑️ Delete Reports &
|
|
1212
|
+
"deleteReportsTitle": "🗑️ Delete Reports, Logs & Cache",
|
|
1213
1213
|
"scanningForFiles": "🔍 Scanning for files to delete...",
|
|
1214
1214
|
"noFilesFoundToDelete": "✅ No files found to delete.",
|
|
1215
1215
|
"availableDirectories": "📁 Available directories:",
|
|
@@ -3,6 +3,8 @@ const SecurityUtils = require('../security');
|
|
|
3
3
|
|
|
4
4
|
const DEFAULT_PROTECTION_FILE = 'i18ntk-auto-translate.json';
|
|
5
5
|
const TOKEN_PREFIX = '__I18NTK_KEEP_';
|
|
6
|
+
const MAX_CONTEXT_RULES = 100;
|
|
7
|
+
const MAX_CONTEXT_INPUT_LENGTH = 200;
|
|
6
8
|
|
|
7
9
|
function defaultProtectionConfig() {
|
|
8
10
|
return {
|
|
@@ -49,12 +51,90 @@ function compilePatterns(patterns) {
|
|
|
49
51
|
return compiled;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function parseContextString(context) {
|
|
55
|
+
if (typeof context !== 'string') return null;
|
|
56
|
+
if (context.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
57
|
+
|
|
58
|
+
const trimmed = context.trim();
|
|
59
|
+
|
|
60
|
+
if (trimmed === 'standalone') {
|
|
61
|
+
return { mode: 'standalone' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const afterMatch = trimmed.match(/^after:(.+)$/i);
|
|
65
|
+
if (afterMatch) {
|
|
66
|
+
const words = afterMatch[1].split('|').map(w => w.trim()).filter(Boolean);
|
|
67
|
+
if (words.length === 0) return null;
|
|
68
|
+
return { mode: 'after', words };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const beforeMatch = trimmed.match(/^before:(.+)$/i);
|
|
72
|
+
if (beforeMatch) {
|
|
73
|
+
const words = beforeMatch[1].split('|').map(w => w.trim()).filter(Boolean);
|
|
74
|
+
if (words.length === 0) return null;
|
|
75
|
+
return { mode: 'before', words };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const prefix = trimmed.substring(0, 'surrounded:'.length);
|
|
79
|
+
if (prefix.toLowerCase() === 'surrounded:') {
|
|
80
|
+
const rest = trimmed.substring('surrounded:'.length);
|
|
81
|
+
const idx = rest.indexOf(',');
|
|
82
|
+
if (idx === -1) return null;
|
|
83
|
+
const left = rest.slice(0, idx).trim();
|
|
84
|
+
const right = rest.slice(idx + 1).trim();
|
|
85
|
+
const leftWords = left.split('|').map(w => w.trim()).filter(Boolean);
|
|
86
|
+
const rightWords = right.split('|').map(w => w.trim()).filter(Boolean);
|
|
87
|
+
if (leftWords.length === 0 || rightWords.length === 0) return null;
|
|
88
|
+
return { mode: 'surrounded', left: leftWords, right: rightWords };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseContextRule(rule) {
|
|
95
|
+
if (typeof rule === 'string') {
|
|
96
|
+
const trimmed = rule.trim();
|
|
97
|
+
if (!trimmed || trimmed.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
98
|
+
return { value: trimmed, type: 'global' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
typeof rule === 'object' &&
|
|
103
|
+
rule !== null &&
|
|
104
|
+
typeof rule.value === 'string' &&
|
|
105
|
+
typeof rule.context === 'string'
|
|
106
|
+
) {
|
|
107
|
+
const value = rule.value.trim();
|
|
108
|
+
const rawContext = rule.context;
|
|
109
|
+
|
|
110
|
+
if (!value || value.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
111
|
+
if (!rawContext || rawContext.length > MAX_CONTEXT_INPUT_LENGTH) return null;
|
|
112
|
+
|
|
113
|
+
const parsed = parseContextString(rawContext);
|
|
114
|
+
if (!parsed) return null;
|
|
115
|
+
|
|
116
|
+
return { value, type: 'context', context: parsed };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
52
122
|
function normalizeProtectionConfig(config = {}, filePath = null) {
|
|
53
123
|
const terms = normalizeList(config.terms);
|
|
54
124
|
const keys = normalizeList(config.keys);
|
|
55
125
|
const values = normalizeList(config.values);
|
|
56
126
|
const patterns = normalizeList(config.patterns);
|
|
57
127
|
|
|
128
|
+
const rawTerms = Array.isArray(config.terms) ? config.terms : [];
|
|
129
|
+
const normalizedTerms = [];
|
|
130
|
+
for (const term of rawTerms) {
|
|
131
|
+
const normalized = parseContextRule(term);
|
|
132
|
+
if (normalized) {
|
|
133
|
+
normalizedTerms.push(normalized);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const cappedTerms = normalizedTerms.slice(0, MAX_CONTEXT_RULES);
|
|
137
|
+
|
|
58
138
|
return {
|
|
59
139
|
enabled: true,
|
|
60
140
|
filePath,
|
|
@@ -62,7 +142,8 @@ function normalizeProtectionConfig(config = {}, filePath = null) {
|
|
|
62
142
|
keys,
|
|
63
143
|
values,
|
|
64
144
|
patterns,
|
|
65
|
-
compiledPatterns: compilePatterns(patterns)
|
|
145
|
+
compiledPatterns: compilePatterns(patterns),
|
|
146
|
+
normalizedTerms: cappedTerms
|
|
66
147
|
};
|
|
67
148
|
}
|
|
68
149
|
|
|
@@ -98,10 +179,20 @@ function saveProtectionFile(filePath, config, options = {}) {
|
|
|
98
179
|
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
|
|
99
180
|
SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
|
|
100
181
|
}
|
|
182
|
+
|
|
183
|
+
const rawTerms = Array.isArray(config?.terms) ? config.terms : [];
|
|
184
|
+
const sanitizedTerms = [];
|
|
185
|
+
for (const term of rawTerms) {
|
|
186
|
+
const normalized = parseContextRule(term);
|
|
187
|
+
if (normalized) {
|
|
188
|
+
sanitizedTerms.push(term);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
101
192
|
const nextConfig = {
|
|
102
193
|
...defaultProtectionConfig(),
|
|
103
194
|
...(config || {}),
|
|
104
|
-
terms:
|
|
195
|
+
terms: sanitizedTerms.slice(0, MAX_CONTEXT_RULES),
|
|
105
196
|
keys: normalizeList(config?.keys),
|
|
106
197
|
values: normalizeList(config?.values),
|
|
107
198
|
patterns: normalizeList(config?.patterns)
|
|
@@ -156,7 +247,8 @@ function shouldPreserveWholeValue(keyPath, value, protection) {
|
|
|
156
247
|
if (!protection || protection.enabled === false) return false;
|
|
157
248
|
if (protection.keys.some(rule => keyMatchesRule(keyPath, rule))) return true;
|
|
158
249
|
const valueText = String(value);
|
|
159
|
-
return protection.values.includes(valueText) ||
|
|
250
|
+
return protection.values.includes(valueText) ||
|
|
251
|
+
(protection.normalizedTerms || []).some(rule => rule.type === 'global' && rule.value === valueText);
|
|
160
252
|
}
|
|
161
253
|
|
|
162
254
|
function addReplacement(replacements, original) {
|
|
@@ -165,13 +257,65 @@ function addReplacement(replacements, original) {
|
|
|
165
257
|
replacements.push({ original });
|
|
166
258
|
}
|
|
167
259
|
|
|
260
|
+
function shouldProtectInContext(value, rule, index, fullText) {
|
|
261
|
+
if (rule.type === 'global') return true;
|
|
262
|
+
|
|
263
|
+
const { context } = rule;
|
|
264
|
+
const before = fullText.substring(0, index);
|
|
265
|
+
const after = fullText.substring(index + value.length);
|
|
266
|
+
|
|
267
|
+
if (context.mode === 'after') {
|
|
268
|
+
const alternation = context.words.map(escapeRegExp).join('|');
|
|
269
|
+
const regex = new RegExp(`(^|[\\s\\p{P}])(${alternation})\\s+$`, 'iu');
|
|
270
|
+
return regex.test(before);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (context.mode === 'before') {
|
|
274
|
+
const alternation = context.words.map(escapeRegExp).join('|');
|
|
275
|
+
const regex = new RegExp(`^\\s+(${alternation})([\\s\\p{P}]|$)`, 'iu');
|
|
276
|
+
return regex.test(after);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (context.mode === 'standalone') {
|
|
280
|
+
const isBoundaryBefore = before === '' || /(?:\s|\(|\[|\{|"|'|\-|–|—|。|、|」)$/.test(before);
|
|
281
|
+
const isBoundaryAfter = after === '' || /^[\s.,!?;:)\]}<>"'\-–—。、」]/.test(after);
|
|
282
|
+
return isBoundaryBefore && isBoundaryAfter;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (context.mode === 'surrounded') {
|
|
286
|
+
const leftAlternation = context.left.map(escapeRegExp).join('|');
|
|
287
|
+
const rightAlternation = context.right.map(escapeRegExp).join('|');
|
|
288
|
+
const leftRegex = new RegExp(`(^|[\\s\\p{P}])(${leftAlternation})\\s+$`, 'iu');
|
|
289
|
+
const rightRegex = new RegExp(`^\\s+(${rightAlternation})([\\s\\p{P}]|$)`, 'iu');
|
|
290
|
+
return leftRegex.test(before) && rightRegex.test(after);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
168
296
|
function collectReplacements(value, protection) {
|
|
169
297
|
const text = String(value);
|
|
170
298
|
const replacements = [];
|
|
171
299
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
300
|
+
const rules = protection.normalizedTerms || (protection.terms || [])
|
|
301
|
+
.map(parseContextRule)
|
|
302
|
+
.filter(Boolean);
|
|
303
|
+
for (const rule of rules) {
|
|
304
|
+
if (rule.type === 'global') {
|
|
305
|
+
if (text.includes(rule.value)) {
|
|
306
|
+
addReplacement(replacements, rule.value);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
if (!rule.value) continue;
|
|
310
|
+
const escaped = escapeRegExp(rule.value);
|
|
311
|
+
const regex = new RegExp(escaped, 'gi');
|
|
312
|
+
let match;
|
|
313
|
+
while ((match = regex.exec(text)) !== null) {
|
|
314
|
+
if (shouldProtectInContext(rule.value, rule, match.index, text)) {
|
|
315
|
+
addReplacement(replacements, match[0]);
|
|
316
|
+
}
|
|
317
|
+
if (match[0] === '') regex.lastIndex++;
|
|
318
|
+
}
|
|
175
319
|
}
|
|
176
320
|
}
|
|
177
321
|
|
|
@@ -221,7 +365,8 @@ function hasProtectionRules(protection) {
|
|
|
221
365
|
return Boolean(
|
|
222
366
|
protection &&
|
|
223
367
|
(
|
|
224
|
-
protection.
|
|
368
|
+
(protection.normalizedTerms && protection.normalizedTerms.length) ||
|
|
369
|
+
(protection.terms && protection.terms.length) ||
|
|
225
370
|
protection.keys.length ||
|
|
226
371
|
protection.values.length ||
|
|
227
372
|
protection.patterns.length
|
|
@@ -235,6 +380,7 @@ module.exports = {
|
|
|
235
380
|
defaultProtectionConfig,
|
|
236
381
|
hasProtectionRules,
|
|
237
382
|
loadProtectionConfig,
|
|
383
|
+
parseContextRule,
|
|
238
384
|
protectText,
|
|
239
385
|
readProtectionFile,
|
|
240
386
|
restoreText,
|