@tuhama/translation-manager 0.6.1 → 0.7.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.
@@ -1,128 +1,204 @@
1
- const fs = require('fs-extra');
2
- const path = require('path');
3
-
4
- /**
5
- * Scanner class to find translation key usages in source code.
6
- */
7
- class Scanner {
8
- constructor(targetDir, localesDir, config = {}) {
9
- this.targetDir = targetDir;
10
- this.localesDir = localesDir;
11
- this.config = config;
12
- this.extensions = config.extensions || ['.js', '.jsx', '.ts', '.tsx', '.html', '.vue'];
13
- this.exclude = config.exclude || ['node_modules', '.git', 'dist', 'build', localesDir];
14
- }
15
-
16
- /**
17
- * Recursively gets files from the target directory.
18
- */
19
- async getFiles() {
20
- const files = [];
21
-
22
- const walk = async (dir) => {
23
- const entries = await fs.readdir(dir, { withFileTypes: true });
24
-
25
- for (const entry of entries) {
26
- const fullPath = path.resolve(dir, entry.name);
27
-
28
- if (entry.isDirectory()) {
29
- if (this.exclude.includes(entry.name) || this.exclude.some(ex => fullPath === path.resolve(this.targetDir, ex) || fullPath.startsWith(path.resolve(this.targetDir, ex) + path.sep))) {
30
- continue;
31
- }
32
- await walk(fullPath);
33
- } else if (this.extensions.includes(path.extname(fullPath))) {
34
- files.push(fullPath);
35
- }
36
- }
37
- };
38
-
39
- await walk(this.targetDir);
40
- return files;
41
- }
42
-
43
- /**
44
- * Scans source code for key usages.
45
- */
46
- async findUnusedKeys(allKeys) {
47
- const files = await this.getFiles();
48
- const contents = await Promise.all(files.map(f => fs.readFile(f, 'utf-8')));
49
- const combinedContent = contents.join('\n---\n');
50
-
51
- const used = new Set();
52
- const maybeUsed = new Set();
53
- const unused = [];
54
-
55
- allKeys.forEach(key => {
56
- // 1. Literal usage
57
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
- const literalRegex = new RegExp(`['"\`]${escapedKey}['"\`]`, 'g');
59
-
60
- if (literalRegex.test(combinedContent)) {
61
- used.add(key);
62
- return;
63
- }
64
-
65
- // 2. Dynamic usage
66
- const parts = key.split('.');
67
- let isMaybeUsed = false;
68
-
69
- for (let i = 1; i < parts.length; i++) {
70
- const prefix = parts.slice(0, i).join('.') + '.';
71
- const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
- const dynamicRegex = new RegExp(`(['"\`]${escapedPrefix}['"\`].*?[+])|(['"\`]${escapedPrefix}.*?\$\{)`, 'g');
73
-
74
- if (dynamicRegex.test(combinedContent)) {
75
- isMaybeUsed = true;
76
- break;
77
- }
78
- }
79
-
80
- if (isMaybeUsed) {
81
- maybeUsed.add(key);
82
- } else {
83
- unused.push(key);
84
- }
85
- });
86
-
87
- return {
88
- unused: unused.sort(),
89
- maybeUsed: Array.from(maybeUsed).sort()
90
- };
91
- }
92
-
93
- /**
94
- * Finds keys that are used in source code but missing from translation files.
95
- */
96
- async findMissingKeys(existingKeys) {
97
- const files = await this.getFiles();
98
- const contents = await Promise.all(files.map(f => fs.readFile(f, 'utf-8')));
99
- const combinedContent = contents.join('\n---\n');
100
-
101
- const missingKeys = new Set();
102
- const existingKeysSet = new Set(existingKeys);
103
-
104
- // Regex patterns to find potential keys:
105
- // 1. t('key')
106
- // 2. i18n.t('key')
107
- // 3. i18nKey="key"
108
- // 4. <Trans i18nKey="key">
109
- const patterns = [
110
- /(?:\bt\(|i18n\.t\(|i18nKey=)\s*['"\`]([^'"\`]+)['"\`]/g
111
- ];
112
-
113
- patterns.forEach(regex => {
114
- let match;
115
- while ((match = regex.exec(combinedContent)) !== null) {
116
- const key = match[1];
117
- // basic validation to avoid random strings
118
- if (key && key.includes('.') && !existingKeysSet.has(key)) {
119
- missingKeys.add(key);
120
- }
121
- }
122
- });
123
-
124
- return Array.from(missingKeys).sort();
125
- }
126
- }
127
-
128
- module.exports = Scanner;
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Scanner class to find translation key usages in source code.
6
+ */
7
+ class Scanner {
8
+ constructor(targetDir, localesDir, config = {}) {
9
+ this.targetDir = targetDir;
10
+ this.localesDir = localesDir;
11
+ this.config = config;
12
+ this.extensions = config.extensions || ['.js', '.jsx', '.ts', '.tsx', '.html', '.vue'];
13
+ this.exclude = config.exclude || ['node_modules', '.git', 'dist', 'build', localesDir];
14
+ }
15
+
16
+ /**
17
+ * Recursively gets files from the target directory.
18
+ */
19
+ async getFiles() {
20
+ const files = [];
21
+
22
+ const walk = async (dir) => {
23
+ const entries = await fs.readdir(dir, { withFileTypes: true });
24
+
25
+ for (const entry of entries) {
26
+ const fullPath = path.resolve(dir, entry.name);
27
+
28
+ if (entry.isDirectory()) {
29
+ if (this.exclude.includes(entry.name) || this.exclude.some(ex => fullPath === path.resolve(this.targetDir, ex) || fullPath.startsWith(path.resolve(this.targetDir, ex) + path.sep))) {
30
+ continue;
31
+ }
32
+ await walk(fullPath);
33
+ } else if (this.extensions.includes(path.extname(fullPath))) {
34
+ files.push(fullPath);
35
+ }
36
+ }
37
+ };
38
+
39
+ await walk(this.targetDir);
40
+ return files;
41
+ }
42
+
43
+ /**
44
+ * Scans source code for key usages.
45
+ */
46
+ async findUnusedKeys(allKeys) {
47
+ const files = await this.getFiles();
48
+ const contents = await Promise.all(files.map(f => fs.readFile(f, 'utf-8')));
49
+ const combinedContent = contents.join('\n---\n');
50
+
51
+ const used = new Set();
52
+ const maybeUsed = new Set();
53
+ const unused = [];
54
+
55
+ allKeys.forEach(key => {
56
+ // 1. Literal usage
57
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ const literalRegex = new RegExp(`['"\`]${escapedKey}['"\`]`, 'g');
59
+
60
+ if (literalRegex.test(combinedContent)) {
61
+ used.add(key);
62
+ return;
63
+ }
64
+
65
+ // 2. Dynamic usage
66
+ const parts = key.split('.');
67
+ let isMaybeUsed = false;
68
+
69
+ for (let i = 1; i < parts.length; i++) {
70
+ const prefix = parts.slice(0, i).join('.') + '.';
71
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ const dynamicRegex = new RegExp("(['\"`]" + escapedPrefix + "['\"`].*?[+])|(['\"`]" + escapedPrefix + ".*?\\${)", 'g');
73
+
74
+ if (dynamicRegex.test(combinedContent)) {
75
+ isMaybeUsed = true;
76
+ break;
77
+ }
78
+ }
79
+
80
+ if (isMaybeUsed) {
81
+ maybeUsed.add(key);
82
+ } else {
83
+ unused.push(key);
84
+ }
85
+ });
86
+
87
+ return {
88
+ unused: unused.sort(),
89
+ maybeUsed: Array.from(maybeUsed).sort()
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Finds keys that are used in source code but missing from translation files.
95
+ * Returns an array of keys by default, or an object with context if includeContext is true.
96
+ */
97
+ async findMissingKeys(existingKeys, includeContext = false) {
98
+ const files = await this.getFiles();
99
+ const missingKeysData = {};
100
+ const existingKeysSet = new Set(existingKeys);
101
+
102
+ // Regex patterns to find potential keys:
103
+ // 1. t('key')
104
+ // 2. i18n.t('key')
105
+ // 3. i18nKey="key"
106
+ // 4. <Trans i18nKey="key">
107
+ // 5. useTranslation(['namespace']) -> t('key')
108
+ const patterns = [
109
+ /(?:\bt\(|i18n\.t\(|i18nKey=)\s*['"\`]([^'"\`\s]+)['"\`]/g
110
+ ];
111
+
112
+ for (const file of files) {
113
+ const content = await fs.readFile(file, 'utf-8');
114
+ const lines = content.split('\n');
115
+
116
+ patterns.forEach(regex => {
117
+ let match;
118
+ // Reset regex state for each file
119
+ regex.lastIndex = 0;
120
+ while ((match = regex.exec(content)) !== null) {
121
+ const key = match[1];
122
+ // basic validation to avoid random strings and ensure it's not a translation file path
123
+ if (key && !existingKeysSet.has(key) && !key.includes('/') && !key.includes('\\')) {
124
+ if (includeContext) {
125
+ if (!missingKeysData[key]) {
126
+ missingKeysData[key] = {
127
+ key,
128
+ occurrences: []
129
+ };
130
+ }
131
+
132
+ // Find line number
133
+ const index = match.index;
134
+ const lineNo = content.substring(0, index).split('\n').length;
135
+ const contextRange = 2; // lines before and after
136
+ const startLine = Math.max(0, lineNo - contextRange - 1);
137
+ const endLine = Math.min(lines.length, lineNo + contextRange);
138
+ const contextLines = lines.slice(startLine, endLine);
139
+
140
+ missingKeysData[key].occurrences.push({
141
+ file: path.relative(this.targetDir, file),
142
+ line: lineNo,
143
+ context: contextLines.join('\n').trim()
144
+ });
145
+ } else {
146
+ missingKeysData[key] = true;
147
+ }
148
+ }
149
+ }
150
+ });
151
+ }
152
+
153
+ if (includeContext) {
154
+ return missingKeysData;
155
+ }
156
+ return Object.keys(missingKeysData).sort();
157
+ }
158
+
159
+ /**
160
+ * Finds context for a list of existing keys.
161
+ */
162
+ async findContextForKeys(keys) {
163
+ const files = await this.getFiles();
164
+ const contextData = {};
165
+ const keysSet = new Set(keys);
166
+
167
+ for (const file of files) {
168
+ const content = await fs.readFile(file, 'utf-8');
169
+ const lines = content.split('\n');
170
+
171
+ keys.forEach(key => {
172
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
173
+ const regex = new RegExp(`['"\`]${escapedKey}['"\`]`, 'g');
174
+
175
+ let match;
176
+ while ((match = regex.exec(content)) !== null) {
177
+ if (!contextData[key]) {
178
+ contextData[key] = {
179
+ key,
180
+ occurrences: []
181
+ };
182
+ }
183
+
184
+ const index = match.index;
185
+ const lineNo = content.substring(0, index).split('\n').length;
186
+ const contextRange = 2;
187
+ const startLine = Math.max(0, lineNo - contextRange - 1);
188
+ const endLine = Math.min(lines.length, lineNo + contextRange);
189
+ const contextLines = lines.slice(startLine, endLine);
190
+
191
+ contextData[key].occurrences.push({
192
+ file: path.relative(this.targetDir, file),
193
+ line: lineNo,
194
+ context: contextLines.join('\n').trim()
195
+ });
196
+ }
197
+ });
198
+ }
199
+
200
+ return contextData;
201
+ }
202
+ }
203
+
204
+ module.exports = Scanner;
@@ -5,6 +5,7 @@ const Storage = require('./Storage');
5
5
  const Scanner = require('./Scanner');
6
6
  const Utilities = require('./Utilities');
7
7
  const GoogleTranslator = require('./services/GoogleTranslator');
8
+ const AITranslator = require('./services/AITranslator');
8
9
 
9
10
  /**
10
11
  * Main manager class for translation management.
@@ -45,9 +46,10 @@ class TranslatorManager {
45
46
 
46
47
  // Scan source for unused and missing keys
47
48
  const scanner = new Scanner(this.targetDir, localesDir, this.config);
48
- const [analysis, missingFromFiles] = await Promise.all([
49
+ const [analysis, missingFromFiles, missingKeysContext] = await Promise.all([
49
50
  scanner.findUnusedKeys(allKeys),
50
- scanner.findMissingKeys(allKeys)
51
+ scanner.findMissingKeys(allKeys),
52
+ scanner.findMissingKeys(allKeys, true)
51
53
  ]);
52
54
 
53
55
  return {
@@ -58,7 +60,8 @@ class TranslatorManager {
58
60
  results,
59
61
  unused: analysis.unused,
60
62
  maybeUsed: analysis.maybeUsed,
61
- missingFromFiles: missingFromFiles
63
+ missingFromFiles: missingFromFiles,
64
+ missingKeysContext: missingKeysContext
62
65
  };
63
66
  }
64
67
 
@@ -112,20 +115,41 @@ class TranslatorManager {
112
115
  /**
113
116
  * Translates a specific text into target language.
114
117
  */
115
- async translateSingle(text, targetLang, sourceLang = 'en') {
116
- // Support both old and new config formats for backward compatibility
117
- let translatorConfig;
118
+ async translateSingle(text, targetLang, sourceLang = 'en', key = null) {
119
+ const { translator, type } = await this.getTranslator();
120
+
121
+ if (type === 'ai' && key) {
122
+ const scanner = new Scanner(this.targetDir, await this.storage.getLocalesDir(), this.config);
123
+ const context = await scanner.findContextForKeys([key]);
124
+ return await translator.translate(text, targetLang, sourceLang, context);
125
+ }
126
+
127
+ return await translator.translate(text, targetLang, sourceLang);
128
+ }
129
+
130
+ /**
131
+ * Gets the configured translator instance.
132
+ */
133
+ async getTranslator() {
134
+ if (this.config.aiTranslate && this.config.aiTranslate.apiKey) {
135
+ return {
136
+ translator: new AITranslator(this.config.aiTranslate),
137
+ type: 'ai'
138
+ };
139
+ }
140
+
141
+ if (this.config.googleTranslate && this.config.googleTranslate.projectId) {
142
+ return {
143
+ translator: new GoogleTranslator(this.config.googleTranslate),
144
+ type: 'google'
145
+ };
146
+ }
147
+
118
148
  if (this.config.googleTranslateApiKey) {
119
- // Legacy v2 config - provide migration guidance
120
- throw new Error('Google Translate API v2 is deprecated. Please update your configuration to use v3 with projectId and keyFilename in the googleTranslate object.');
121
- } else if (this.config.googleTranslate) {
122
- translatorConfig = this.config.googleTranslate;
123
- } else {
124
- throw new Error('Google Translate configuration is missing. Please add googleTranslate.projectId and googleTranslate.keyFilename in Settings.');
149
+ throw new Error('Google Translate API v2 is deprecated. Please update your configuration.');
125
150
  }
126
151
 
127
- const translator = new GoogleTranslator(translatorConfig);
128
- return await translator.translate(text, targetLang, sourceLang);
152
+ throw new Error('No translation service configured. Please add Google Translate or AI settings.');
129
153
  }
130
154
 
131
155
  /**
@@ -159,20 +183,16 @@ class TranslatorManager {
159
183
  async bulkTranslate(sourceLang) {
160
184
  const report = await this.getBulkTranslateReport(sourceLang);
161
185
  const translations = await this.storage.readAll();
162
-
163
- // Support both old and new config formats for backward compatibility
164
- let translatorConfig;
165
- if (this.config.googleTranslateApiKey) {
166
- // Legacy v2 config - provide migration guidance
167
- throw new Error('Google Translate API v2 is deprecated. Please update your configuration to use v3 with projectId and keyFilename in the googleTranslate object.');
168
- } else if (this.config.googleTranslate) {
169
- translatorConfig = this.config.googleTranslate;
170
- } else {
171
- throw new Error('Google Translate configuration is missing. Please add googleTranslate.projectId and googleTranslate.keyFilename in Settings.');
186
+ const { translator, type } = await this.getTranslator();
187
+
188
+ // If AI, get context
189
+ let context = {};
190
+ if (type === 'ai') {
191
+ const scanner = new Scanner(this.targetDir, await this.storage.getLocalesDir(), this.config);
192
+ const allMissingKeys = Object.values(report).flatMap(r => r.keys);
193
+ context = await scanner.findContextForKeys([...new Set(allMissingKeys)]);
172
194
  }
173
195
 
174
- const translator = new GoogleTranslator(translatorConfig);
175
-
176
196
  const preview = {};
177
197
 
178
198
  for (const lang in report) {
@@ -185,7 +205,7 @@ class TranslatorManager {
185
205
  const validKeys = validIndices.map(idx => keys[idx]);
186
206
 
187
207
  if (textsToTranslate.length > 0) {
188
- const translatedTexts = await translator.translate(textsToTranslate, lang, sourceLang);
208
+ const translatedTexts = await translator.translate(textsToTranslate, lang, sourceLang, context);
189
209
  preview[lang] = {};
190
210
  validKeys.forEach((key, idx) => {
191
211
  preview[lang][key] = translatedTexts[idx];
@@ -260,27 +280,68 @@ class TranslatorManager {
260
280
  }
261
281
 
262
282
  /**
263
- * Exports keys that are used in code but not in translation files.
283
+ * Exports all missing translation keys.
284
+ * Includes:
285
+ * 1. Keys used in code but missing from all translation files.
286
+ * 2. Keys existing in translation files but missing values in some languages.
287
+ * @param {string} sourceLang - The source language to use as reference (default: 'en')
264
288
  * @returns {Object} - Export data with missing keys and empty values for each language
265
289
  */
266
- async exportMissingFromFiles() {
267
- const { languages, missingFromFiles } = await this.scan();
290
+ async exportMissingFromFiles(sourceLang = 'en') {
291
+ const { languages, translations, allKeys, missingFromFiles, missingKeysContext } = await this.scan();
268
292
  const exportData = {};
293
+ const context = {};
269
294
 
270
- // Build structure: {"key": {"lang1": "", "lang2": ""}}
295
+ // 1. Add keys missing from files (found in code)
271
296
  missingFromFiles.forEach(key => {
272
297
  exportData[key] = {};
273
298
  languages.forEach(lang => {
274
299
  exportData[key][lang] = '';
275
300
  });
301
+
302
+ // Add context if available
303
+ if (missingKeysContext[key]) {
304
+ context[key] = missingKeysContext[key].occurrences.map(occ => ({
305
+ file: occ.file,
306
+ line: occ.line,
307
+ snippet: occ.context
308
+ }));
309
+ }
310
+ });
311
+
312
+ // 2. Add existing keys that are missing translations in some languages
313
+ allKeys.forEach(key => {
314
+ let isMissingInAny = false;
315
+ const keyLangs = {};
316
+
317
+ languages.forEach(lang => {
318
+ const val = lodash.get(translations[lang], key);
319
+ if (val === undefined || val === '') {
320
+ isMissingInAny = true;
321
+ keyLangs[lang] = '';
322
+ } else {
323
+ keyLangs[lang] = val;
324
+ }
325
+ });
326
+
327
+ if (isMissingInAny) {
328
+ // If it's already in exportData (from code), we've already handled it.
329
+ // Otherwise, add it now.
330
+ if (!exportData[key]) {
331
+ exportData[key] = keyLangs;
332
+ }
333
+ }
276
334
  });
277
335
 
278
336
  return {
279
337
  exportData,
338
+ context,
280
339
  metadata: {
281
340
  languages,
282
- totalKeys: missingFromFiles.length,
283
- exportedAt: new Date().toISOString()
341
+ sourceLang,
342
+ totalKeys: Object.keys(exportData).length,
343
+ exportedAt: new Date().toISOString(),
344
+ aiFriendly: true
284
345
  }
285
346
  };
286
347
  }
@@ -306,6 +367,9 @@ class TranslatorManager {
306
367
  };
307
368
 
308
369
  for (const key in importData) {
370
+ // Skip metadata if present
371
+ if (key === 'metadata') continue;
372
+
309
373
  const keyTranslations = importData[key];
310
374
 
311
375
  for (const lang in keyTranslations) {
@@ -1,55 +1,59 @@
1
- const lodash = require('lodash');
2
-
3
- /**
4
- * Utility functions for object manipulation.
5
- */
6
- class Utilities {
7
- /**
8
- * Recursively sorts object keys alphabetically.
9
- */
10
- static sortObject(obj) {
11
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
12
- return obj;
13
- }
14
-
15
- const sorted = {};
16
- Object.keys(obj).sort().forEach(key => {
17
- sorted[key] = Utilities.sortObject(obj[key]);
18
- });
19
- return sorted;
20
- }
21
-
22
- /**
23
- * Flattens a nested object into a set of dot-notated keys.
24
- */
25
- static flattenKeys(obj, prefix = '', keySet = new Set()) {
26
- Object.keys(obj).forEach(key => {
27
- const fullKey = prefix ? `${prefix}.${key}` : key;
28
- if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
29
- Utilities.flattenKeys(obj[key], fullKey, keySet);
30
- } else {
31
- keySet.add(fullKey);
32
- }
33
- });
34
- return keySet;
35
- }
36
-
37
- /**
38
- * Normalizes translation keys across all languages.
39
- */
40
- static syncKeys(translations, allKeys) {
41
- const languages = Object.keys(translations);
42
- languages.forEach(lang => {
43
- const content = translations[lang];
44
- allKeys.forEach(key => {
45
- if (lodash.get(content, key) === undefined) {
46
- lodash.set(content, key, '');
47
- }
48
- });
49
- translations[lang] = Utilities.sortObject(content);
50
- });
51
- return translations;
52
- }
53
- }
54
-
55
- module.exports = Utilities;
1
+ const lodash = require('lodash');
2
+
3
+ /**
4
+ * Utility functions for object manipulation.
5
+ */
6
+ class Utilities {
7
+ /**
8
+ * Recursively sorts object keys alphabetically.
9
+ */
10
+ static sortObject(obj) {
11
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
12
+ return obj;
13
+ }
14
+
15
+ const sorted = {};
16
+ Object.keys(obj).sort().forEach(key => {
17
+ sorted[key] = Utilities.sortObject(obj[key]);
18
+ });
19
+ return sorted;
20
+ }
21
+
22
+ /**
23
+ * Flattens a nested object into a set of dot-notated keys.
24
+ */
25
+ static flattenKeys(obj, prefix = '', keySet = new Set()) {
26
+ if (!obj || typeof obj !== 'object') {
27
+ return keySet;
28
+ }
29
+
30
+ Object.keys(obj).forEach(key => {
31
+ const fullKey = prefix ? `${prefix}.${key}` : key;
32
+ if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
33
+ Utilities.flattenKeys(obj[key], fullKey, keySet);
34
+ } else {
35
+ keySet.add(fullKey);
36
+ }
37
+ });
38
+ return keySet;
39
+ }
40
+
41
+ /**
42
+ * Normalizes translation keys across all languages.
43
+ */
44
+ static syncKeys(translations, allKeys) {
45
+ const languages = Object.keys(translations);
46
+ languages.forEach(lang => {
47
+ const content = translations[lang];
48
+ allKeys.forEach(key => {
49
+ if (lodash.get(content, key) === undefined) {
50
+ lodash.set(content, key, '');
51
+ }
52
+ });
53
+ translations[lang] = Utilities.sortObject(content);
54
+ });
55
+ return translations;
56
+ }
57
+ }
58
+
59
+ module.exports = Utilities;