@tuhama/translation-manager 0.6.0 → 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 +117 -27
- package/src/core/Utilities.js +59 -55
- package/src/core/services/AITranslator.js +111 -0
- package/src/server.js +3 -5
- 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-DrDBw1Js.css +0 -1
- package/web/dist/assets/index-POIkadEy.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];
|
|
@@ -259,6 +279,73 @@ class TranslatorManager {
|
|
|
259
279
|
};
|
|
260
280
|
}
|
|
261
281
|
|
|
282
|
+
/**
|
|
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')
|
|
288
|
+
* @returns {Object} - Export data with missing keys and empty values for each language
|
|
289
|
+
*/
|
|
290
|
+
async exportMissingFromFiles(sourceLang = 'en') {
|
|
291
|
+
const { languages, translations, allKeys, missingFromFiles, missingKeysContext } = await this.scan();
|
|
292
|
+
const exportData = {};
|
|
293
|
+
const context = {};
|
|
294
|
+
|
|
295
|
+
// 1. Add keys missing from files (found in code)
|
|
296
|
+
missingFromFiles.forEach(key => {
|
|
297
|
+
exportData[key] = {};
|
|
298
|
+
languages.forEach(lang => {
|
|
299
|
+
exportData[key][lang] = '';
|
|
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
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
exportData,
|
|
338
|
+
context,
|
|
339
|
+
metadata: {
|
|
340
|
+
languages,
|
|
341
|
+
sourceLang,
|
|
342
|
+
totalKeys: Object.keys(exportData).length,
|
|
343
|
+
exportedAt: new Date().toISOString(),
|
|
344
|
+
aiFriendly: true
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
262
349
|
/**
|
|
263
350
|
* Imports translated keys from the export format back into translation files
|
|
264
351
|
* @param {Object} importData - Data in format {"key": {"lang1": "translation", "lang2": "translation"}}
|
|
@@ -280,6 +367,9 @@ class TranslatorManager {
|
|
|
280
367
|
};
|
|
281
368
|
|
|
282
369
|
for (const key in importData) {
|
|
370
|
+
// Skip metadata if present
|
|
371
|
+
if (key === 'metadata') continue;
|
|
372
|
+
|
|
283
373
|
const keyTranslations = importData[key];
|
|
284
374
|
|
|
285
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;
|