@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.
- package/LICENSE +21 -21
- package/README.md +37 -21
- package/bin/index.js +87 -45
- package/package.json +76 -66
- package/src/core/Scanner.js +204 -128
- package/src/core/TranslatorManager.js +97 -33
- package/src/core/Utilities.js +59 -55
- package/src/core/services/AITranslator.js +111 -0
- package/translations.skill +40 -0
- package/web/dist/assets/index-5lMTDHig.css +1 -0
- package/web/dist/assets/index-MTmK2fr8.js +9 -0
- package/web/dist/index.html +19 -19
- package/web/package.json +29 -22
- package/web/dist/assets/index-DtMLkoQW.css +0 -1
- package/web/dist/assets/index-p37Bqu5O.js +0 -21
package/src/core/Scanner.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
283
|
-
|
|
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) {
|
package/src/core/Utilities.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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;
|