feat(tools): enhance translation management script (#6171)

- Add extract and merge modes to manage missing translations
- Implement functions to extract missing keys and merge WIP files
- Create usage documentation for the translation management script
This commit is contained in:
Serif 2026-01-25 20:08:06 +03:00 committed by GitHub
parent 298c956518
commit bc3df77e3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 284 additions and 2 deletions

View file

@ -48,3 +48,7 @@ The above will display the English text for `SOME_KEY`.
- Use `en.json` as reference for context
- Keep translations concise (UI space is limited)
- Test your translations locally if possible (`ng serve`)
## Translation Management Script
For managing missing translations and maintaining consistency, use the `tools/add-missing-i18n-variables.js` script. See [i18n-script-usage.md](i18n-script-usage.md) for detailed instructions.

91
docs/i18n-script-usage.md Normal file
View file

@ -0,0 +1,91 @@
# I18n Translation Management Script
This document describes the usage of the `tools/add-missing-i18n-variables.js` script, which helps manage internationalization (i18n) files for Super Productivity.
## Overview
The script manages translation files located in `src/assets/i18n/`. The base language is English (`en.json`), and other languages like `de.json` (German), `tr.json` (Turkish), etc., contain translations.
The script supports three modes:
- **Extract mode**: Creates work-in-progress (WIP) files with missing translations for a specific language.
- **Merge mode**: Merges translated WIP files back into the main language file.
- **Legacy mode**: Adds missing keys to all language files (default when no mode is specified).
## File Structure
- `en.json`: The reference English file with all keys.
- `{lang}.json`: Main translation files (e.g., `de.json`, `tr.json`).
- `{lang}-wip.json`: Temporary work-in-progress files containing only missing translations.
## Usage
### Extract Missing Translations
To extract missing translations for a specific language (e.g., Turkish):
```bash
node tools/add-missing-i18n-variables.js extract tr
```
This creates `tr-wip.json` with all keys that exist in `en.json` but are missing or empty in `tr.json`.
### Translate WIP File
Edit the generated `{lang}-wip.json` file and provide translations for the keys.
Example `tr-wip.json`:
```json
{
"APP": {
"SKIP_SYNC_WAIT": "Skip waiting for sync"
},
"F": {
"CALDAV": {
"ISSUE_CONTENT": {
"DESCRIPTION": "Description"
}
}
}
}
```
### Merge Translations
After translating the WIP file, merge it back into the main language file:
```bash
node tools/add-missing-i18n-variables.js merge tr
```
This:
- Merges translations from `tr-wip.json` into `tr.json`
- Maintains the same key order as `en.json`
- Validates that all keys are present
- Deletes the `tr-wip.json` file
### Legacy Mode (Update All Files)
To add missing keys to all language files at once (preserving existing translations):
```bash
node tools/add-missing-i18n-variables.js
```
This updates all `{lang}.json` files to include any new keys from `en.json`, placing them in the same order.
## Workflow Example
1. New features are added, updating `en.json` with new keys.
2. Run extract for your language: `node tools/add-missing-i18n-variables.js extract de`
3. Translate the keys in `de-wip.json`.
4. Run merge: `node tools/add-missing-i18n-variables.js merge de`
5. Submit a pull request with the updated `de.json`.
## Notes
- The script preserves the order of keys to match `en.json`.
- Empty strings in translation files trigger English fallback.
- WIP files are temporary and it would be deleted via the merge command.

View file

@ -21,6 +21,83 @@ function mergeInOrder(enObj, langObj) {
return result;
}
// Extract keys that exist in en.json but not in langObj
function extractMissingKeys(enObj, langObj) {
const missing = {};
for (const key of Object.keys(enObj)) {
if (typeof enObj[key] === 'object' && !Array.isArray(enObj[key])) {
const nested = extractMissingKeys(enObj[key], langObj?.[key] || {});
if (Object.keys(nested).length > 0) {
missing[key] = nested;
}
} else if (!langObj || !(key in langObj)) {
missing[key] = enObj[key];
}
}
return missing;
}
// Merge WIP translations into main language file
// IMPORTANT: Must maintain same order as en.json
function mergeWipToMain(enObj, langObj, wipObj) {
const result = {};
// Iterate through en.json keys to maintain order
for (const key of Object.keys(enObj)) {
if (typeof enObj[key] === 'object' && !Array.isArray(enObj[key])) {
result[key] = mergeWipToMain(enObj[key], langObj?.[key] || {}, wipObj?.[key] || {});
} else {
// Priority: WIP > existing lang > English
if (wipObj && key in wipObj) {
result[key] = wipObj[key];
} else if (langObj && key in langObj) {
result[key] = langObj[key];
} else {
result[key] = enObj[key];
}
}
}
return result;
}
// Verify all en.json keys exist in langObj after merge
function validateComplete(enObj, langObj, prefix = '') {
const missing = [];
for (const key of Object.keys(enObj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (typeof enObj[key] === 'object' && !Array.isArray(enObj[key])) {
missing.push(...validateComplete(enObj[key], langObj?.[key], path));
} else if (!langObj || !(key in langObj)) {
missing.push(path);
}
}
return missing;
}
// Count total translation keys (leaf nodes only)
function countKeys(obj) {
let count = 0;
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
count += countKeys(obj[key]);
} else {
count++;
}
}
return count;
}
// Parse CLI arguments
const args = process.argv.slice(2);
const mode = args[0]; // 'extract', 'merge', or undefined for legacy
const language = args[1]; // e.g., 'tr', 'de'
if (!fs.existsSync(enPath)) {
console.error('en.json not found in src/assets/i18n/');
process.exit(1);
@ -34,10 +111,116 @@ if (!fs.existsSync(i18nDir)) {
// Read the English reference file
const en = JSON.parse(fs.readFileSync(enPath, 'utf8'));
// Get all i18n files except en.json
// ========== EXTRACT MODE ==========
if (mode === 'extract') {
if (!language) {
console.error('Error: Language code required for extract mode');
console.error('Usage: node tools/add-missing-i18n-variables.js extract <language>');
console.error('Example: node tools/add-missing-i18n-variables.js extract tr');
process.exit(1);
}
const langPath = path.join(i18nDir, `${language}.json`);
const wipPath = path.join(i18nDir, `${language}-wip.json`);
// Read existing language file or create empty object
let langObj = {};
if (fs.existsSync(langPath)) {
const content = fs.readFileSync(langPath, 'utf8');
if (content.trim()) {
langObj = JSON.parse(content);
}
}
// Extract missing keys
const missing = extractMissingKeys(en, langObj);
const missingCount = countKeys(missing);
if (missingCount === 0) {
console.log(`✓ No missing translations for ${language}`);
console.log(` All ${countKeys(en)} keys are present in ${language}.json`);
process.exit(0);
}
// Write WIP file
fs.writeFileSync(wipPath, JSON.stringify(missing, null, 2) + '\n', 'utf8');
console.log(`✓ Found ${missingCount} missing translations`);
console.log(`✓ Created ${language}-wip.json`);
console.log('');
console.log('Next steps:');
console.log(` 1. Translate the keys in ${language}-wip.json`);
console.log(` 2. Run: node tools/add-missing-i18n-variables.js merge ${language}`);
process.exit(0);
}
// ========== MERGE MODE ==========
if (mode === 'merge') {
if (!language) {
console.error('Error: Language code required for merge mode');
console.error('Usage: node tools/add-missing-i18n-variables.js merge <language>');
console.error('Example: node tools/add-missing-i18n-variables.js merge tr');
process.exit(1);
}
const langPath = path.join(i18nDir, `${language}.json`);
const wipPath = path.join(i18nDir, `${language}-wip.json`);
if (!fs.existsSync(wipPath)) {
console.error(`Error: ${language}-wip.json not found`);
console.error(
`Run extract first: node tools/add-missing-i18n-variables.js extract ${language}`,
);
process.exit(1);
}
// Read files
let langObj = {};
if (fs.existsSync(langPath)) {
const content = fs.readFileSync(langPath, 'utf8');
if (content.trim()) {
langObj = JSON.parse(content);
}
}
const wipObj = JSON.parse(fs.readFileSync(wipPath, 'utf8'));
const wipCount = countKeys(wipObj);
// Merge WIP into main file (maintaining en.json order)
const merged = mergeWipToMain(en, langObj, wipObj);
// Validate
const missingKeys = validateComplete(en, merged);
if (missingKeys.length > 0) {
console.error('✗ Validation failed: Some keys are still missing:');
missingKeys.slice(0, 10).forEach((key) => console.error(` - ${key}`));
if (missingKeys.length > 10) {
console.error(` ... and ${missingKeys.length - 10} more`);
}
process.exit(1);
}
// Write merged file
fs.writeFileSync(langPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
// Delete WIP file
fs.unlinkSync(wipPath);
console.log(`✓ Merged ${wipCount} translations from ${language}-wip.json`);
console.log(`✓ Updated ${language}.json (maintaining en.json order)`);
console.log(`✓ Validation passed: All ${countKeys(en)} keys present`);
console.log(`✓ Deleted ${language}-wip.json`);
process.exit(0);
}
// ========== LEGACY MODE (default) ==========
// Get all i18n files except en.json and -wip.json files
const i18nFiles = fs
.readdirSync(i18nDir)
.filter((file) => file.endsWith('.json') && file !== 'en.json')
.filter(
(file) => file.endsWith('.json') && file !== 'en.json' && !file.endsWith('-wip.json'),
)
.sort();
console.log(`Found ${i18nFiles.length} language files to update:`);
@ -92,6 +275,10 @@ if (errors === 0) {
console.log(
'All language files updated successfully with missing keys in the same order as en.json.',
);
console.log('');
console.log('💡 Tip: Use extract/merge workflow for incremental translations:');
console.log(' node tools/add-missing-i18n-variables.js extract <lang>');
console.log(' node tools/add-missing-i18n-variables.js merge <lang>');
} else {
console.log('');
console.log('Some files had errors. Please check the output above.');