@tuhama/translation-manager 0.1.0 → 0.3.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/README.md +23 -2
- package/package.json +2 -2
- package/src/core/Scanner.js +128 -0
- package/src/core/Storage.js +82 -0
- package/src/core/TranslatorManager.js +206 -0
- package/src/core/Utilities.js +55 -0
- package/src/core/services/GoogleTranslator.js +51 -0
- package/src/manager.js +47 -192
- package/src/server.js +130 -64
- package/web/dist/assets/index-5lMTDHig.css +1 -0
- package/web/dist/assets/index-MTmK2fr8.js +9 -0
- package/web/dist/index.html +8 -3
- package/web/dist/logo.png +0 -0
- package/web/dist/og-image.png +0 -0
- package/web/dist/assets/index-AnMAeTAB.js +0 -9
- package/web/dist/assets/index-D8MglXxY.css +0 -1
package/README.md
CHANGED
|
@@ -4,9 +4,12 @@ A modern, web-based interface for managing i18n translation files in React and o
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
- **Modern UI**: Dark mode, glassmorphism, and smooth animations.
|
|
7
|
+
- **Auto-Translation**: Integrated Google Translate support for single-key and bulk translations.
|
|
8
|
+
- **Missing Keys Detection**: Identifies translation keys used in source code but missing from files.
|
|
9
|
+
- **Cleanup Tool**: Detects and batch-removes unused translation keys.
|
|
10
|
+
- **Normalization**: Synchronizes keys across all languages and sorts them alphabetically with one click.
|
|
7
11
|
- **Nested Keys**: Supports dot-notation for nested JSON structures.
|
|
8
|
-
- **Tree View**: Easy navigation and
|
|
9
|
-
- **Auto-population**: Automatically populates existing values when entering an existing key.
|
|
12
|
+
- **Tree View**: Easy navigation and management of translation keys.
|
|
10
13
|
- **Zero Config**: Auto-detects common locales folders.
|
|
11
14
|
|
|
12
15
|
## Installation
|
|
@@ -19,6 +22,20 @@ Or run directly with npx:
|
|
|
19
22
|
npx @tuhama/translation-manager
|
|
20
23
|
```
|
|
21
24
|
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### ⚠️ Missing Keys Detection
|
|
28
|
+
The application automatically scans your source code for translation keys used (e.g., `t('key.name')`) but missing from your translation files. Click the "**Missing**" button in the sidebar to review and create them instantly.
|
|
29
|
+
|
|
30
|
+
### 🧹 Cleaning Unused Keys
|
|
31
|
+
Over time, some translation keys might become obsolete. Use the "**Clean**" button to identify and batch-delete keys that are no longer referenced in your source code.
|
|
32
|
+
|
|
33
|
+
### 🪄 Auto-Translation
|
|
34
|
+
Specify a Google Translate API Key in the settings to enable auto-translation. Use the "**Source-to-All**" button in the editor to quickly populate all languages from a single source translation.
|
|
35
|
+
|
|
36
|
+
### 🪄 Normalization
|
|
37
|
+
To keep your translation files organized, use the "**Normalize**" button to synchronize keys across all files and sort them alphabetically.
|
|
38
|
+
|
|
22
39
|
## Configuration
|
|
23
40
|
You can optionally create a `translation.config.json` in your project root:
|
|
24
41
|
```json
|
|
@@ -34,5 +51,9 @@ To work on this repo:
|
|
|
34
51
|
2. `cd web && npm install`
|
|
35
52
|
3. `npm run dev` (starts both the API and the Vite UI)
|
|
36
53
|
|
|
54
|
+
## Limitations
|
|
55
|
+
- **Dynamic Keys**: The scanner uses regex to find translation keys. Highly dynamic keys (e.g. `t(someVar + '.key')` or `t(dynamicValue)`) may not be detected by the "Missing Keys" or "Unused Keys" tools.
|
|
56
|
+
- **Namespaces**: Currently optimized for single-namespace or default-namespace projects.
|
|
57
|
+
|
|
37
58
|
## License
|
|
38
59
|
MIT © [Tuhama](mailto:tuhama.gh.qlyshi@gmail.com)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuhama/translation-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A modern, web-based UI for managing i18n translation files in React and other JavaScript projects.",
|
|
5
5
|
"author": "Tuhama <tuhama.gh.qlyshi@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"translation-manager": "bin/index.js"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
|
-
"dist",
|
|
19
18
|
"bin",
|
|
20
19
|
"src",
|
|
21
20
|
"web/dist",
|
|
@@ -49,6 +48,7 @@
|
|
|
49
48
|
},
|
|
50
49
|
"homepage": "https://github.com/Tuhama/translationManager#readme",
|
|
51
50
|
"dependencies": {
|
|
51
|
+
"axios": "^1.14.0",
|
|
52
52
|
"chokidar": "^5.0.0",
|
|
53
53
|
"commander": "^14.0.3",
|
|
54
54
|
"cors": "^2.8.6",
|
|
@@ -0,0 +1,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
|
+
*/
|
|
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;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interface-like class for managing translation file storage.
|
|
6
|
+
* Defaults to JSON files on local filesystem.
|
|
7
|
+
*/
|
|
8
|
+
class Storage {
|
|
9
|
+
constructor(targetDir, config = {}) {
|
|
10
|
+
this.targetDir = targetDir;
|
|
11
|
+
this.config = config;
|
|
12
|
+
this.defaultPaths = [
|
|
13
|
+
'public/locales',
|
|
14
|
+
'src/locales',
|
|
15
|
+
'src/i18n',
|
|
16
|
+
'locales'
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Finds and returns the path to the locales directory.
|
|
22
|
+
*/
|
|
23
|
+
async getLocalesDir() {
|
|
24
|
+
let localesDir = this.config.path;
|
|
25
|
+
|
|
26
|
+
if (!localesDir) {
|
|
27
|
+
for (const p of this.defaultPaths) {
|
|
28
|
+
const fullPath = path.resolve(this.targetDir, p);
|
|
29
|
+
if (await fs.pathExists(fullPath) && (await fs.stat(fullPath)).isDirectory()) {
|
|
30
|
+
return fullPath;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
localesDir = path.resolve(this.targetDir, localesDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!localesDir || !(await fs.pathExists(localesDir))) {
|
|
38
|
+
throw new Error('Could not find translation directory. Please specify it in the config or ensure it exists.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return localesDir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads all translation files from the locales directory.
|
|
46
|
+
*/
|
|
47
|
+
async readAll() {
|
|
48
|
+
const localesDir = await this.getLocalesDir();
|
|
49
|
+
const files = await fs.readdir(localesDir);
|
|
50
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
51
|
+
|
|
52
|
+
const translations = {};
|
|
53
|
+
for (const file of jsonFiles) {
|
|
54
|
+
const lang = path.basename(file, '.json');
|
|
55
|
+
const content = await fs.readJson(path.join(localesDir, file));
|
|
56
|
+
translations[lang] = content;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return translations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Writes a single translation file (or all of them).
|
|
64
|
+
*/
|
|
65
|
+
async write(lang, content) {
|
|
66
|
+
const localesDir = await this.getLocalesDir();
|
|
67
|
+
const filePath = path.join(localesDir, `${lang}.json`);
|
|
68
|
+
await fs.writeJson(filePath, content, { spaces: 2 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Writes all translations.
|
|
73
|
+
*/
|
|
74
|
+
async writeAll(translations) {
|
|
75
|
+
const languages = Object.keys(translations);
|
|
76
|
+
for (const lang of languages) {
|
|
77
|
+
await this.write(lang, translations[lang]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = Storage;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const lodash = require('lodash');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const Storage = require('./Storage');
|
|
5
|
+
const Scanner = require('./Scanner');
|
|
6
|
+
const Utilities = require('./Utilities');
|
|
7
|
+
const GoogleTranslator = require('./services/GoogleTranslator');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main manager class for translation management.
|
|
11
|
+
* Acts as an orchestrator.
|
|
12
|
+
*/
|
|
13
|
+
class TranslatorManager {
|
|
14
|
+
constructor(targetDir, config = {}) {
|
|
15
|
+
this.targetDir = targetDir;
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.storage = new Storage(targetDir, config);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Re-scans translations and sources to build a complete picture.
|
|
22
|
+
*/
|
|
23
|
+
async scan() {
|
|
24
|
+
const localesDir = await this.storage.getLocalesDir();
|
|
25
|
+
const translations = await this.storage.readAll();
|
|
26
|
+
const languages = Object.keys(translations);
|
|
27
|
+
|
|
28
|
+
// Flatten all keys across all languages
|
|
29
|
+
const allKeysSet = new Set();
|
|
30
|
+
languages.forEach(lang => Utilities.flattenKeys(translations[lang], '', allKeysSet));
|
|
31
|
+
const allKeys = Array.from(allKeysSet).sort();
|
|
32
|
+
|
|
33
|
+
// Calculate results
|
|
34
|
+
const results = {};
|
|
35
|
+
allKeys.forEach(key => {
|
|
36
|
+
const missing = [];
|
|
37
|
+
languages.forEach(lang => {
|
|
38
|
+
const val = lodash.get(translations[lang], key);
|
|
39
|
+
if (val === undefined || val === '') {
|
|
40
|
+
missing.push(lang);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
results[key] = { missing };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Scan source for unused and missing keys
|
|
47
|
+
const scanner = new Scanner(this.targetDir, localesDir, this.config);
|
|
48
|
+
const [analysis, missingFromFiles] = await Promise.all([
|
|
49
|
+
scanner.findUnusedKeys(allKeys),
|
|
50
|
+
scanner.findMissingKeys(allKeys)
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
localesDir,
|
|
55
|
+
languages,
|
|
56
|
+
translations,
|
|
57
|
+
allKeys,
|
|
58
|
+
results,
|
|
59
|
+
unused: analysis.unused,
|
|
60
|
+
maybeUsed: analysis.maybeUsed,
|
|
61
|
+
missingFromFiles: missingFromFiles
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Saves a translation key across all language files.
|
|
67
|
+
*/
|
|
68
|
+
async saveTranslation(key, values) {
|
|
69
|
+
const translations = await this.storage.readAll();
|
|
70
|
+
const languages = Object.keys(translations);
|
|
71
|
+
|
|
72
|
+
for (const lang of languages) {
|
|
73
|
+
if (values[lang] !== undefined) {
|
|
74
|
+
lodash.set(translations[lang], key, values[lang]);
|
|
75
|
+
await this.storage.write(lang, translations[lang]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Deletes one or more translation keys across all language files.
|
|
82
|
+
*/
|
|
83
|
+
async deleteTranslations(keys) {
|
|
84
|
+
const keysToDelete = Array.isArray(keys) ? keys : [keys];
|
|
85
|
+
const translations = await this.storage.readAll();
|
|
86
|
+
const languages = Object.keys(translations);
|
|
87
|
+
|
|
88
|
+
for (const lang of languages) {
|
|
89
|
+
keysToDelete.forEach(key => lodash.unset(translations[lang], key));
|
|
90
|
+
await this.storage.write(lang, translations[lang]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalizes and sorts everything.
|
|
96
|
+
*/
|
|
97
|
+
async normalize() {
|
|
98
|
+
let translations = await this.storage.readAll();
|
|
99
|
+
const languages = Object.keys(translations);
|
|
100
|
+
|
|
101
|
+
const allKeysSet = new Set();
|
|
102
|
+
languages.forEach(lang => Utilities.flattenKeys(translations[lang], '', allKeysSet));
|
|
103
|
+
const allKeys = Array.from(allKeysSet);
|
|
104
|
+
|
|
105
|
+
// Sync and sort
|
|
106
|
+
translations = Utilities.syncKeys(translations, allKeys);
|
|
107
|
+
|
|
108
|
+
// Save back
|
|
109
|
+
await this.storage.writeAll(translations);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Translates a specific text into target language.
|
|
114
|
+
*/
|
|
115
|
+
async translateSingle(text, targetLang, sourceLang = 'en') {
|
|
116
|
+
const translator = new GoogleTranslator(this.config.googleTranslateApiKey);
|
|
117
|
+
return await translator.translate(text, targetLang, sourceLang);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Gets a report of missing translations.
|
|
122
|
+
*/
|
|
123
|
+
async getBulkTranslateReport(sourceLang) {
|
|
124
|
+
const { languages, translations, allKeys } = await this.scan();
|
|
125
|
+
const report = {};
|
|
126
|
+
|
|
127
|
+
languages.forEach(lang => {
|
|
128
|
+
if (lang === sourceLang) return;
|
|
129
|
+
const missing = allKeys.filter(key => {
|
|
130
|
+
const val = lodash.get(translations[lang], key);
|
|
131
|
+
return val === undefined || val === '';
|
|
132
|
+
});
|
|
133
|
+
if (missing.length > 0) {
|
|
134
|
+
report[lang] = {
|
|
135
|
+
count: missing.length,
|
|
136
|
+
keys: missing
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Performs bulk translation for all missing keys.
|
|
146
|
+
* Returns an object mapping language to key-value pairs for review.
|
|
147
|
+
*/
|
|
148
|
+
async bulkTranslate(sourceLang) {
|
|
149
|
+
const report = await this.getBulkTranslateReport(sourceLang);
|
|
150
|
+
const translations = await this.storage.readAll();
|
|
151
|
+
const translator = new GoogleTranslator(this.config.googleTranslateApiKey);
|
|
152
|
+
|
|
153
|
+
const preview = {};
|
|
154
|
+
|
|
155
|
+
for (const lang in report) {
|
|
156
|
+
const keys = report[lang].keys;
|
|
157
|
+
const sourceTexts = keys.map(key => lodash.get(translations[sourceLang], key));
|
|
158
|
+
|
|
159
|
+
// Filter out keys that don't have source text
|
|
160
|
+
const validIndices = sourceTexts.map((text, idx) => text ? idx : null).filter(idx => idx !== null);
|
|
161
|
+
const textsToTranslate = validIndices.map(idx => sourceTexts[idx]);
|
|
162
|
+
const validKeys = validIndices.map(idx => keys[idx]);
|
|
163
|
+
|
|
164
|
+
if (textsToTranslate.length > 0) {
|
|
165
|
+
const translatedTexts = await translator.translate(textsToTranslate, lang, sourceLang);
|
|
166
|
+
preview[lang] = {};
|
|
167
|
+
validKeys.forEach((key, idx) => {
|
|
168
|
+
preview[lang][key] = translatedTexts[idx];
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return preview;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Saves multiple translation keys across languages.
|
|
178
|
+
* @param {Object} data - { lang: { key: value } }
|
|
179
|
+
*/
|
|
180
|
+
async saveBulkTranslations(data) {
|
|
181
|
+
const translations = await this.storage.readAll();
|
|
182
|
+
const languages = Object.keys(translations);
|
|
183
|
+
|
|
184
|
+
for (const lang in data) {
|
|
185
|
+
if (languages.includes(lang)) {
|
|
186
|
+
for (const key in data[lang]) {
|
|
187
|
+
lodash.set(translations[lang], key, data[lang][key]);
|
|
188
|
+
}
|
|
189
|
+
await this.storage.write(lang, translations[lang]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Saves the current configuration to the config file.
|
|
196
|
+
*/
|
|
197
|
+
async saveConfig(newConfig) {
|
|
198
|
+
this.config = { ...this.config, ...newConfig };
|
|
199
|
+
// Sync storage config if path changed
|
|
200
|
+
this.storage.config = this.config;
|
|
201
|
+
const configPath = path.resolve(this.targetDir, 'translation.config.json');
|
|
202
|
+
await fs.writeJson(configPath, this.config, { spaces: 2 });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = TranslatorManager;
|
|
@@ -0,0 +1,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
|
+
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;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Service for interacting with Google Cloud Translation API.
|
|
5
|
+
*/
|
|
6
|
+
class GoogleTranslator {
|
|
7
|
+
constructor(apiKey) {
|
|
8
|
+
this.apiKey = apiKey;
|
|
9
|
+
this.baseUrl = 'https://translation.googleapis.com/language/translate/v2';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Translates text or an array of texts.
|
|
14
|
+
* @param {string|string[]} text - Text(s) to translate
|
|
15
|
+
* @param {string} targetLang - Target language code (e.g. 'fr')
|
|
16
|
+
* @param {string} sourceLang - Source language code (e.g. 'en')
|
|
17
|
+
*/
|
|
18
|
+
async translate(text, targetLang, sourceLang = 'en') {
|
|
19
|
+
if (!this.apiKey) {
|
|
20
|
+
throw new Error('Google Translate API Key is missing. Please add it in Settings.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!text || (Array.isArray(text) && text.length === 0)) {
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const response = await axios.post(
|
|
29
|
+
`${this.baseUrl}?key=${this.apiKey}`,
|
|
30
|
+
{
|
|
31
|
+
q: text,
|
|
32
|
+
target: targetLang,
|
|
33
|
+
source: sourceLang,
|
|
34
|
+
format: 'text'
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const translations = response.data.data.translations;
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(text)) {
|
|
41
|
+
return translations.map(t => t.translatedText);
|
|
42
|
+
}
|
|
43
|
+
return translations[0].translatedText;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const message = error.response?.data?.error?.message || error.message;
|
|
46
|
+
throw new Error(`Google Translate Error: ${message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = GoogleTranslator;
|