Kibana Globalization - Phase 1 (#7545)

* Add low level i18n plugin

Manages languages that are available and is responsible for loading translated
content at the granularity of a plugin.

To be done:
 - APIs for store and retrieval

* Use Kibana install as root for the translation store directory

Setting the path for storing the bundled language translation files to
<KIBANA_INSTALL>/data/store_translations/<PLUGIN_NAME>

* Updated i18n core plugin APIs to be asynchronous

To be done:
 - Better error handling in APIs
 - Fix threading issue with storePluginLanguageTranslations API

* Fix thread synchroization issue in storePluginLanguageTranslations

* Update error handling in i18n core plugin

* Change to use NodeJS mkdirp function for creating directories recursively

Updates with review comments from @srl295. Changed export syntax to show the
exported functions at end of file.

* Add REST API for getting translations of a language for a plugin

To be done:
 - Add algorithm to decide on the language for a plugin by comparing the accept languages
from the REST call and the plugin supported languages
 - Add REST API tests

* Add algorithm for determining plugin language when retrieving translations

Client would pass languages used in the 'accept-language' header. These
languages would then be compared against the plugin supported languages
and best compared language would be selected.

To be done:
 - Add REST API tests

* Add API to return all registered plugin language translations

* Add HAPI API to get all plugins translation files

* Update register translations API to be independent of plugin name and language

The register API is updated to be independent of plugin name and language. The API will now
traverse the path given and create language bundles as per language files it traverses.
The translations files structure has also been simplified to be just key/value objects.

To be done:
 - Add hapi API to get translations
 - Extend the API tests to test responses

* Update API test

* Add eslint fix for API test

* Update with review comments

From review https://github.com/elastic/kibana/pull/7545#issuecomment-231147435
following comments updated:
- README, .gitignore, and .eslintrc are not needed in a core plugin
- package.json only needs name and version
- unit tests need to go in a tests directory otherwise they won't get picked up
by the grunt tasks. Also our convention is to name the test file with the same
name as the module it's testing (so i18n_tests.js should just be i18n.js)
- For consistency with the rest of the code base, rename the data directory to fixtures.
- Prefer const (or let if necessary). Don't use var.
- Use ES6 imports/exports rather than commonjs style
- Only export the i18n module's public API. For instance, I don't think getPluginTranslationDetails is used outside of the i18n module, so it shouldn't be exposed publicly. If you want to expose it for testing purposes, I would recommend creating an i18n directory with an index.js file that exports the module's public API, and a separate i18n.js file with the "private" API. index.js will be for public use, i18n.js will be for private internal use.

* Update after review comments

From review (https://github.com/elastic/kibana/pull/7545#issuecomment-231884490):
- i18n module API should return promises for async operations instead of using
callbacks
- All filesystem access should be async
- Unit tests need to be updated based on new proposed plugin structure
(single language file, not split by view)

From design (https://github.com/elastic/kibana/issues/6515#issuecomment-231400097):
- Removed API as will consider in later phase

TODO:
- Make write function async

* Update after review comments

Updated write function to be asynchronous

* Update registerTranslations API to take absolute translation file as argument

The API originally took the directory as the argument but following reviews it
was decided to change to absolute file because it will be less brittle
since it is more explicit.

* Translate the Kibana welcome message

Translates the start-up message (“Kibana is loading ...”)in the Jade template.

To be done:
 - Means to register the core plugin translations. They are currently added
in the fixtures directory as static files. Need to be generated on the fly.

* Add build task to generate core plugin translations

Task which calls registerTranslations API and then a task which copies the
regsitered translations to <kibana_root>/build/kibana

* Add hook to optimize module to add registration during dev startup

Registration of the core plugin translations during development start of
Kibana server. The translations include the welcome message and server error
startup message.

* Handle scenario when the user preferred language header is not passed

The UI when loading asks i18n plugin which language translation to use
depending on the user preferred language header 'accept-language'.
This commit is to handle scenario where header is not passed. The algorithm
then chooses the default language.

* Replace registering of translations at plugin install time to the plugin init phase

This change follows review comments in:
https://github.com/elastic/kibana/issues/6515#issuecomment-236237218

* Update after review comments

Comments:
- https://github.com/elastic/kibana/pull/7545#discussion-diff-72890673
- https://github.com/elastic/kibana/pull/7545#discussion-diff-72894762

* Update after plugin folder layout changes in Kibana

This require to use <kibana_root>/data for registered translations
and i18n plugin moved to core_plugins from plugins.

Refer to PR for more details:
https://github.com/elastic/kibana/pull/7562

* Update translation registration to file path rather than bundling

After review discussions it was agreed to just register the absolute paths
to translation files rather than bundling each file into one central file
at registration.

* Update review comments

This commit contains the following review comments:
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74661282
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74661392
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74662271
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74663235
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74669201
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74669269
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74669419
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74669628
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74799382

* Update review comments

The following review comments are included in the commit:
- https://github.com/elastic/kibana/pull/7545#discussion_r74663515
- https://github.com/elastic/kibana/pull/7545#discussion_r74666995
- https://github.com/elastic/kibana/pull/7545#discussion_r74805552

* Expose the i18n APIs in the server object for plugin access

Plugins should call the i18n plugin APIs through the server object
and not directly from the module.

This closes he following comments:
- https://github.com/elastic/kibana/pull/7545#discussion_r74662598
- https://github.com/elastic/kibana/pull/7545#discussion_r74669327
- https://github.com/elastic/kibana/pull/7545#discussion_r74669765

* Update accept-language-parser module to 1.2.0

Module version 1.2.0 fixes issue:
https://github.com/opentable/accept-language-parser/issues/8

This commit updates review comments:
https://github.com/elastic/kibana/pull/7545#discussion-diff-75525214
https://github.com/elastic/kibana/pull/7545#issuecomment-240290461

* Add i18n default locale as a configurable item

Adds 'defaultLocale' configurable item to the i18n plugin configuration.
The default locale is used for translations if the locale specified by user
is not supported.

This commit satisfies the review comment:
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74669970

* Move UI i18n wrapper functionality into a module

This commit better structures the i18n capability so that it can be called
in UI code in a clearly defined fashion with minimum code. It also fixes
potential race conditions.

This commit updates review comments:
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74804791
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74801802
- https://github.com/elastic/kibana/pull/7545#discussion-diff-74670457

* Fill any missing translations using translations from default locale

The default language translations are loaded and are compared against the selected
language translations. The comparison can then highlight any missing translation
keys and can load the default translations keys as needed. This helps to unsure
where possible that a translation string is available in most scenarios even if not
in the locale requested.

This commit resolves review comments:
- https://github.com/elastic/kibana/pull/7545#issuecomment-239202583
- https://github.com/elastic/kibana/pull/7545#issuecomment-239203734

* Add unit tests for the i18n UI wrapper functions

* Fix issues after rebase with master

* Add translation keys verification tool

This tool helps to check that translation keys are translated. This tool can be
used for non-angular translation constructs like the Jade templates.

* Updates after review comments

Updates for review comments:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-3748114

* Update after review comments

Update for review comments:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-3937958

To be done:
- Update of unit tests for UI and server
- Call of verify translations

* Update unit tests after review changes

There was a number of changes to the i18n module and the ui i18n wrapper
following review comments. This commit is to update the unit tests with
respect.

* Add build task for verify translations

* Update the kibana i18n IDs to be prefixed with kibana

* Update verify translations to test registered translations

It was testing the static translation files. It is now updated to
test the translations registered when Kibana server is started and
the plugins have initialized.

* Update after review comments

Updates following review comments:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-5529711

* Update after review

This commit contain updates after the following review:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-5707951

* Updates after review

Updates for review comments:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-6656571

* Update after review

Updates for the following review comments:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-6911265

* Update after review

Updates after the following review comments:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-7084765

* Update unit tests to use expect throwError

* Update after rebase with master

Loading message changed following merge of commit
26c53e8a8d (diff-e25d7fee746a4f249e17f87c02fd95f8R55)
This required update to the welcome message and how it is called.

* Update following review

Updated the following review comments:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-9297662

* Update the algorithm to return the locale

The algorithm to return which locale to use for translations based on the user
locale list and the regsitered locales is updated in this commit. The algorithm
previously did an exact match on all the user locales first before (by priority)
then checking for best case match. The algorithm is now modified to check each
user locale starting with the highest priority first for an exact match and then
for best case match. If no match it then moves to the next user locale with
the next highest priority. This is to follow the priority list that a user
browser is configured for where there maybe a locale translation available
but might not be the exact match with regard to the locale code and/or script.
An example of this is that the highest priority locale of the user is 'en-US'
but the locale translation available is 'en'. It is better select the 'en'
locale rather than select the next highest locale which is an exact match.

* Update after review comments

Updates after the following reviews:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-9785665
https://github.com/elastic/kibana/pull/7545#pullrequestreview-9786404

* Fix after merge with master

Change in the flo and layout of ui index meant that acceptLanguages were not
being passed. This commit is an update to fix this so that the welcome
messages are loaded.

* Update after review comments

This commit is for updates after the following review:
https://github.com/elastic/kibana/pull/7545#pullrequestreview-10435175

* Fix issue when unit test run in CI as core translations are registered

When unit tests are run on a test server (like in the CI), it will start
Kibana server and register the core translations. This means that the i18n
unit tests need to be able to store the existing registration prior to
testing and replace after testing.

* [server/ui] move i18n into ui module

* [server/ui] restore renderApp() method signature

* [server/ui] unify i18n logic in UiI18n class

* [server] move translation files into "translations" dir

* Update i18n module to loaded by multiple server instances within the one process

* Update i18n module to a class

Moving the i18n module into a class so as to encapsulate the registered
translations which means there can be different and distinct instances per process.
This is to accomodate the user case where there might be multiple Kibana server
instances in a process and the localization should be at the server level.

* Identify private members in a class with underscore-prefix convention

* Remove redundant translation from core translation file

Message starting with 'Give me a moment...' is no longer part of loading
message folowing a rebase with master.

* [ui/i18n] reject translations files that do not use absolute paths

* Update config item locale to defaultLocale

* Update after review comments

- Update after following review: https://github.com/elastic/kibana/pull/7545#pullrequestreview-12775161
- Also, fix syntax mess following rebase with master of src/optimize/index.js

* Fix rebase with master error

* Add task for verifying translations in CI

* Fix lint errors
This commit is contained in:
Martin Hickey 2016-12-14 00:55:48 +00:00 committed by Spencer
parent bb2ed406e1
commit 7028a88efd
22 changed files with 645 additions and 9 deletions

View file

@ -97,3 +97,7 @@
# Set the interval in milliseconds to sample system and process performance
# metrics. Minimum is 100ms. Defaults to 5000.
#ops.interval: 5000
# The default locale. This locale can be used in certain circumstances to substitute any missing
# translations.
#i18n.defaultLocale: "en"

View file

@ -81,6 +81,7 @@
"@spalger/test-subj-selector": "0.2.1",
"@spalger/ui-ace": "0.2.3",
"JSONStream": "1.1.1",
"accept-language-parser": "1.2.0",
"angular": "1.4.7",
"angular-bootstrap-colorpicker": "3.0.19",
"angular-elastic": "2.5.0",

View file

@ -1,5 +1,8 @@
import { resolve } from 'path';
import Promise from 'bluebird';
import { mkdirp as mkdirpNode } from 'mkdirp';
import manageUuid from './server/lib/manage_uuid';
import ingest from './server/routes/api/ingest';
import search from './server/routes/api/search';
@ -13,6 +16,7 @@ module.exports = function (kibana) {
const kbnBaseUrl = '/app/kibana';
return new kibana.Plugin({
id: 'kibana',
config: function (Joi) {
return Joi.object({
enabled: Joi.boolean().default(true),
@ -85,12 +89,17 @@ module.exports = function (kibana) {
linkToLastSubUrl: false
},
],
injectDefaultVars(server, options) {
return {
kbnIndex: options.index,
kbnBaseUrl
};
},
translations: [
resolve(__dirname, './translations/en.json')
]
},
preInit: async function (server) {
@ -113,9 +122,7 @@ module.exports = function (kibana) {
search(server);
settings(server);
scripts(server);
server.expose('systemApi', systemApi);
}
});
};

View file

@ -0,0 +1,4 @@
{
"UI-WELCOME_MESSAGE": "Loading Kibana",
"UI-WELCOME_ERROR": "Kibana did not load properly. Check the server output for more information."
}

View file

@ -155,4 +155,8 @@ module.exports = () => Joi.object({
enabled: Joi.boolean().default(true)
}).default(),
i18n: Joi.object({
defaultLocale: Joi.string().default('en'),
}).default(),
}).default();

View file

@ -0,0 +1,4 @@
{
"test_plugin_1-NO_SSL": "Dont run the DE dev server using HTTPS",
"test_plugin_1-DEV": "Run the DE server with development mode defaults"
}

View file

@ -0,0 +1,6 @@
{
"test_plugin_1-NO_SSL": "Dont run the dev server using HTTPS",
"test_plugin_1-DEV": "Run the server with development mode defaults",
"test_plugin_1-NO_RUN_SERVER": "Dont run the dev server",
"test_plugin_1-HOME": "Run along home now!"
}

View file

@ -0,0 +1,3 @@
{
"test_plugin_1-NO_SSL": "Dont run the es-ES dev server using HTTPS! I am regsitered afterwards!"
}

View file

@ -0,0 +1,3 @@
{
"test_plugin_1-NO_SSL": "Dont run the DE dev server using HTTPS! I am regsitered afterwards!"
}

View file

@ -0,0 +1,6 @@
{
"test_plugin_2-XXXXXX": "This is XXXXXX string",
"test_plugin_2-YYYY_PPPP": "This is YYYY_PPPP string",
"test_plugin_2-FFFFFFFFFFFF": "This is FFFFFFFFFFFF string",
"test_plugin_2-ZZZ": "This is ZZZ string"
}

View file

@ -0,0 +1,226 @@
import expect from 'expect.js';
import _ from 'lodash';
import { join } from 'path';
import { I18n } from '../';
const FIXTURES = join(__dirname, 'fixtures');
describe('ui/i18n module', function () {
describe('one plugin', function () {
const i18nObj = new I18n();
before('registerTranslations - one plugin', function () {
const pluginName = 'test_plugin_1';
const pluginTranslationPath = join(FIXTURES, 'translations', pluginName);
const translationFiles = [
join(pluginTranslationPath, 'de.json'),
join(pluginTranslationPath, 'en.json')
];
const filesLen = translationFiles.length;
for (let indx = 0; indx < filesLen; indx++) {
i18nObj.registerTranslations(translationFiles[indx]);
}
});
describe('getTranslations', function () {
it('should return the translations for en locale as registered' , function () {
const languageTag = ['en'];
const expectedTranslationJson = {
'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS',
'test_plugin_1-DEV': 'Run the server with development mode defaults',
'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server',
'test_plugin_1-HOME': 'Run along home now!'
};
return checkTranslations(expectedTranslationJson, languageTag, i18nObj);
});
it('should return the translations for de locale as registered' , function () {
const languageTag = ['de'];
const expectedTranslationJson = {
'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS',
'test_plugin_1-DEV': 'Run the DE server with development mode defaults'
};
return checkTranslations(expectedTranslationJson, languageTag, i18nObj);
});
it('should pick the highest priority language for which translations exist' , function () {
const languageTags = ['es-ES', 'de', 'en'];
const expectedTranslations = {
'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS',
'test_plugin_1-DEV': 'Run the DE server with development mode defaults',
};
return checkTranslations(expectedTranslations, languageTags, i18nObj);
});
it('should return translations for highest priority locale where best case match is chosen from registered locales' , function () {
const languageTags = ['es', 'de'];
const expectedTranslations = {
'test_plugin_1-NO_SSL': 'Dont run the es-ES dev server using HTTPS! I am regsitered afterwards!'
};
i18nObj.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_1','es-ES.json'));
return checkTranslations(expectedTranslations, languageTags, i18nObj);
});
it('should return an empty object for locales with no translations' , function () {
const languageTags = ['ja-JA', 'fr'];
return checkTranslations({}, languageTags, i18nObj);
});
});
describe('getTranslationsForDefaultLocale', function () {
it('should return translations for default locale which is set to the en locale' , function () {
const i18nObj1 = new I18n('en');
const expectedTranslations = {
'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS',
'test_plugin_1-DEV': 'Run the server with development mode defaults',
'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server',
'test_plugin_1-HOME': 'Run along home now!'
};
i18nObj1.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_1','en.json'));
return checkTranslationsForDefaultLocale(expectedTranslations, i18nObj1);
});
it('should return translations for default locale which is set to the de locale' , function () {
const i18nObj1 = new I18n('de');
const expectedTranslations = {
'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS',
'test_plugin_1-DEV': 'Run the DE server with development mode defaults',
};
i18nObj1.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_1','de.json'));
return checkTranslationsForDefaultLocale(expectedTranslations, i18nObj1);
});
});
describe('getAllTranslations', function () {
it('should return all translations' , function () {
const expectedTranslations = {
de: {
'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS',
'test_plugin_1-DEV': 'Run the DE server with development mode defaults'
},
en: {
'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS',
'test_plugin_1-DEV': 'Run the server with development mode defaults',
'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server',
'test_plugin_1-HOME': 'Run along home now!'
},
'es-ES': {
'test_plugin_1-NO_SSL': 'Dont run the es-ES dev server using HTTPS! I am regsitered afterwards!'
}
};
return checkAllTranslations(expectedTranslations, i18nObj);
});
});
});
describe('multiple plugins', function () {
const i18nObj = new I18n();
beforeEach('registerTranslations - multiple plugin', function () {
const pluginTranslationPath = join(FIXTURES, 'translations');
const translationFiles = [
join(pluginTranslationPath, 'test_plugin_1', 'de.json'),
join(pluginTranslationPath, 'test_plugin_1', 'en.json'),
join(pluginTranslationPath, 'test_plugin_2', 'en.json')
];
const filesLen = translationFiles.length;
for (let indx = 0; indx < filesLen; indx++) {
i18nObj.registerTranslations(translationFiles[indx]);
}
});
describe('getTranslations', function () {
it('should return the translations for en locale as registered' , function () {
const languageTag = ['en'];
const expectedTranslationJson = {
'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS',
'test_plugin_1-DEV': 'Run the server with development mode defaults',
'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server',
'test_plugin_1-HOME': 'Run along home now!',
'test_plugin_2-XXXXXX': 'This is XXXXXX string',
'test_plugin_2-YYYY_PPPP': 'This is YYYY_PPPP string',
'test_plugin_2-FFFFFFFFFFFF': 'This is FFFFFFFFFFFF string',
'test_plugin_2-ZZZ': 'This is ZZZ string'
};
return checkTranslations(expectedTranslationJson, languageTag, i18nObj);
});
it('should return the translations for de locale as registered' , function () {
const languageTag = ['de'];
const expectedTranslationJson = {
'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS',
'test_plugin_1-DEV': 'Run the DE server with development mode defaults'
};
return checkTranslations(expectedTranslationJson, languageTag, i18nObj);
});
it('should return the most recently registered translation for a key that has multiple translations' , function () {
i18nObj.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_2', 'de.json'));
const languageTag = ['de'];
const expectedTranslationJson = {
'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS! I am regsitered afterwards!',
'test_plugin_1-DEV': 'Run the DE server with development mode defaults'
};
return checkTranslations(expectedTranslationJson, languageTag, i18nObj);
});
});
});
describe('registerTranslations', function () {
const i18nObj = new I18n();
it('should throw error when registering relative path', function () {
return expect(i18nObj.registerTranslations).withArgs('./some/path').to.throwError();
});
it('should throw error when registering empty filename', function () {
return expect(i18nObj.registerTranslations).withArgs('').to.throwError();
});
it('should throw error when registering filename with no extension', function () {
return expect(i18nObj.registerTranslations).withArgs('file1').to.throwError();
});
it('should throw error when registering filename with non JSON extension', function () {
return expect(i18nObj.registerTranslations).withArgs('file1.txt').to.throwError();
});
});
});
function checkTranslations(expectedTranslations, languageTags, i18nObj) {
return i18nObj.getTranslations(...languageTags)
.then(function (actualTranslations) {
expect(_.isEqual(actualTranslations, expectedTranslations)).to.be(true);
});
}
function checkAllTranslations(expectedTranslations, i18nObj) {
return i18nObj.getAllTranslations()
.then(function (actualTranslations) {
expect(_.isEqual(actualTranslations, expectedTranslations)).to.be(true);
});
}
function checkTranslationsForDefaultLocale(expectedTranslations, i18nObj) {
return i18nObj.getTranslationsForDefaultLocale()
.then(function (actualTranslations) {
expect(_.isEqual(actualTranslations, expectedTranslations)).to.be(true);
});
}

136
src/ui/i18n/i18n.js Normal file
View file

@ -0,0 +1,136 @@
import path from 'path';
import Promise from 'bluebird';
import { readFile } from 'fs';
import _ from 'lodash';
const asyncReadFile = Promise.promisify(readFile);
const TRANSLATION_FILE_EXTENSION = '.json';
function getLocaleFromFileName(fullFileName) {
if (_.isEmpty(fullFileName)) throw new Error('Filename empty');
const fileExt = path.extname(fullFileName);
if (fileExt.length <= 0 || fileExt !== TRANSLATION_FILE_EXTENSION) {
throw new Error('Translations must be in a JSON file. File being registered is ' + fullFileName);
}
return path.basename(fullFileName, TRANSLATION_FILE_EXTENSION);
}
function getBestLocaleMatch(languageTag, registeredLocales) {
if (_.contains(registeredLocales, languageTag)) {
return languageTag;
}
// Find the first registered locale that begins with one of the language codes from the provided language tag.
// For example, if there is an 'en' language code, it would match an 'en-US' registered locale.
const languageCode = _.first(languageTag.split('-')) || [];
return _.find(registeredLocales, (locale) => _.startsWith(locale, languageCode));
}
export class I18n {
_registeredTranslations = {};
constructor(defaultLocale = 'en') {
this._defaultLocale = defaultLocale;
}
/**
* Return all translations for registered locales
* @return {Promise<Object>} translations - A Promise object where keys are
* the locale and values are Objects
* of translation keys and translations
*/
getAllTranslations() {
const localeTranslations = {};
const locales = this._getRegisteredTranslationLocales();
const translations = _.map(locales, (locale) => {
return this._getTranslationsForLocale(locale)
.then(function (translations) {
localeTranslations[locale] = translations;
});
});
return Promise.all(translations)
.then(translations => _.assign({}, localeTranslations));
}
/**
* Return translations for a suitable locale from a user side locale list
* @param {...string} languageTags - BCP 47 language tags. The tags are listed in priority order as set in the Accept-Language header.
* @returns {Promise<Object>} translations - promise for an object where
* keys are translation keys and
* values are translations
* This object will contain all registered translations for the highest priority locale which is registered with the i18n module.
* This object can be empty if no locale in the language tags can be matched against the registered locales.
*/
getTranslations(...languageTags) {
const locale = this._getTranslationLocale(languageTags);
return this._getTranslationsForLocale(locale);
}
/**
* Return all translations registered for the default locale.
* @returns {Promise<Object>} translations - promise for an object where
* keys are translation keys and
* values are translations
*/
getTranslationsForDefaultLocale() {
return this._getTranslationsForLocale(this._defaultLocale);
}
/**
* The translation file is registered with i18n plugin. The plugin contains a list of registered translation file paths per language.
* @param {String} absolutePluginTranslationFilePath - Absolute path to the translation file to register.
*/
registerTranslations(absolutePluginTranslationFilePath) {
if (!path.isAbsolute(absolutePluginTranslationFilePath)) {
throw new TypeError(
'Paths to translation files must be absolute. ' +
`Got relative path: "${absolutePluginTranslationFilePath}"`
);
}
const locale = getLocaleFromFileName(absolutePluginTranslationFilePath);
this._registeredTranslations[locale] =
_.uniq(_.get(this._registeredTranslations, locale, []).concat(absolutePluginTranslationFilePath));
}
_getRegisteredTranslationLocales() {
return Object.keys(this._registeredTranslations);
}
_getTranslationLocale(languageTags) {
let locale = '';
const registeredLocales = this._getRegisteredTranslationLocales();
_.forEach(languageTags, (tag) => {
locale = locale || getBestLocaleMatch(tag, registeredLocales);
});
return locale;
}
_getTranslationsForLocale(locale) {
if (!this._registeredTranslations.hasOwnProperty(locale)) {
return Promise.resolve({});
}
const translationFiles = this._registeredTranslations[locale];
const translations = _.map(translationFiles, (filename) => {
return asyncReadFile(filename, 'utf8')
.then(fileContents => JSON.parse(fileContents))
.catch(SyntaxError, function (e) {
throw new Error('Invalid json in ' + filename);
})
.catch(function (e) {
throw new Error('Cannot read file ' + filename);
});
});
return Promise.all(translations)
.then(translations => _.assign({}, ...translations));
}
}

1
src/ui/i18n/index.js Normal file
View file

@ -0,0 +1 @@
export { I18n } from './i18n';

View file

@ -1,21 +1,23 @@
import { format as formatUrl } from 'url';
import { readFileSync as readFile } from 'fs';
import { defaults } from 'lodash';
import { defaults, _ } from 'lodash';
import { props } from 'bluebird';
import Boom from 'boom';
import { reduce as reduceAsync } from 'bluebird';
import { resolve } from 'path';
import fromRoot from '../utils/from_root';
import UiExports from './ui_exports';
import UiBundle from './ui_bundle';
import UiBundleCollection from './ui_bundle_collection';
import UiBundlerEnv from './ui_bundler_env';
import { UiI18n } from './ui_i18n';
export default async (kbnServer, server, config) => {
const uiExports = kbnServer.uiExports = new UiExports({
urlBasePath: config.get('server.basePath')
});
const uiI18n = kbnServer.uiI18n = new UiI18n(config.get('i18n.defaultLocale'));
uiI18n.addUiExportConsumer(uiExports);
const bundlerEnv = new UiBundlerEnv(config.get('optimize.bundleDir'));
bundlerEnv.addContext('env', config.get('env.name'));
bundlerEnv.addContext('urlBasePath', config.get('server.basePath'));
@ -88,14 +90,18 @@ export default async (kbnServer, server, config) => {
async function renderApp({ app, reply, includeUserProvidedConfig = true }) {
try {
const request = reply.request;
const translations = await uiI18n.getTranslationsForRequest(request);
return reply.view(app.templateName, {
app,
kibanaPayload: await getKibanaPayload({
app,
request: reply.request,
request,
includeUserProvidedConfig
}),
bundlePath: `${config.get('server.basePath')}/bundles`,
i18n: key => _.get(translations, key, ''),
});
} catch (err) {
reply(err);

View file

@ -0,0 +1,4 @@
{
"UI-WELCOME_MESSAGE": "Loading",
"UI-WELCOME_ERROR": ""
}

View file

@ -41,6 +41,16 @@ class UiExports {
this.consumers.push(consumer);
}
addConsumerForType(typeToConsume, consumer) {
this.consumers.push({
exportConsumer(uiExportType) {
if (uiExportType === typeToConsume) {
return consumer;
}
}
});
}
exportConsumer(type) {
for (const consumer of this.consumers) {
if (!consumer.exportConsumer) continue;

66
src/ui/ui_i18n.js Normal file
View file

@ -0,0 +1,66 @@
import { resolve } from 'path';
import { defaults, compact } from 'lodash';
import langParser from 'accept-language-parser';
import { I18n } from './i18n';
function acceptLanguageHeaderToBCP47Tags(header) {
return langParser.parse(header).map(lang => (
compact([lang.code, lang.region, lang.script]).join('-')
));
}
export class UiI18n {
constructor(defaultLocale = 'en') {
this._i18n = new I18n(defaultLocale);
this._i18n.registerTranslations(resolve(__dirname, './translations/en.json'));
}
/**
* Fetch the language translations as defined by the request.
*
* @param {Hapi.Request} request
* @returns {Promise<Object>} translations promise for an object where
* keys are translation keys and
* values are translations
*/
async getTranslationsForRequest(request) {
const header = request.headers['accept-language'];
const tags = acceptLanguageHeaderToBCP47Tags(header);
const requestedTranslations = await this._i18n.getTranslations(...tags);
const defaultTranslations = await this._i18n.getTranslationsForDefaultLocale();
return defaults({}, requestedTranslations, defaultTranslations);
}
/**
* uiExport consumers help the uiExports module know what to
* do with the uiExports defined by each plugin.
*
* This consumer will allow plugins to define export with the
* "language" type like so:
*
* new kibana.Plugin({
* uiExports: {
* languages: [
* resolve(__dirname, './translations/es.json'),
* ],
* },
* });
*
*/
addUiExportConsumer(uiExports) {
uiExports.addConsumerForType('translations', (plugin, translations) => {
translations.forEach(path => {
this._i18n.registerTranslations(path);
});
});
}
/**
Refer to I18n.getAllTranslations()
*/
getAllTranslations() {
return this._i18n.getAllTranslations();
}
}

View file

@ -93,7 +93,7 @@ block content
.kibanaLoader__logo
.kibanaWelcomeLogo
.kibanaLoader__content
| Loading Kibana
| #{i18n('UI-WELCOME_MESSAGE')}
script.
window.onload = function () {
@ -125,7 +125,7 @@ block content
err.style['text-align'] = 'center';
err.style['background'] = '#F44336';
err.style['padding'] = '25px';
err.innerText = 'Kibana did not load properly. Check the server output for more information.';
err.innerText = '#{i18n('UI-WELCOME_ERROR')}';
document.body.innerHTML = err.outerHTML;
}

View file

@ -12,6 +12,7 @@ module.exports = function (grunt) {
'_build:babelOptions',
'_build:plugins',
'_build:data',
'_build:verifyTranslations',
'_build:packageJson',
'_build:readme',
'_build:babelCache',

View file

@ -0,0 +1,62 @@
import Promise from 'bluebird';
import _ from 'lodash';
import fromRoot from '../../src/utils/from_root';
import KbnServer from '../../src/server/kbn_server';
import * as i18nVerify from '../utils/i18n_verify_keys';
module.exports = function (grunt) {
grunt.registerTask('_build:verifyTranslations', function () {
const done = this.async();
const parsePaths = [fromRoot('/src/ui/views/*.jade')];
const serverConfig = {
env: 'production',
logging: {
silent: true,
quiet: true,
verbose: false
},
optimize: {
useBundleCache: false,
enabled: false
},
server: {
autoListen: false
},
plugins: {
initialize: true,
scanDirs: [fromRoot('src/core_plugins')]
},
uiSettings: {
enabled: false
}
};
const kbnServer = new KbnServer(serverConfig);
kbnServer.ready()
.then(() => verifyTranslations(kbnServer.uiI18n, parsePaths))
.then(() => kbnServer.close())
.then(done)
.catch((err) => {
kbnServer.close()
.then(() => done(err));
});
});
};
function verifyTranslations(uiI18nObj, parsePaths)
{
return uiI18nObj.getAllTranslations()
.then(function (translations) {
return i18nVerify.getTranslationKeys(parsePaths)
.then(function (translationKeys) {
const keysNotTranslatedPerLocale = i18nVerify.getNonTranslatedKeys(translationKeys, translations);
if (!_.isEmpty(keysNotTranslatedPerLocale)) {
const str = JSON.stringify(keysNotTranslatedPerLocale);
const errMsg = 'The following translation keys per locale are not translated: ' + str;
throw new Error(errMsg);
}
});
});
}

View file

@ -30,6 +30,7 @@ module.exports = function (grunt) {
'test:server',
'test:browser-ci',
'test:api',
'_build:verifyTranslations',
]);
grunt.registerTask('jenkins:selenium', [

View file

@ -0,0 +1,81 @@
import fs from 'fs';
import glob from 'glob';
import path from 'path';
import Promise from 'bluebird';
import _ from 'lodash';
const readFile = Promise.promisify(fs.readFile);
const globProm = Promise.promisify(glob);
/**
* Return all the translation keys found for the file pattern
* @param {Array<String>} filesPatterns - List of file patterns to be checkd for translation keys
* @param {Array<String>} translations - List of translations keys
* @return {Promise} - A Promise object which will return a String Array of the translation keys
* not translated then the Object will contain all non translated translation keys with value of file the key is from
*/
export function getTranslationKeys(filesPatterns) {
return getFilesToVerify(filesPatterns)
.then(function (filesToVerify) {
return getKeys(filesToVerify);
});
};
/**
* Return translation keys that are not translated
* @param {Array<String>} translationKeys - List of translation keys to be checked if translated
* @param {Object} localeTranslations - Object of locales and their translations
* @return {Object} - A object which will be empty if all translation keys are translated. If translation keys are
* not translated then the Object will contain all non translated translation keys per localem
*/
export function getNonTranslatedKeys(translationKeys, localeTranslations) {
const keysNotTranslatedPerLocale = {};
_.forEach(localeTranslations, (translations, locale) => {
const keysNotTranslated = _.difference(translationKeys, Object.keys(translations));
if (!_.isEmpty(keysNotTranslated)) {
keysNotTranslatedPerLocale[locale] = keysNotTranslated;
}
});
return keysNotTranslatedPerLocale;
};
function getFilesToVerify(verifyFilesPatterns) {
const filesToVerify = [];
return Promise.map(verifyFilesPatterns, (verifyFilesPattern) => {
const baseSearchDir = path.dirname(verifyFilesPattern);
const pattern = path.basename(verifyFilesPattern);
return globProm(pattern, {cwd: baseSearchDir, matchBase: true})
.then(function (files) {
for (const file of files) {
filesToVerify.push(path.join(baseSearchDir, file));
}
});
})
.then(function () {
return filesToVerify;
});
}
function getKeys(filesToVerify) {
const translationKeys = [];
const translationPattern = 'i18n\\(\'(.*)\'\\)';
const translationRegEx = new RegExp(translationPattern, 'g');
const filePromises = _.map(filesToVerify, (file) => {
return readFile(file, 'utf8')
.then(function (fileContents) {
let regexMatch;
while ((regexMatch = translationRegEx.exec(fileContents)) !== null) {
if (regexMatch.length >= 2) {
const translationKey = regexMatch[1];
translationKeys.push(translationKey);
}
}
});
});
return Promise.all(filePromises)
.then(function () {
return _.uniq(translationKeys);
});
}