@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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service for interacting with AI-based Translation APIs (OpenAI, Gemini, etc.)
|
|
3
|
+
* This service is "context-aware" and uses extracted code snippets to improve translation quality.
|
|
4
|
+
*/
|
|
5
|
+
class AITranslator {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.provider = config.provider || 'openai'; // 'openai' or 'gemini'
|
|
8
|
+
this.apiKey = config.apiKey;
|
|
9
|
+
this.model = config.model || (this.provider === 'openai' ? 'gpt-4o' : 'gemini-1.5-flash');
|
|
10
|
+
|
|
11
|
+
if (!this.apiKey) {
|
|
12
|
+
throw new Error(`API Key is required for ${this.provider} translation.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Translates text with optional context.
|
|
18
|
+
* @param {string|string[]} text - Text(s) to translate
|
|
19
|
+
* @param {string} targetLang - Target language code
|
|
20
|
+
* @param {string} sourceLang - Source language code
|
|
21
|
+
* @param {Object} context - Optional context mapping keys to snippets
|
|
22
|
+
*/
|
|
23
|
+
async translate(text, targetLang, sourceLang = 'en', context = {}) {
|
|
24
|
+
if (!text || (Array.isArray(text) && text.length === 0)) {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isArray = Array.isArray(text);
|
|
29
|
+
const textsToTranslate = isArray ? text : [text];
|
|
30
|
+
|
|
31
|
+
// Prepare the prompt
|
|
32
|
+
let prompt = `Translate the following ${sourceLang} strings to ${targetLang}.
|
|
33
|
+
Return only a JSON object where keys are the original strings and values are the translations.
|
|
34
|
+
Maintain any placeholders like {name} or {{count}}.
|
|
35
|
+
|
|
36
|
+
Strings to translate:
|
|
37
|
+
${textsToTranslate.map(t => `- "${t}"`).join('\n')}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
// Add context if available
|
|
41
|
+
const contextEntries = Object.entries(context);
|
|
42
|
+
if (contextEntries.length > 0) {
|
|
43
|
+
prompt += `\nContext for some strings:\n`;
|
|
44
|
+
contextEntries.forEach(([key, occs]) => {
|
|
45
|
+
const snippets = occs.map(o => o.snippet).join('\n---\n');
|
|
46
|
+
prompt += `Key "${key}": used in these code locations:\n${snippets}\n`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const result = await this._callAI(prompt);
|
|
52
|
+
const translationsMap = JSON.parse(result);
|
|
53
|
+
|
|
54
|
+
if (isArray) {
|
|
55
|
+
return textsToTranslate.map(t => translationsMap[t] || t);
|
|
56
|
+
}
|
|
57
|
+
return translationsMap[textsToTranslate[0]] || textsToTranslate[0];
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`AI Translation Error (${this.provider}): ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async _callAI(prompt) {
|
|
64
|
+
if (this.provider === 'openai') {
|
|
65
|
+
return await this._callOpenAI(prompt);
|
|
66
|
+
} else if (this.provider === 'gemini') {
|
|
67
|
+
return await this._callGemini(prompt);
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Unsupported AI provider: ${this.provider}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async _callOpenAI(prompt) {
|
|
73
|
+
// We'll use fetch to avoid adding heavy dependencies like 'openai' package
|
|
74
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
'Authorization': `Bearer ${this.apiKey}`
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
model: this.model,
|
|
82
|
+
messages: [
|
|
83
|
+
{ role: 'system', content: 'You are a professional translator for software applications.' },
|
|
84
|
+
{ role: 'user', content: prompt }
|
|
85
|
+
],
|
|
86
|
+
response_format: { type: 'json_object' }
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const data = await response.json();
|
|
91
|
+
if (data.error) throw new Error(data.error.message);
|
|
92
|
+
return data.choices[0].message.content;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async _callGemini(prompt) {
|
|
96
|
+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
101
|
+
generationConfig: { response_mime_type: 'application/json' }
|
|
102
|
+
})
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const data = await response.json();
|
|
106
|
+
if (data.error) throw new Error(data.error.message);
|
|
107
|
+
return data.candidates[0].content.parts[0].text;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = AITranslator;
|
package/src/server.js
CHANGED
|
@@ -141,12 +141,11 @@ function startServer(targetDir, port = 3000, config = {}) {
|
|
|
141
141
|
|
|
142
142
|
app.get('/api/export-missing', async (req, res) => {
|
|
143
143
|
try {
|
|
144
|
-
const
|
|
145
|
-
const exportResult = await manager.exportMissingKeys(sourceLang);
|
|
144
|
+
const exportResult = await manager.exportMissingFromFiles();
|
|
146
145
|
|
|
147
146
|
// Set headers for file download
|
|
148
147
|
res.setHeader('Content-Type', 'application/json');
|
|
149
|
-
res.setHeader('Content-Disposition', `attachment; filename="missing-
|
|
148
|
+
res.setHeader('Content-Disposition', `attachment; filename="missing-keys-from-code-${new Date().toISOString().split('T')[0]}.json"`);
|
|
150
149
|
|
|
151
150
|
res.json(exportResult.exportData);
|
|
152
151
|
} catch (err) {
|
|
@@ -171,8 +170,7 @@ function startServer(targetDir, port = 3000, config = {}) {
|
|
|
171
170
|
|
|
172
171
|
app.get('/api/export-missing/preview', async (req, res) => {
|
|
173
172
|
try {
|
|
174
|
-
const
|
|
175
|
-
const exportResult = await manager.exportMissingKeys(sourceLang);
|
|
173
|
+
const exportResult = await manager.exportMissingFromFiles();
|
|
176
174
|
res.json(exportResult);
|
|
177
175
|
} catch (err) {
|
|
178
176
|
res.status(500).json({ error: err.message });
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Skill: Manage Translations
|
|
2
|
+
|
|
3
|
+
This skill enables an AI agent to efficiently manage i18n translations using the `@tuhama/translation-manager` library.
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
This project uses a custom translation manager that supports AI-powered context extraction. Translation keys are stored in JSON files (usually in `src/locales` or `locales`).
|
|
7
|
+
|
|
8
|
+
## Workflow
|
|
9
|
+
|
|
10
|
+
### 1. Audit Translation Health
|
|
11
|
+
Before starting any work, always check the current status:
|
|
12
|
+
- **Command**: `npx @tuhama/translation-manager status`
|
|
13
|
+
- **Goal**: Identify missing translations, unused keys, and coverage percentage across all languages.
|
|
14
|
+
|
|
15
|
+
### 2. Identify Missing Keys from Code
|
|
16
|
+
Find keys that are used in the source code (e.g., `t('key.name')`) but aren't defined in the JSON files:
|
|
17
|
+
- **Action**: Use the `Scanner` or the Web UI to find "Missing from files" keys.
|
|
18
|
+
- **AI Task**: If you find missing keys, suggest descriptive key names based on their usage.
|
|
19
|
+
|
|
20
|
+
### 3. Generate Context-Aware Translations
|
|
21
|
+
When translating, leverage the extracted code context:
|
|
22
|
+
- **Rule**: Use the surrounding code snippets to determine the correct nuance (e.g., is "Save" a button or a noun?).
|
|
23
|
+
- **Style**: Maintain consistent tone across the app (default: professional and concise).
|
|
24
|
+
- **Placeholders**: Always preserve `{name}`, `{{count}}`, or other interpolation markers.
|
|
25
|
+
|
|
26
|
+
### 4. Maintain Key Consistency
|
|
27
|
+
- **Naming**: Use dot-notation for nesting (e.g., `auth.login.button_label`).
|
|
28
|
+
- **Sorting**: Keys should be kept in alphabetical order. Use the "Normalize" feature or `npx @tuhama/translation-manager normalize` (if available).
|
|
29
|
+
- **Cleanup**: Periodically check for unused keys and remove them to keep bundle sizes small.
|
|
30
|
+
|
|
31
|
+
## AI Guidelines for this Repo
|
|
32
|
+
- **Source Language**: Usually English (`en`).
|
|
33
|
+
- **Target Languages**: Check the `locales` directory for existing languages.
|
|
34
|
+
- **Naming Convention**: Prefer `snake_case` or `camelCase` based on existing patterns in the JSON files.
|
|
35
|
+
- **Key Discovery**: If you see hardcoded strings in `.js`, `.jsx`, `.ts`, or `.tsx` files, recommend wrapping them in `t()` and adding them to the translations.
|
|
36
|
+
|
|
37
|
+
## Commands Reference
|
|
38
|
+
- `npx @tuhama/translation-manager`: Start the Web UI.
|
|
39
|
+
- `npx @tuhama/translation-manager status`: Get JSON health report.
|
|
40
|
+
- `pnpm test`: Run the test suite to ensure core logic remains robust.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg-color:#0b0e14;--sidebar-bg:#111827cc;--accent-color:#7c3aed;--accent-hover:#6d28d9;--text-color:#f3f4f6;--text-muted:#9ca3af;--border-color:#ffffff14;--glass-bg:#ffffff08;--glass-blur:blur(20px);--green:#10b981;--red:#ef4444;--transition:all .25s ease}*{box-sizing:border-box;margin:0;padding:0}body{background-color:var(--bg-color);color:var(--text-color);-webkit-font-smoothing:antialiased;font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.6}#root{height:100vh;display:flex}.app-container{grid-template-columns:350px 1fr;width:100%;height:100vh;display:grid}.sidebar{background:var(--sidebar-bg);-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-right:1px solid var(--border-color);flex-direction:column;display:flex;overflow:hidden}.sidebar-header{border-bottom:1px solid var(--border-color);padding:32px 24px 20px}.brand-row{align-items:center;gap:16px;margin-bottom:24px;display:flex}.logo{filter:drop-shadow(0 0 10px #7c3aed66);border-radius:10px;width:42px;height:42px}.sidebar-header h1{background:linear-gradient(135deg,#a78bfa 0%,#3b82f6 100%);-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text;margin-bottom:0;font-size:1.5rem;font-weight:800}.search-box{margin-bottom:16px;position:relative}.search-box input{border:1px solid var(--border-color);color:#fff;background:#0003;border-radius:10px;width:100%;padding:12px 16px;font-size:.95rem}.sidebar-actions{gap:8px;margin-top:16px;display:flex}.create-btn{border:1px solid var(--accent-color);color:var(--accent-color);cursor:pointer;transition:var(--transition);background:#7c3aed1a;border-radius:10px;flex:2;padding:11px;font-weight:600}.normalize-btn{border:1px solid var(--green);color:var(--green);cursor:pointer;transition:var(--transition);background:#10b9811a;border-radius:10px;flex:1;justify-content:center;align-items:center;padding:11px;font-size:.85rem;font-weight:600;display:flex}.normalize-btn:hover{background:var(--green);color:#fff}.clean-btn{color:#f59e0b;cursor:pointer;transition:var(--transition);background:#f59e0b1a;border:1px solid #f59e0b;border-radius:10px;flex:1;justify-content:center;align-items:center;padding:11px;font-size:.85rem;font-weight:600;display:flex}.clean-btn:hover{color:#fff;background:#f59e0b}.create-btn:hover{background:var(--accent-color);color:#fff}.tree-view{flex:1;padding:16px;overflow-y:auto}.key-list{list-style:none}.key-list li{cursor:pointer;transition:var(--transition);color:var(--text-muted);border-radius:8px;justify-content:space-between;align-items:center;margin-bottom:4px;padding:10px 14px;font-size:.9rem;display:flex}.key-list li.unused{opacity:.5;font-style:italic}.key-list li.unused span:after{content:" (unused)";opacity:.7;margin-left:4px;font-size:.7rem}.key-list li span{white-space:nowrap;text-overflow:ellipsis;flex:1;overflow:hidden}.key-list li:hover{color:#fff;background:#ffffff0d}.key-list li.active{color:#a78bfa;background:#7c3aed26;font-weight:600}.key-list li.incomplete span{color:var(--text-muted)}.warning-dot{filter:drop-shadow(0 0 5px #ef444480);margin-left:8px;font-size:.8rem}.delete-icon{opacity:0;color:var(--red);cursor:pointer;background:0 0;border:none;padding:0 4px;font-size:1.25rem}.key-list li:hover .delete-icon{opacity:1}.editor{justify-content:center;align-items:flex-start;padding:40px;display:flex;overflow-y:auto}.editor-form{width:100%;max-width:800px;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);background:#1f293766;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px #00000080}.form-group{text-align:left;margin-bottom:24px}.form-group.missing label{color:var(--red)}.form-group.missing input,.form-group.missing textarea{border-color:#ef44444d}.missing-label{margin-left:4px;font-size:.75rem;font-style:italic;font-weight:400}.form-group input,.form-group textarea{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:14px;font-family:inherit;font-size:1rem}.form-group textarea{resize:vertical;min-height:100px}.form-actions{gap:16px;margin-top:32px;display:flex}.primary-btn{background:var(--accent-color);color:#fff;cursor:pointer;transition:var(--transition);border:none;border-radius:10px;padding:14px 28px;font-weight:600}.primary-btn:hover{background:var(--accent-hover);transform:translateY(-1px)}.secondary-btn{border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;background:0 0;border-radius:10px;padding:14px 28px;font-weight:600}.empty-state{text-align:center;max-width:500px}.hero-icon{filter:drop-shadow(0 0 20px #8b5cf64d);margin-bottom:24px;font-size:4rem}.empty-state h2{margin-bottom:12px;font-size:2rem}.empty-state p{color:var(--text-muted);margin-bottom:32px}.modal-overlay{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);z-index:1000;background:#000000b3;justify-content:center;align-items:center;padding:20px;display:flex;position:fixed;inset:0}.modal-content{border:1px solid var(--border-color);background:#1f2937;border-radius:20px;flex-direction:column;width:100%;max-width:600px;max-height:80vh;display:flex;overflow:hidden;box-shadow:0 25px 50px -12px #00000080}.modal-header{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:24px;display:flex}.modal-body{flex:1;padding:24px;overflow-y:auto}.cleanup-item{cursor:pointer;background:#ffffff08;border-radius:10px;align-items:center;gap:12px;margin-bottom:8px;padding:12px;display:flex}.cleanup-item:hover{background:#ffffff0f}.cleanup-item input{accent-color:var(--accent-color)}.maybe-used-warning{color:#f59e0b;align-items:center;gap:4px;margin-left:auto;font-size:.8rem;font-style:italic;display:flex}.modal-footer{border-top:1px solid var(--border-color);justify-content:flex-end;gap:12px;padding:20px;display:flex}.sidebar-tools{gap:8px;margin-top:12px;display:flex}.tool-btn{border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;transition:var(--transition);background:#ffffff08;border-radius:8px;flex:1;justify-content:center;align-items:center;gap:6px;padding:8px 12px;font-size:.8rem;font-weight:500;display:flex}.tool-btn:hover{color:#fff;background:#ffffff14;border-color:#fff3}.tool-btn.icon-text{flex:2}.editor-header-row{justify-content:space-between;align-items:center;margin-bottom:32px;display:flex}.magic-btn{color:#a78bfa;background:#7c3aed1a;border-color:#7c3aed4d;padding:10px 16px;font-size:.85rem}.magic-btn:hover{background:var(--accent-color);color:#fff}.label-row{justify-content:space-between;align-items:center;margin-bottom:8px;display:flex}.label-row label{margin-bottom:0}.icon-btn{cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:4px;display:flex}.icon-btn:hover{background:#ffffff1a}.magic-wand{opacity:.6;font-size:1.1rem}.form-group:hover .magic-wand{opacity:1}.alert{border-radius:10px;margin-bottom:20px;padding:12px 16px;font-size:.9rem}.alert-success{color:#34d399;background:#10b9811a;border:1px solid #10b9814d}.alert-error{color:#f87171;background:#ef44441a;border:1px solid #ef44444d}.wizard-step{margin-bottom:24px}.wizard-step label{color:var(--text-color);margin-bottom:12px;font-weight:600;display:block}.source-select{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:12px;font-size:1rem}.report-summary{border:1px solid var(--border-color);background:#0003;border-radius:12px;padding:16px}.report-summary ul{color:var(--text-muted);margin-top:12px;padding-left:20px}.preview-container{border:1px solid var(--border-color);background:#0003;border-radius:12px;max-height:400px;padding:16px;overflow-y:auto}.lang-preview{margin-bottom:24px}.lang-preview h3{color:var(--accent-color);border-bottom:1px solid var(--border-color);margin-bottom:12px;padding-bottom:4px;font-size:.85rem}.preview-list{flex-direction:column;gap:8px;display:flex}.preview-item{gap:8px;font-size:.85rem;display:flex}.preview-key{color:var(--text-muted);font-weight:500}.preview-value{color:var(--text-color)}.settings-input{letter-spacing:2px}.help-text{color:var(--text-muted);margin-top:8px;font-size:.8rem}.help-text a{color:var(--accent-color);text-decoration:none}.help-text a:hover{text-decoration:underline}.success-text{color:var(--green);font-weight:500}.approve-btn{background:var(--green)}.approve-btn:hover{background:#059669}.tool-btn.warning{color:#f59e0b;background:#f59e0b1a;border-color:#f59e0b4d}.tool-btn.warning:hover{color:#fff;background:#f59e0b}.missing-keys-list{margin-top:16px;list-style:none}.missing-key-item{border:1px solid var(--border-color);background:#ffffff08;border-radius:12px;justify-content:space-between;align-items:center;margin-bottom:8px;padding:14px;display:flex}.key-path{color:#a78bfa;font-family:JetBrains Mono,Fira Code,monospace;font-size:.85rem}.warning-box{color:#f59e0b;background:#f59e0b14;border:1px solid #f59e0b33;border-radius:12px;margin-bottom:24px;padding:16px;font-size:.85rem}.sm{padding:8px 14px;font-size:.8rem}
|