@tuhama/translation-manager 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuhama/translation-manager",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs-extra');
2
2
  const path = require('path');
3
+ const Utilities = require('./Utilities');
3
4
 
4
5
  /**
5
6
  * Interface-like class for managing translation file storage.
@@ -62,19 +63,24 @@ class Storage {
62
63
  /**
63
64
  * Writes a single translation file (or all of them).
64
65
  */
65
- async write(lang, content) {
66
+ async write(lang, content, options = {}) {
66
67
  const localesDir = await this.getLocalesDir();
67
68
  const filePath = path.join(localesDir, `${lang}.json`);
68
- await fs.writeJson(filePath, content, { spaces: 2 });
69
+
70
+ // Sort the content before writing if sorting is enabled (default: true)
71
+ const shouldSort = options.sort !== false;
72
+ const finalContent = shouldSort ? Utilities.sortObject(content) : content;
73
+
74
+ await fs.writeJson(filePath, finalContent, { spaces: 2 });
69
75
  }
70
76
 
71
77
  /**
72
78
  * Writes all translations.
73
79
  */
74
- async writeAll(translations) {
80
+ async writeAll(translations, options = {}) {
75
81
  const languages = Object.keys(translations);
76
82
  for (const lang of languages) {
77
- await this.write(lang, translations[lang]);
83
+ await this.write(lang, translations[lang], options);
78
84
  }
79
85
  }
80
86
  }
@@ -65,14 +65,14 @@ class TranslatorManager {
65
65
  /**
66
66
  * Saves a translation key across all language files.
67
67
  */
68
- async saveTranslation(key, values) {
68
+ async saveTranslation(key, values, options = {}) {
69
69
  const translations = await this.storage.readAll();
70
70
  const languages = Object.keys(translations);
71
71
 
72
72
  for (const lang of languages) {
73
73
  if (values[lang] !== undefined) {
74
74
  lodash.set(translations[lang], key, values[lang]);
75
- await this.storage.write(lang, translations[lang]);
75
+ await this.storage.write(lang, translations[lang], options);
76
76
  }
77
77
  }
78
78
  }
@@ -200,7 +200,7 @@ class TranslatorManager {
200
200
  * Saves multiple translation keys across languages.
201
201
  * @param {Object} data - { lang: { key: value } }
202
202
  */
203
- async saveBulkTranslations(data) {
203
+ async saveBulkTranslations(data, options = {}) {
204
204
  const translations = await this.storage.readAll();
205
205
  const languages = Object.keys(translations);
206
206
 
@@ -209,11 +209,114 @@ class TranslatorManager {
209
209
  for (const key in data[lang]) {
210
210
  lodash.set(translations[lang], key, data[lang][key]);
211
211
  }
212
- await this.storage.write(lang, translations[lang]);
212
+ await this.storage.write(lang, translations[lang], options);
213
213
  }
214
214
  }
215
215
  }
216
216
 
217
+ /**
218
+ * Exports missing translation keys in the format: {"key": {"lang1": "", "lang2": ""}}
219
+ * @param {string} sourceLang - The source language to exclude from missing keys
220
+ * @returns {Object} - Export data with missing keys and empty values for each target language
221
+ */
222
+ async exportMissingKeys(sourceLang = 'en') {
223
+ const { languages, translations, allKeys } = await this.scan();
224
+ const exportData = {};
225
+
226
+ // Get all missing keys across all languages
227
+ const allMissingKeys = new Set();
228
+
229
+ languages.forEach(lang => {
230
+ if (lang === sourceLang) return;
231
+
232
+ allKeys.forEach(key => {
233
+ const val = lodash.get(translations[lang], key);
234
+ if (val === undefined || val === '') {
235
+ allMissingKeys.add(key);
236
+ }
237
+ });
238
+ });
239
+
240
+ // Build export structure: {"key": {"lang1": "", "lang2": ""}}
241
+ Array.from(allMissingKeys).forEach(key => {
242
+ exportData[key] = {};
243
+ languages.forEach(lang => {
244
+ if (lang !== sourceLang) {
245
+ const val = lodash.get(translations[lang], key);
246
+ exportData[key][lang] = (val === undefined || val === '') ? '' : val;
247
+ }
248
+ });
249
+ });
250
+
251
+ return {
252
+ exportData,
253
+ metadata: {
254
+ sourceLang,
255
+ targetLanguages: languages.filter(lang => lang !== sourceLang),
256
+ totalKeys: allMissingKeys.size,
257
+ exportedAt: new Date().toISOString()
258
+ }
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Imports translated keys from the export format back into translation files
264
+ * @param {Object} importData - Data in format {"key": {"lang1": "translation", "lang2": "translation"}}
265
+ * @param {Object} options - Import options (merge strategy, formatting, etc.)
266
+ */
267
+ async importTranslations(importData, options = {}) {
268
+ const {
269
+ overwriteExisting = false,
270
+ skipEmpty = true,
271
+ format = true
272
+ } = options;
273
+
274
+ const translations = await this.storage.readAll();
275
+ const languages = Object.keys(translations);
276
+ const importStats = {
277
+ imported: 0,
278
+ skipped: 0,
279
+ errors: []
280
+ };
281
+
282
+ for (const key in importData) {
283
+ const keyTranslations = importData[key];
284
+
285
+ for (const lang in keyTranslations) {
286
+ const translation = keyTranslations[lang];
287
+
288
+ // Skip if language doesn't exist in project
289
+ if (!languages.includes(lang)) {
290
+ importStats.errors.push(`Language '${lang}' not found in project`);
291
+ continue;
292
+ }
293
+
294
+ // Skip empty translations if configured
295
+ if (skipEmpty && (!translation || translation.trim() === '')) {
296
+ importStats.skipped++;
297
+ continue;
298
+ }
299
+
300
+ // Check if key already has a value
301
+ const existingValue = lodash.get(translations[lang], key);
302
+ if (existingValue && existingValue !== '' && !overwriteExisting) {
303
+ importStats.skipped++;
304
+ continue;
305
+ }
306
+
307
+ // Import the translation
308
+ lodash.set(translations[lang], key, translation);
309
+ importStats.imported++;
310
+ }
311
+ }
312
+
313
+ // Save all updated translations
314
+ const saveOptions = { sort: format };
315
+ await this.storage.writeAll(translations, saveOptions);
316
+
317
+ return importStats;
318
+ }
319
+
217
320
  /**
218
321
  * Saves the current configuration to the config file.
219
322
  */
package/src/server.js CHANGED
@@ -26,8 +26,9 @@ function startServer(targetDir, port = 3000, config = {}) {
26
26
 
27
27
  app.post('/api/translations', async (req, res) => {
28
28
  try {
29
- const { key, values } = req.body;
30
- await manager.saveTranslation(key, values);
29
+ const { key, values, format = true } = req.body;
30
+ const options = { sort: format };
31
+ await manager.saveTranslation(key, values, options);
31
32
  res.json({ success: true });
32
33
  } catch (err) {
33
34
  res.status(500).json({ error: err.message });
@@ -129,14 +130,55 @@ function startServer(targetDir, port = 3000, config = {}) {
129
130
 
130
131
  app.post('/api/bulk-save', async (req, res) => {
131
132
  try {
132
- const { data } = req.body;
133
- await manager.saveBulkTranslations(data);
133
+ const { data, format = true } = req.body;
134
+ const options = { sort: format };
135
+ await manager.saveBulkTranslations(data, options);
134
136
  res.json({ success: true });
135
137
  } catch (err) {
136
138
  res.status(500).json({ error: err.message });
137
139
  }
138
140
  });
139
141
 
142
+ app.get('/api/export-missing', async (req, res) => {
143
+ try {
144
+ const { sourceLang = 'en' } = req.query;
145
+ const exportResult = await manager.exportMissingKeys(sourceLang);
146
+
147
+ // Set headers for file download
148
+ res.setHeader('Content-Type', 'application/json');
149
+ res.setHeader('Content-Disposition', `attachment; filename="missing-translations-${new Date().toISOString().split('T')[0]}.json"`);
150
+
151
+ res.json(exportResult.exportData);
152
+ } catch (err) {
153
+ res.status(500).json({ error: err.message });
154
+ }
155
+ });
156
+
157
+ app.post('/api/import-translations', async (req, res) => {
158
+ try {
159
+ const { data, options = {} } = req.body;
160
+
161
+ if (!data || typeof data !== 'object') {
162
+ return res.status(400).json({ error: 'Invalid import data format' });
163
+ }
164
+
165
+ const importStats = await manager.importTranslations(data, options);
166
+ res.json({ success: true, stats: importStats });
167
+ } catch (err) {
168
+ res.status(500).json({ error: err.message });
169
+ }
170
+ });
171
+
172
+ app.get('/api/export-missing/preview', async (req, res) => {
173
+ try {
174
+ const { sourceLang = 'en' } = req.query;
175
+ const exportResult = await manager.exportMissingKeys(sourceLang);
176
+ res.json(exportResult);
177
+ } catch (err) {
178
+ res.status(500).json({ error: err.message });
179
+ }
180
+ });
181
+
140
182
  app.get('/api/config', (req, res) => {
141
183
  res.json(manager.config);
142
184
  });
@@ -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{flex-direction:column;width:100%;height:100vh;display:flex}.app-body{flex:1;grid-template-columns:300px 1fr;display:grid;overflow:hidden}.app-header{background:var(--sidebar-bg);-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-bottom:1px solid var(--border-color);z-index:100;justify-content:space-between;align-items:center;min-height:70px;padding:16px 24px;display:flex}.header-left{align-items:center;display:flex}.brand-section{align-items:center;gap:16px;display:flex}.header-logo{filter:drop-shadow(0 0 8px #7c3aed66);border-radius:8px;width:36px;height:36px}.app-title{background:linear-gradient(135deg,#a78bfa 0%,#3b82f6 100%);-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text;margin:0;font-size:1.4rem;font-weight:800}.header-right{align-items:center;display:flex}.header-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.header-primary-btn{background:linear-gradient(135deg, var(--accent-color) 0%, var(--accent-hover) 100%);color:#fff;cursor:pointer;transition:var(--transition);border:none;border-radius:8px;align-items:center;gap:8px;padding:10px 16px;font-size:.9rem;font-weight:600;display:flex}.header-primary-btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px #7c3aed4d}.header-btn{border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;transition:var(--transition);white-space:nowrap;background:#ffffff0d;border-radius:6px;align-items:center;gap:6px;padding:8px 12px;font-size:.8rem;font-weight:500;display:flex}.header-btn:hover{border-color:var(--accent-color);color:var(--accent-color);background:#ffffff14;transform:translateY(-1px)}.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-search{border-bottom:1px solid var(--border-color);padding:20px 16px}.search-input{border:1px solid var(--border-color);color:#fff;width:100%;transition:var(--transition);background:#0003;border-radius:10px;padding:12px 16px;font-size:.95rem}.search-input:focus{border-color:var(--accent-color);outline:none;box-shadow:0 0 0 2px #7c3aed1a}.search-input::placeholder{color:var(--text-muted)}.tree-view{flex:1;padding:16px;overflow-y:auto}.loading-state{color:var(--text-muted);justify-content:center;align-items:center;padding:40px 20px;font-style:italic;display:flex}.dropdown-container{position:relative}.dropdown-menu{background:var(--sidebar-bg);border:1px solid var(--border-color);z-index:1000;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-radius:8px;min-width:200px;margin-top:4px;padding:8px 0;position:absolute;top:100%;left:0;right:0;box-shadow:0 8px 32px #0000004d}.dropdown-item{width:100%;color:var(--text-color);cursor:pointer;transition:var(--transition);text-align:left;background:0 0;border:none;align-items:center;gap:12px;padding:10px 16px;font-size:.9rem;display:flex}.dropdown-item:hover{color:var(--accent-color);background:#7c3aed1a}.item-icon{text-align:center;width:16px;font-size:1rem}.dropdown-divider{background:var(--border-color);height:1px;margin:8px 16px}.dropdown-arrow{color:var(--text-muted);transition:var(--transition);font-size:.8rem}.btn-icon{font-size:1rem}.key-tree{list-style:none}.tree-node{margin-bottom:2px}.tree-children{margin:0;padding:0;list-style:none}.tree-branch-header{cursor:pointer;transition:var(--transition);color:var(--text-color);border-radius:6px;align-items:center;padding:8px 14px;font-size:.9rem;font-weight:500;display:flex}.tree-branch-header:hover{background:#ffffff0d}.tree-branch-header.has-selected-child{color:#a78bfa;background:#7c3aed14}.tree-expand-icon{color:var(--text-muted);transition:var(--transition);text-align:center;width:12px;margin-right:8px;font-size:.8rem}.tree-branch-text{flex:1;align-items:center;gap:8px;display:flex}.tree-branch-count{color:var(--text-muted);font-size:.75rem;font-weight:400}.branch-indicator{margin-left:4px;font-size:.8rem}.branch-indicator.missing{filter:drop-shadow(0 0 3px #ef44444d)}.branch-indicator.unused{opacity:.6}.tree-leaf{cursor:pointer;transition:var(--transition);color:var(--text-muted);border-radius:6px;justify-content:space-between;align-items:center;margin-bottom:2px;padding:8px 14px;font-size:.85rem;display:flex}.tree-leaf.unused{opacity:.5;font-style:italic}.tree-leaf.unused .tree-leaf-text:after{content:" (unused)";opacity:.7;margin-left:4px;font-size:.7rem}.tree-leaf-content{flex:1;align-items:center;display:flex}.tree-leaf-text{white-space:nowrap;text-overflow:ellipsis;flex:1;overflow:hidden}.tree-leaf:hover{color:#fff;background:#ffffff0d}.tree-leaf.active{color:#a78bfa;background:#7c3aed26;font-weight:600}.tree-leaf.incomplete .tree-leaf-text{color:var(--text-muted)}.missing-translation-dot{filter:drop-shadow(0 0 5px #ef444480);margin-left:8px;font-size:.8rem}.delete-icon{opacity:0;color:var(--red);cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:4px;padding:2px 4px;font-size:1.1rem}.tree-leaf:hover .delete-icon{opacity:1}.delete-icon:hover{background:#ef44441a}.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}.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}