Skip to content

Commit

Permalink
Merge pull request #1429 from headlamp-k8s/revamp-i18n
Browse files Browse the repository at this point in the history
Add tools and more tests for translations
  • Loading branch information
joaquimrocha authored Oct 18, 2023
2 parents 175a45c + 942e073 commit e19a3f4
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 24 deletions.
1 change: 1 addition & 0 deletions app/electron/i18next-parser.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module.exports = {
namespaceSeparator: '|',
keySeparator: false,
defaultNamespace: 'app',
contextSeparator: '//context:',
output: path.join(helper.LOCALES_DIR, './$LOCALE/$NAMESPACE.json'),
locales: helper.CURRENT_LOCALES,
// The English catalog has "SomeKey": "SomeKey" so we stop warnings about
Expand Down
40 changes: 40 additions & 0 deletions app/electron/locales/de/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"About": "Über",
"Quit": "Beenden",
"Select All": "Alles auswählen",
"Delete": "Löschen",
"Services": "Dienste",
"Hide": "Ausblenden",
"Hide Others": "Andere ausblenden",
"Show All": "Alle anzeigen",
"File": "Datei",
"Close": "Schließen",
"Edit": "Bearbeiten",
"Cut": "Ausschneiden",
"Copy": "Kopieren",
"Paste": "Einfügen",
"Paste and Match Style": "Einfügen und Stil anpassen",
"Speech": "Sprache",
"Start Speaking": "Sprechen starten",
"Stop Speaking": "Sprechen stoppen",
"View": "Ansicht",
"Reload": "Neu laden",
"Toggle Developer Tools": "Entwicklertools umschalten",
"Reset Zoom": "Zoom zurücksetzen",
"Zoom In": "Vergrößern",
"Zoom Out": "Verkleinern",
"Toggle Fullscreen": "Vollbild umschalten",
"Window": "Fenster",
"Minimize": "Minimieren",
"Bring All to Front": "Alle nach vorne bringen",
"Help": "Hilfe",
"Documentation": "Dokumentation",
"Open an Issue": "Ein Problem melden",
"Invalid URL": "Ungültige URL",
"Application opened with an invalid URL: {{ url }}": "Anwendung mit einer ungültigen URL geöffnet: {{ url }}",
"Another process is running": "Ein anderer Prozess läuft",
"Looks like another process is already running. Continue by terminating that process automatically, or quit?": "Es sieht so aus, als ob bereits ein anderer Prozess läuft. Fortfahren, indem Sie diesen Prozess automatisch beenden, oder beenden?",
"Continue": "Weiter",
"Failed to quit the other running process": "Konnte den anderen laufenden Prozess nicht beenden",
"Could not quit the other running process, PIDs: {{ process_list }}. Please stop that process and relaunch the app.": "Konnte den anderen laufenden Prozess nicht beenden, PIDs: {{ process_list }}. Bitte stoppen Sie diesen Prozess und starten Sie die App neu."
}
2 changes: 1 addition & 1 deletion app/electron/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"Reset Zoom": "Reset Zoom",
"Zoom In": "Zoom In",
"Zoom Out": "Zoom Out",
"Toogle Fullscreen": "Toogle Fullscreen",
"Toggle Fullscreen": "Toggle Fullscreen",
"Window": "Window",
"Minimize": "Minimize",
"Bring All to Front": "Bring All to Front",
Expand Down
2 changes: 1 addition & 1 deletion app/electron/locales/es/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"Reset Zoom": "Tamaño real",
"Zoom In": "Ampliar",
"Zoom Out": "Reducir",
"Toogle Fullscreen": "Alternar pantalla completa",
"Toggle Fullscreen": "Alternar pantalla completa",
"Window": "Ventana",
"Minimize": "Minimizar",
"Bring All to Front": "Traer todo al frente",
Expand Down
40 changes: 40 additions & 0 deletions app/electron/locales/fr/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"About": "À propos",
"Quit": "Quitter",
"Select All": "Tout sélectionner",
"Delete": "Supprimer",
"Services": "Services",
"Hide": "Masquer",
"Hide Others": "Masquer les autres",
"Show All": "Tout afficher",
"File": "Fichier",
"Close": "Fermer",
"Edit": "Éditer",
"Cut": "Couper",
"Copy": "Copier",
"Paste": "Coller",
"Paste and Match Style": "Coller et adapter le style",
"Speech": "Parole",
"Start Speaking": "Commencer à parler",
"Stop Speaking": "Arrêter de parler",
"View": "Vue",
"Reload": "Recharger",
"Toggle Developer Tools": "Basculer les outils de développement",
"Reset Zoom": "Réinitialiser le zoom",
"Zoom In": "Zoomer",
"Zoom Out": "Dézoomer",
"Toggle Fullscreen": "Basculer en plein écran",
"Window": "Fenêtre",
"Minimize": "Minimiser",
"Bring All to Front": "Tout ramener au premier plan",
"Help": "Aide",
"Documentation": "Documentation",
"Open an Issue": "Ouvrir un problème",
"Invalid URL": "URL invalide",
"Application opened with an invalid URL: {{ url }}": "Application ouverte avec une URL invalide : {{ url }}",
"Another process is running": "Un autre processus est en cours",
"Looks like another process is already running. Continue by terminating that process automatically, or quit?": "Il semble qu'un autre processus soit déjà en cours. Continuer en terminant ce processus automatiquement, ou quitter ?",
"Continue": "Continuer",
"Failed to quit the other running process": "Échec de la fermeture de l'autre processus en cours",
"Could not quit the other running process, PIDs: {{ process_list }}. Please stop that process and relaunch the app.": "Impossible de quitter l'autre processus en cours, PIDs : {{ process_list }}. Veuillez arrêter ce processus et relancer l'application."
}
2 changes: 1 addition & 1 deletion app/electron/locales/pt/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"Reset Zoom": "Tamanho real",
"Zoom In": "Ampliar",
"Zoom Out": "Reduzir",
"Toogle Fullscreen": "Alternar ecrã completo",
"Toggle Fullscreen": "Alternar ecrã completo",
"Window": "Janela",
"Minimize": "Minimizar",
"Bring All to Front": "Passar tudo para a frente",
Expand Down
2 changes: 1 addition & 1 deletion app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ function getDefaultAppMenu(): AppMenu[] {
},
sep,
{
label: i18n.t('Toogle Fullscreen'),
label: i18n.t('Toggle Fullscreen'),
role: 'togglefullscreen',
id: 'original-toggle-fullscreen',
},
Expand Down
72 changes: 52 additions & 20 deletions docs/development/i18n/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,37 @@ be translated and which should be left in the original form.

## Namespaces

[i18next namespaces](https://www.i18next.com/principles/namespaces)
are useful to keep things modular.
We have only two main [i18next namespaces](https://www.i18next.com/principles/namespaces):

We have a namespace for each app section, and also some frequently used global parts.
Namespaces are separated from the actual text by a `|` character.
E.g. `t('mynamescapce|This will be the translated text')`.
* **glossary**: For Kubernetes jargon or terms/sentences that are very technical.
* **translation**: Default namespace, used for everything else not in the **glossary** namespace.

### Frequent, and Glossary namespaces
We do have a third namespace that concerns only the desktop app related strings: **app**.

Additionally we have some global namespaces for frequently used and
jargony technical words.
In Headlamp, namespaces are separated by a `|` character. E.g. `t('glossary|Pod')`.

- frequent.json, Phrases reused many times, eg. 'save', 'cancel'
- glossary.json, Reusing these consistently inside texts like jargon words (Pods)
## Context

In order to better express context for a translation, we use the [i18next context](https://www.i18next.com/principles/context) feature. It is used like this:

```typescript
return t('translation|Desired', { context: 'pods' });
```

In the example above, we give the extra context of "pods" for the word "Desired", meaning it refers to the concept of pod, and precisely more than one (in case the target language of
the translation distinguishes between plural and singular for this word).

In the translated files, the context will show up in the respective key as:

```json
"Desired//context:pods": ""
```

And should be translated without that context suffix. For example, for Spanish:

```json
"Desired//context:pods": "Deseados"
```

#### Technical Jargon words

Expand All @@ -52,7 +69,7 @@ Here is an example which can use number formatting:


```JavaScript
return t('cluster:{{numReady, number}} / {{numItems, number}} Requested', {
return t('{{numReady, number}} / {{numItems, number}} Requested', {
numReady: podsReady.length,
numItems: items.length,
});
Expand All @@ -70,15 +87,6 @@ Here's an example of using date formatting:
});
```

## Adding a new component.

See the `frontend/src/i18n/locales/en/` folder for a complete list of namespaces.
If you need a new namespace (e.g. when you're using a sentence that's very specific to
a single/new component), use that namespace as you would if it already existed.

Then run `make i18n` and a new translation file for that namespace will show up in
all locale folders.

## Adding a new language.

Create a folder using the locale code in:
Expand All @@ -89,6 +97,30 @@ the project and creates the corresponding catalog files.

Integrated components may need to be adjusted (MaterialUI/Monaco etc).

## Translating missing strings

Since technical development happens more frequently than translations, chances
are that developers introduce new strings that need to be translated, and will
be stored as empty strings (defaulting to English) in the translation files.

In order to more easily spot and translate the missing strings, we have two CLI
tools:

* *extract-empty-translations.js*: This script (in ./frontend/src/i18n/tools/)
will extract the strings without a corresponding translation from the translation
files, and copy them into a new file.
E.g. `$ node copy-empty-translations.js ../locales/de/translation.json` will
by default create a `../locales/de/translation_empty.json`. This file can be
used to translate the strings in a more isolated way.
* *copy-translations.js*: This script (in ./frontend/src/i18n/tools/)
by default copies any existing translations from one source translation file to
a target one, if the same key is not translated in the destination file.
E.g. `$ node copy-translations.js ../locales/de/translation_no_longer_empty.json ../locales/de/translation.json` will
copy any new translations from the file given as the first argument to the one
given as the second argument, if the same key is not translated in the second.
There are some options to this script, which can be seen by running it with the
`--help` flag.

## Material UI

Some Material UI components are localized, and are configured
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/i18n/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { execSync } from 'child_process';
import { error } from 'console';
import fs from 'fs';
import glob from 'glob';

const path = require('node:path');
const allowlist = require('./allowlist.json');

const frontendDir = path.join(__dirname, '..', '..');

/*
* Description:
* This test will check for duplicate keys in the translation files and will fail if any are found.
Expand Down Expand Up @@ -117,3 +120,17 @@ describe('Test for non-intentional repeating translation keys', () => {
expect(result).toBe(true);
});
});

function getTranslationChanges() {
// Get uncommitted changes in the tracked translation files.
return execSync(
`git status -uno --porcelain ${frontendDir}/src/i18n/locales/*/*.json`
).toString();
}

describe('Forgotten translations', () => {
test('Check uncommitted translations', async () => {
execSync('npm run i18n', { cwd: frontendDir });
expect(getTranslationChanges()).toBe('');
});
});
63 changes: 63 additions & 0 deletions frontend/src/i18n/tools/copy-translations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// This script copies the translations from a translations file to an existing file.
// By default, it 1) only overwrites translations that are missing or are empty, in the destination file,
// and 2) only copies translations that are in the destination file.
// You can use the --force option to overwrite translations that are not empty in the destination file.
// You can use the --all option to copy all translations, even if they are not in the destination file.

const fs = require('fs');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const argv = yargs(hideBin(process.argv))
.command('$0 <srcFile> <destFile>', 'Process a translations file', yargs => {
yargs.positional('srcFile', {
describe: 'Path to the translations file to copy translations from',
demandOption: true,
type: 'string',
});
yargs.positional('destFile', {
describe: 'Path to the translations file to copy translations into',
demandOption: true,
type: 'string',
});
})
.option('force', {
alias: 'f',
type: 'boolean',
default: false,
describe: 'Force overwrite non-empty values',
})
.option('all', {
alias: 'a',
type: 'boolean',
default: false,
describe: 'Copy all translations, even if they are not in the destination file',
})
.version(false).argv;

const src = fs.readFileSync(argv.srcFile, 'utf8');
const dest = fs.readFileSync(argv.destFile, 'utf8');

const srcData = JSON.parse(src);
const destData = JSON.parse(dest);
const copyAll = argv.all;
let isChanged = false;

for (const key in srcData) {
if (
(!!srcData[key] || copyAll) &&
(destData.hasOwnProperty(key) || argv.all) &&
(!destData[key] || argv.force)
) {
isChanged = true;
destData[key] = srcData[key];
}
}

if (!isChanged) {
console.log('No translations copied.');
process.exit(0);
}

// Write the updated destData back to the file
fs.writeFileSync(argv.destFile, JSON.stringify(destData, null, 2));
50 changes: 50 additions & 0 deletions frontend/src/i18n/tools/extract-empty-translations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// This script copies the empty translations from a translations file to a new file. So they can
// be easily spotted and translated.
//
// Usage: node extract-empty-translations.js <translationsFile> [-o <outputFile>]
// Example (creates a ./src/i18n/locales/de/translations_empty.json file):
// node extract-empty-translations.js ./src/i18n/locales/de/translations.json

const fs = require('fs');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const argv = yargs(hideBin(process.argv))
.command('$0 <translationsFile>', 'Process a translations file', yargs => {
yargs.positional('translationsFile', {
describe: 'Path to the translations file',
type: 'string',
});
})
.option('outputFile', {
alias: 'o',
type: 'string',
describe: 'Path to the output file',
})
.version(false).argv;

const translations = fs.readFileSync(argv.translationsFile, 'utf8');
const translationsData = JSON.parse(translations);

// Extract the keys with empty values
const emptyKeys = Object.keys(translationsData).filter(key => translationsData[key] === '');

// Create an object with the empty keys and empty values
const emptyTranslationsData = emptyKeys.reduce((obj, key) => {
obj[key] = '';
return obj;
}, {});

// If an output file is specified, write the data to the file
if (Object.keys(emptyTranslationsData).length === 0) {
console.log('No missing translations found.');
process.exit(0);
}

const outputFileName =
argv.outputFile ||
argv.translationsFile.slice(0, argv.translationsFile.lastIndexOf('.')) + '_empty.json';
// Write the empty translations data to the output file
fs.writeFileSync(outputFileName, JSON.stringify(emptyTranslationsData, null, 2));

console.log(`Created ${outputFileName}`);

0 comments on commit e19a3f4

Please sign in to comment.