diff --git a/.i18nrc.json b/.i18nrc.json index 86825e8f703ef..0d8e1f71a8865 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -52,6 +52,8 @@ "x-pack/plugins/infra/public/utils/loading_state/loading_result.ts", "x-pack/plugins/infra/server/graphql/types.ts", "x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts" - + ], + "translations": [ + "x-pack/plugins/translations/translations/zh-CN.json" ] } diff --git a/package.json b/package.json index 70c9756d349f4..3305333c0a0a0 100644 --- a/package.json +++ b/package.json @@ -289,6 +289,7 @@ "@types/eslint": "^4.16.2", "@types/execa": "^0.9.0", "@types/fetch-mock": "7.2.1", + "@types/json5": "^0.0.30", "@types/getopts": "^2.0.0", "@types/glob": "^5.0.35", "@types/globby": "^8.0.0", diff --git a/packages/kbn-i18n/GUIDELINE.md b/packages/kbn-i18n/GUIDELINE.md index 71685be100294..20fb5b7c09847 100644 --- a/packages/kbn-i18n/GUIDELINE.md +++ b/packages/kbn-i18n/GUIDELINE.md @@ -440,9 +440,10 @@ it('should render normally', async () => { 3. Check functionality of an element (button is clicked, checkbox is checked/unchecked, etc.). -4. Run i18n validation tool and skim through created `en.json`: - ```js - node scripts/i18n_check --output ./ +4. Run i18n validation/extraction tools and skim through created `en.json`: + ```bash + $ node scripts/i18n_check --ignore-missing + $ node scripts/i18n_extract --output-dir ./ ``` 5. Run linters and type checker as you normally do. diff --git a/src/dev/i18n/serializers/index.js b/scripts/i18n_extract.js similarity index 90% rename from src/dev/i18n/serializers/index.js rename to scripts/i18n_extract.js index 3c10d7754563d..bd403258d6700 100644 --- a/src/dev/i18n/serializers/index.js +++ b/scripts/i18n_extract.js @@ -17,5 +17,5 @@ * under the License. */ -export { serializeToJson } from './json'; -export { serializeToJson5 } from './json5'; +require('../src/setup_node_env'); +require('../src/dev/run_i18n_extract'); diff --git a/src/dev/i18n/README.md b/src/dev/i18n/README.md index 8280f2b81a5a0..765b887f82bf0 100644 --- a/src/dev/i18n/README.md +++ b/src/dev/i18n/README.md @@ -140,19 +140,20 @@ The `description` is optional, `values` is optional too unless `defaultMessage` ### Usage ```bash -node scripts/i18n_check --path path/to/plugin --path path/to/another/plugin --output ./translations --output-format json5 +node scripts/i18n_extract --path path/to/plugin --path path/to/another/plugin --output-dir ./translations --output-format json5 ``` * `path/to/plugin` is an example of path to a directory(-es) where messages searching should start. By default `--path` is `.`, it means that messages from all paths in `.i18nrc.json` will be parsed. Each specified path should start with any path in `.i18nrc.json` or be a part of it. -* `--output` specifies a path to a directory, where `en.json` will be created, if `--output` is not provided, `en.json` generation will be skipped. It is useful if you want to validate i18n engine usage.\ +* `--output-dir` specifies a path to a directory, where `en.json` will be created.\ In case of parsing issues, exception with the necessary information will be thrown to console and extraction will be aborted. -* `--output-format` specifies format of generated `en.json` (if `--output` is provided). By default it is `json`. Use it only if you need a JSON5 file. +* `--output-format` specifies format of generated `en.json`. By default it is `json`. Use it only if you need a JSON5 file. +* `--include-config` specifies additional paths to `.i18nrc.json` files (may be useful for 3rd-party plugins) ### Output `/en.json` -The tool generates a JSON/JSON5 file only if `--output` path is provided. It contains injected `formats` object and `messages` object with `id: message` or `id: {text, comment}` pairs. Messages are sorted by id. +The generated JSON/JSON5 file contains `formats` object and `messages` object with `id: message` or `id: {text, comment}` pairs. Messages are sorted by id. **Example**: @@ -169,11 +170,12 @@ The tool generates a JSON/JSON5 file only if `--output` path is provided. It con } ``` -## Locale files verification / integration tool +## Locale files integration tool ### Description -The tool is used for verifying locale file, finding unused / missing messages, key duplications, grouping messages by namespaces and creating JSON files in right folders. +The tool is used for verifying locale file, finding unused / missing messages, key duplications and value references mismatches. If all these +checks are passing, the tool groups messages by namespaces and creates JSON files in right folders. ### Notes @@ -182,9 +184,22 @@ The tool throws an exception if `formats` object is missing in locale file. ### Usage ```bash -node scripts/i18n_integrate --path path/to/locale.json +node scripts/i18n_integrate --source path/to/locale.json --target x-pack/plugins/translations/translations/locale.json ``` +* `--source` path to the JSON file with translations that should be integrated. +* `--target` defines a single path to the JSON file where translations should be integrated to, path mappings from +[.i18nrc.json](../../../.i18nrc.json) are ignored in this case. It's currently used for integrating of Kibana built-in +translations that are located in a single JSON file within `x-pack/translations` plugin. +* `--dry-run` tells the tool to exit after verification phase and not write translations to the disk. +* `--ignore-incompatible` specifies whether tool should ignore incompatible translations. It may be useful when the code base you're +integrating translations to has changed and some default messages switched to ICU structure that is incompatible with the one used in corresponding translation. +* `--ignore-missing` specifies whether tool should ignore missing translations. It may be useful when the code base you're +integrating translations to has moved forward since the revision translations were created for. +* `--ignore-unused` specifies whether tool should ignore unused translations. It may be useful when the code base you're +integrating translations to has changed and some translations are not needed anymore. +* `--include-config` specifies additional paths to `.i18nrc.json` files (may be useful for 3rd-party plugins) + ### Output -The tool generates locale files in plugin folders and few other special locations based on namespaces and corresponding mappings defined in [.i18nrc.json](../../../.i18nrc.json). +Unless `--target` is specified, the tool generates locale files in plugin folders and few other special locations based on namespaces and corresponding mappings defined in [.i18nrc.json](../../../.i18nrc.json). diff --git a/src/dev/i18n/__snapshots__/integrate_locale_files.test.js.snap b/src/dev/i18n/__snapshots__/integrate_locale_files.test.ts.snap similarity index 88% rename from src/dev/i18n/__snapshots__/integrate_locale_files.test.js.snap rename to src/dev/i18n/__snapshots__/integrate_locale_files.test.ts.snap index f455387afe1e4..83ded0984459a 100644 --- a/src/dev/i18n/__snapshots__/integrate_locale_files.test.js.snap +++ b/src/dev/i18n/__snapshots__/integrate_locale_files.test.ts.snap @@ -142,22 +142,28 @@ Array [ ] `; -exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id and missing id 1`] = ` +exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 1`] = ` " -Missing translations: +1 missing translation(s): plugin-1.message-id-2" `; -exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id and missing id 2`] = ` +exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 2`] = ` " -Unused translations: +1 unused translation(s): plugin-1.message-id-3" `; -exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id and missing id 3`] = ` +exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 3`] = ` " -Unused translations: +1 unused translation(s): plugin-2.message -Missing translations: +1 missing translation(s): plugin-2.message-id" `; + +exports[`dev/i18n/integrate_locale_files verifyMessages throws an error for unused id, missing id or the incompatible ones 4`] = ` +" +Incompatible translation: some properties are missing in \\"values\\" object (\\"plugin-1.message-id-2\\"): [value]. +" +`; diff --git a/src/dev/i18n/__snapshots__/utils.test.js.snap b/src/dev/i18n/__snapshots__/utils.test.js.snap index 9b7a66d8353f2..b4e15304cca96 100644 --- a/src/dev/i18n/__snapshots__/utils.test.js.snap +++ b/src/dev/i18n/__snapshots__/utils.test.js.snap @@ -20,27 +20,12 @@ exports[`i18n utils should not escape linebreaks 1`] = ` exports[`i18n utils should parse string concatenation 1`] = `"Very long concatenated string"`; -exports[`i18n utils should throw if "values" has a value that is unused in the message 1`] = ` -"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): -[url]." -`; +exports[`i18n utils should throw if "values" has a value that is unused in the message 1`] = `"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): [url]."`; -exports[`i18n utils should throw if "values" property is not provided and defaultMessage requires it 1`] = ` -"some properties are missing in \\"values\\" object (\\"namespace.message.id\\"): -[username,password,url]." -`; +exports[`i18n utils should throw if "values" property is not provided and defaultMessage requires it 1`] = `"some properties are missing in \\"values\\" object (\\"namespace.message.id\\"): [username,password,url]."`; -exports[`i18n utils should throw if "values" property is provided and defaultMessage doesn't include any references 1`] = ` -"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): -[url,username]." -`; +exports[`i18n utils should throw if "values" property is provided and defaultMessage doesn't include any references 1`] = `"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): [url,username]."`; -exports[`i18n utils should throw if some key is missing in "values" 1`] = ` -"some properties are missing in \\"values\\" object (\\"namespace.message.id\\"): -[password]." -`; +exports[`i18n utils should throw if some key is missing in "values" 1`] = `"some properties are missing in \\"values\\" object (\\"namespace.message.id\\"): [password]."`; -exports[`i18n utils should throw on wrong nested ICU message 1`] = ` -"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): -[third]." -`; +exports[`i18n utils should throw on wrong nested ICU message 1`] = `"\\"values\\" object contains unused properties (\\"namespace.message.id\\"): [third]."`; diff --git a/src/dev/i18n/config.ts b/src/dev/i18n/config.ts new file mode 100644 index 0000000000000..0327e2ea1b688 --- /dev/null +++ b/src/dev/i18n/config.ts @@ -0,0 +1,93 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; + +// @ts-ignore +import { normalizePath, readFileAsync } from '.'; +// @ts-ignore +import rootConfig from '../../../.i18nrc.json'; + +export interface I18nConfig { + paths: Record; + exclude: string[]; + translations: string[]; +} + +/** + * Merges root .i18nrc.json config with any other additional configs (e.g. from + * third-party plugins). + * @param configPaths List of config paths. + */ +export async function mergeConfigs(configPaths: string | string[] = []) { + const mergedConfig: I18nConfig = { exclude: [], translations: [], ...rootConfig }; + + for (const configPath of Array.isArray(configPaths) ? configPaths : [configPaths]) { + const additionalConfig: I18nConfig = { + paths: {}, + exclude: [], + translations: [], + ...JSON.parse(await readFileAsync(resolve(configPath))), + }; + + for (const [namespace, path] of Object.entries(additionalConfig.paths)) { + mergedConfig.paths[namespace] = normalizePath(resolve(configPath, '..', path)); + } + + for (const exclude of additionalConfig.exclude) { + mergedConfig.exclude.push(normalizePath(resolve(configPath, '..', exclude))); + } + + for (const translations of additionalConfig.translations) { + mergedConfig.translations.push(normalizePath(resolve(configPath, '..', translations))); + } + } + + return mergedConfig; +} + +/** + * Filters out custom paths based on the paths defined in config and that are + * known to contain i18n strings. + * @param inputPaths List of paths to filter. + * @param config I18n config instance. + */ +export function filterConfigPaths(inputPaths: string[], config: I18nConfig) { + const availablePaths = Object.values(config.paths); + const pathsForExtraction = new Set(); + + for (const inputPath of inputPaths) { + const normalizedPath = normalizePath(inputPath); + + // If input path is the sub path of or equal to any available path, include it. + if ( + availablePaths.some(path => normalizedPath.startsWith(`${path}/`) || path === normalizedPath) + ) { + pathsForExtraction.add(normalizedPath); + } else { + // Otherwise go through all available paths and see if any of them is the sub + // path of the input path (empty normalized path corresponds to root or above). + availablePaths + .filter(path => !normalizedPath || path.startsWith(`${normalizedPath}/`)) + .forEach(ePath => pathsForExtraction.add(ePath)); + } + } + + return [...pathsForExtraction]; +} diff --git a/src/dev/i18n/constants.js b/src/dev/i18n/constants.ts similarity index 100% rename from src/dev/i18n/constants.js rename to src/dev/i18n/constants.ts diff --git a/src/dev/i18n/extract_default_translations.js b/src/dev/i18n/extract_default_translations.js index b0e3d60a6d7c5..d32c62413e4ea 100644 --- a/src/dev/i18n/extract_default_translations.js +++ b/src/dev/i18n/extract_default_translations.js @@ -42,30 +42,6 @@ function addMessageToMap(targetMap, key, value, reporter) { } } -export function filterPaths(inputPaths, paths) { - const availablePaths = Object.values(paths); - const pathsForExtraction = new Set(); - - for (const inputPath of inputPaths) { - const normalizedPath = normalizePath(inputPath); - - // If input path is the sub path of or equal to any available path, include it. - if ( - availablePaths.some(path => normalizedPath.startsWith(`${path}/`) || path === normalizedPath) - ) { - pathsForExtraction.add(normalizedPath); - } else { - // Otherwise go through all available paths and see if any of them is the sub - // path of the input path (empty normalized path corresponds to root or above). - availablePaths - .filter(path => !normalizedPath || path.startsWith(`${normalizedPath}/`)) - .forEach(ePath => pathsForExtraction.add(ePath)); - } - } - - return [...pathsForExtraction]; -} - function filterEntries(entries, exclude) { return entries.filter(entry => exclude.every(excludedPath => !normalizePath(entry).startsWith(excludedPath)) @@ -148,13 +124,3 @@ export async function extractMessagesFromPathToMap(inputPath, targetMap, config, }) ); } - -export async function getDefaultMessagesMap(inputPaths, config, reporter) { - const defaultMessagesMap = new Map(); - - for (const inputPath of filterPaths(inputPaths, config.paths)) { - await extractMessagesFromPathToMap(inputPath, defaultMessagesMap, config, reporter); - } - - return defaultMessagesMap; -} diff --git a/src/dev/i18n/extractors/react.js b/src/dev/i18n/extractors/react.js index 4e00309cb201b..c05f3d9524fb2 100644 --- a/src/dev/i18n/extractors/react.js +++ b/src/dev/i18n/extractors/react.js @@ -57,7 +57,7 @@ export function extractIntlMessages(node) { : undefined; if (!messageId) { - createFailError(`Empty "id" value in intl.formatMessage() is not allowed.`); + throw createFailError(`Empty "id" value in intl.formatMessage() is not allowed.`); } const message = messageProperty diff --git a/src/dev/i18n/index.js b/src/dev/i18n/index.ts similarity index 79% rename from src/dev/i18n/index.js rename to src/dev/i18n/index.ts index 832c09813bb9a..604d7bab72c92 100644 --- a/src/dev/i18n/index.js +++ b/src/dev/i18n/index.ts @@ -17,6 +17,10 @@ * under the License. */ -export { filterPaths, extractMessagesFromPathToMap } from './extract_default_translations'; +// @ts-ignore +export { extractMessagesFromPathToMap } from './extract_default_translations'; +// @ts-ignore export { writeFileAsync, readFileAsync, normalizePath, ErrorReporter } from './utils'; export { serializeToJson, serializeToJson5 } from './serializers'; +export { I18nConfig, filterConfigPaths, mergeConfigs } from './config'; +export { integrateLocaleFiles } from './integrate_locale_files'; diff --git a/src/dev/i18n/integrate_locale_files.js b/src/dev/i18n/integrate_locale_files.js deleted file mode 100644 index fa06f6399f460..0000000000000 --- a/src/dev/i18n/integrate_locale_files.js +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import path from 'path'; - -import { - difference, - readFileAsync, - writeFileAsync, - accessAsync, - makeDirAsync, - normalizePath, - ErrorReporter, -} from './utils'; -import { paths, exclude } from '../../../.i18nrc.json'; -import { getDefaultMessagesMap } from './extract_default_translations'; -import { createFailError } from '../run'; -import { serializeToJson } from './serializers/json'; - -export function verifyMessages(localizedMessagesMap, defaultMessagesMap) { - let errorMessage = ''; - - const defaultMessagesIds = [...defaultMessagesMap.keys()]; - const localizedMessagesIds = [...localizedMessagesMap.keys()]; - - const unusedTranslations = difference(localizedMessagesIds, defaultMessagesIds); - if (unusedTranslations.length > 0) { - errorMessage += `\nUnused translations:\n${unusedTranslations.join(', ')}`; - } - - const missingTranslations = difference(defaultMessagesIds, localizedMessagesIds); - if (missingTranslations.length > 0) { - errorMessage += `\nMissing translations:\n${missingTranslations.join(', ')}`; - } - - if (errorMessage) { - throw createFailError(errorMessage); - } -} - -function groupMessagesByNamespace(localizedMessagesMap) { - const localizedMessagesByNamespace = new Map(); - const knownNamespaces = Object.keys(paths); - - for (const [messageId, messageValue] of localizedMessagesMap) { - const namespace = knownNamespaces.find(key => messageId.startsWith(`${key}.`)); - - if (!namespace) { - throw createFailError(`Unknown namespace in id ${messageId}.`); - } - - if (!localizedMessagesByNamespace.has(namespace)) { - localizedMessagesByNamespace.set(namespace, []); - } - - localizedMessagesByNamespace - .get(namespace) - .push([messageId, { message: messageValue.text || messageValue }]); - } - - return localizedMessagesByNamespace; -} - -async function writeMessages(localizedMessagesByNamespace, fileName, formats, log) { - for (const [namespace, messages] of localizedMessagesByNamespace) { - const destPath = path.resolve(paths[namespace], 'translations'); - - try { - await accessAsync(destPath); - } catch (_) { - await makeDirAsync(destPath); - } - - const writePath = path.resolve(destPath, fileName); - await writeFileAsync(writePath, serializeToJson(messages, formats)); - log.success(`Translations have been integrated to ${normalizePath(writePath)}`); - } -} - -export async function integrateLocaleFiles(filePath, log) { - const reporter = new ErrorReporter(); - const defaultMessagesMap = await getDefaultMessagesMap(['.'], { paths, exclude }, reporter); - const localizedMessages = JSON.parse((await readFileAsync(filePath)).toString()); - - if (!localizedMessages.formats) { - throw createFailError(`Locale file should contain formats object.`); - } - - const localizedMessagesMap = new Map(Object.entries(localizedMessages.messages)); - verifyMessages(localizedMessagesMap, defaultMessagesMap); - - // use basename of filePath to write the same locale name as the source file has - const fileName = path.basename(filePath); - const localizedMessagesByNamespace = groupMessagesByNamespace(localizedMessagesMap); - await writeMessages(localizedMessagesByNamespace, fileName, localizedMessages.formats, log); -} diff --git a/src/dev/i18n/integrate_locale_files.test.js b/src/dev/i18n/integrate_locale_files.test.js deleted file mode 100644 index 04f7b607192c0..0000000000000 --- a/src/dev/i18n/integrate_locale_files.test.js +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import path from 'path'; - -import { verifyMessages, integrateLocaleFiles } from './integrate_locale_files'; -import { normalizePath } from './utils'; - -const localePath = path.resolve(__dirname, '__fixtures__', 'integrate_locale_files', 'fr.json'); - -const mockDefaultMessagesMap = new Map([ - ['plugin-1.message-id-1', 'Message text 1'], - ['plugin-1.message-id-2', 'Message text 2'], - ['plugin-2.message-id', 'Message text'], -]); - -jest.mock('./extract_default_translations.js', () => ({ - getDefaultMessagesMap: () => mockDefaultMessagesMap, -})); - -jest.mock('../../../.i18nrc.json', () => ({ - paths: { - 'plugin-1': 'src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_1', - 'plugin-2': 'src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_2', - }, - exclude: [], -})); - -const utils = require('./utils'); -utils.writeFileAsync = jest.fn(); -utils.makeDirAsync = jest.fn(); - -describe('dev/i18n/integrate_locale_files', () => { - describe('verifyMessages', () => { - test('validates localized messages', () => { - const localizedMessagesMap = new Map([ - ['plugin-1.message-id-1', 'Translated text 1'], - ['plugin-1.message-id-2', 'Translated text 2'], - ['plugin-2.message-id', 'Translated text'], - ]); - - expect(() => verifyMessages(localizedMessagesMap, mockDefaultMessagesMap)).not.toThrow(); - }); - - test('throws an error for unused id and missing id', () => { - const localizedMessagesMapWithMissingMessage = new Map([ - ['plugin-1.message-id-1', 'Translated text 1'], - ['plugin-2.message-id', 'Translated text'], - ]); - - const localizedMessagesMapWithUnusedMessage = new Map([ - ['plugin-1.message-id-1', 'Translated text 1'], - ['plugin-1.message-id-2', 'Translated text 2'], - ['plugin-1.message-id-3', 'Translated text 3'], - ['plugin-2.message-id', 'Translated text'], - ]); - - const localizedMessagesMapWithIdTypo = new Map([ - ['plugin-1.message-id-1', 'Message text 1'], - ['plugin-1.message-id-2', 'Message text 2'], - ['plugin-2.message', 'Message text'], - ]); - - expect(() => - verifyMessages(localizedMessagesMapWithMissingMessage, mockDefaultMessagesMap) - ).toThrowErrorMatchingSnapshot(); - expect(() => - verifyMessages(localizedMessagesMapWithUnusedMessage, mockDefaultMessagesMap) - ).toThrowErrorMatchingSnapshot(); - expect(() => - verifyMessages(localizedMessagesMapWithIdTypo, mockDefaultMessagesMap) - ).toThrowErrorMatchingSnapshot(); - }); - }); - - describe('integrateLocaleFiles', () => { - test('splits locale file by plugins and writes them into the right folders', async () => { - const success = jest.fn(); - await integrateLocaleFiles(localePath, { success }); - - const [[path1, json1], [path2, json2]] = utils.writeFileAsync.mock.calls; - const [[dirPath1], [dirPath2]] = utils.makeDirAsync.mock.calls; - - expect([normalizePath(path1), json1]).toMatchSnapshot(); - expect([normalizePath(path2), json2]).toMatchSnapshot(); - expect([normalizePath(dirPath1), normalizePath(dirPath2)]).toMatchSnapshot(); - }); - }); -}); diff --git a/src/dev/i18n/integrate_locale_files.test.ts b/src/dev/i18n/integrate_locale_files.test.ts new file mode 100644 index 0000000000000..050f16f3d3344 --- /dev/null +++ b/src/dev/i18n/integrate_locale_files.test.ts @@ -0,0 +1,184 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockWriteFileAsync = jest.fn(); +const mockMakeDirAsync = jest.fn(); +jest.mock('./utils', () => ({ + // Jest typings don't define `requireActual` for some reason. + ...(jest as any).requireActual('./utils'), + writeFileAsync: mockWriteFileAsync, + makeDirAsync: mockMakeDirAsync, +})); + +import path from 'path'; +import { integrateLocaleFiles, verifyMessages } from './integrate_locale_files'; +// @ts-ignore +import { normalizePath } from './utils'; + +const localePath = path.resolve(__dirname, '__fixtures__', 'integrate_locale_files', 'fr.json'); + +const mockDefaultMessagesMap = new Map([ + ['plugin-1.message-id-1', { message: 'Message text 1' }], + ['plugin-1.message-id-2', { message: 'Message text 2' }], + ['plugin-2.message-id', { message: 'Message text' }], +]); + +const defaultIntegrateOptions = { + sourceFileName: localePath, + dryRun: false, + ignoreIncompatible: false, + ignoreMissing: false, + ignoreUnused: false, + config: { + paths: { + 'plugin-1': 'src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_1', + 'plugin-2': 'src/dev/i18n/__fixtures__/integrate_locale_files/test_plugin_2', + }, + exclude: [], + translations: [], + }, + log: { success: jest.fn(), warning: jest.fn() } as any, +}; + +describe('dev/i18n/integrate_locale_files', () => { + describe('verifyMessages', () => { + test('validates localized messages', () => { + const localizedMessagesMap = new Map([ + ['plugin-1.message-id-1', 'Translated text 1'], + ['plugin-1.message-id-2', 'Translated text 2'], + ['plugin-2.message-id', 'Translated text'], + ]); + + expect(() => + verifyMessages(localizedMessagesMap, mockDefaultMessagesMap, defaultIntegrateOptions) + ).not.toThrow(); + }); + + test('throws an error for unused id, missing id or the incompatible ones', () => { + const localizedMessagesMapWithMissingMessage = new Map([ + ['plugin-1.message-id-1', 'Translated text 1'], + ['plugin-2.message-id', 'Translated text'], + ]); + + const localizedMessagesMapWithUnusedMessage = new Map([ + ['plugin-1.message-id-1', 'Translated text 1'], + ['plugin-1.message-id-2', 'Translated text 2'], + ['plugin-1.message-id-3', 'Translated text 3'], + ['plugin-2.message-id', 'Translated text'], + ]); + + const localizedMessagesMapWithIdTypo = new Map([ + ['plugin-1.message-id-1', 'Message text 1'], + ['plugin-1.message-id-2', 'Message text 2'], + ['plugin-2.message', 'Message text'], + ]); + + const localizedMessagesMapWithUnknownValues = new Map([ + ['plugin-1.message-id-1', 'Translated text 1'], + ['plugin-1.message-id-2', 'Translated text 2 with some unknown {value}'], + ['plugin-2.message-id', 'Translated text'], + ]); + + expect(() => + verifyMessages( + localizedMessagesMapWithMissingMessage, + mockDefaultMessagesMap, + defaultIntegrateOptions + ) + ).toThrowErrorMatchingSnapshot(); + expect(() => + verifyMessages( + localizedMessagesMapWithUnusedMessage, + mockDefaultMessagesMap, + defaultIntegrateOptions + ) + ).toThrowErrorMatchingSnapshot(); + expect(() => + verifyMessages( + localizedMessagesMapWithIdTypo, + mockDefaultMessagesMap, + defaultIntegrateOptions + ) + ).toThrowErrorMatchingSnapshot(); + expect(() => + verifyMessages( + localizedMessagesMapWithUnknownValues, + mockDefaultMessagesMap, + defaultIntegrateOptions + ) + ).toThrowErrorMatchingSnapshot(); + }); + + test('removes unused ids if `ignoreUnused` is set', () => { + const localizedMessagesMapWithUnusedMessage = new Map([ + ['plugin-1.message-id-1', 'Translated text 1'], + ['plugin-1.message-id-2', 'Translated text 2'], + ['plugin-1.message-id-3', 'Some old translated text 3'], + ['plugin-2.message-id', 'Translated text'], + ['plugin-2.message', 'Some old translated text'], + ]); + + verifyMessages(localizedMessagesMapWithUnusedMessage, mockDefaultMessagesMap, { + ...defaultIntegrateOptions, + ignoreUnused: true, + }); + + expect(localizedMessagesMapWithUnusedMessage).toMatchInlineSnapshot(` +Map { + "plugin-1.message-id-1" => "Translated text 1", + "plugin-1.message-id-2" => "Translated text 2", + "plugin-2.message-id" => "Translated text", +} +`); + }); + + test('removes ids with incompatible ICU structure if `ignoreIncompatible` is set', () => { + const localizedMessagesMapWithIncompatibleMessage = new Map([ + ['plugin-1.message-id-1', 'Translated text 1'], + ['plugin-1.message-id-2', 'Translated text 2 with some unknown {value}'], + ['plugin-2.message-id', 'Translated text'], + ]); + + verifyMessages(localizedMessagesMapWithIncompatibleMessage, mockDefaultMessagesMap, { + ...defaultIntegrateOptions, + ignoreIncompatible: true, + }); + + expect(localizedMessagesMapWithIncompatibleMessage).toMatchInlineSnapshot(` +Map { + "plugin-1.message-id-1" => "Translated text 1", + "plugin-2.message-id" => "Translated text", +} +`); + }); + }); + + describe('integrateLocaleFiles', () => { + test('splits locale file by plugins and writes them into the right folders', async () => { + await integrateLocaleFiles(mockDefaultMessagesMap, defaultIntegrateOptions); + + const [[path1, json1], [path2, json2]] = mockWriteFileAsync.mock.calls; + const [[dirPath1], [dirPath2]] = mockMakeDirAsync.mock.calls; + + expect([normalizePath(path1), json1]).toMatchSnapshot(); + expect([normalizePath(path2), json2]).toMatchSnapshot(); + expect([normalizePath(dirPath1), normalizePath(dirPath2)]).toMatchSnapshot(); + }); + }); +}); diff --git a/src/dev/i18n/integrate_locale_files.ts b/src/dev/i18n/integrate_locale_files.ts new file mode 100644 index 0000000000000..b090eedce443d --- /dev/null +++ b/src/dev/i18n/integrate_locale_files.ts @@ -0,0 +1,200 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ToolingLog } from '@kbn/dev-utils'; +import { i18n } from '@kbn/i18n'; +import path from 'path'; + +import { + accessAsync, + checkValuesProperty, + difference, + extractValueReferencesFromMessage, + makeDirAsync, + normalizePath, + readFileAsync, + writeFileAsync, + // @ts-ignore +} from './utils'; + +import { createFailError } from '../run'; +import { I18nConfig } from './config'; +import { serializeToJson } from './serializers'; + +interface IntegrateOptions { + sourceFileName: string; + targetFileName?: string; + dryRun: boolean; + ignoreIncompatible: boolean; + ignoreUnused: boolean; + ignoreMissing: boolean; + config: I18nConfig; + log: ToolingLog; +} + +type MessageMap = Map; +type GroupedMessageMap = Map>; +type LocalizedMessageMap = Map; + +export function verifyMessages( + localizedMessagesMap: LocalizedMessageMap, + defaultMessagesMap: MessageMap, + options: IntegrateOptions +) { + let errorMessage = ''; + + const defaultMessagesIds = [...defaultMessagesMap.keys()]; + const localizedMessagesIds = [...localizedMessagesMap.keys()]; + + const unusedTranslations = difference(localizedMessagesIds, defaultMessagesIds); + if (unusedTranslations.length > 0) { + if (!options.ignoreUnused) { + errorMessage += `\n${ + unusedTranslations.length + } unused translation(s):\n${unusedTranslations.join(', ')}`; + } else { + for (const unusedTranslationId of unusedTranslations) { + localizedMessagesMap.delete(unusedTranslationId); + } + } + } + + if (!options.ignoreMissing) { + const missingTranslations = difference(defaultMessagesIds, localizedMessagesIds); + if (missingTranslations.length > 0) { + errorMessage += `\n${ + missingTranslations.length + } missing translation(s):\n${missingTranslations.join(', ')}`; + } + } + + for (const messageId of localizedMessagesIds) { + const defaultMessage = defaultMessagesMap.get(messageId); + if (defaultMessage) { + try { + const message = localizedMessagesMap.get(messageId)!; + checkValuesProperty( + extractValueReferencesFromMessage(defaultMessage.message, messageId), + typeof message === 'string' ? message : message.text, + messageId + ); + } catch (err) { + if (options.ignoreIncompatible) { + localizedMessagesMap.delete(messageId); + options.log.warning(`Incompatible translation ignored: ${err.message}`); + } else { + errorMessage += `\nIncompatible translation: ${err.message}\n`; + } + } + } + } + + if (errorMessage) { + throw createFailError(errorMessage); + } +} + +function groupMessagesByNamespace( + localizedMessagesMap: LocalizedMessageMap, + knownNamespaces: string[] +) { + const localizedMessagesByNamespace = new Map(); + for (const [messageId, messageValue] of localizedMessagesMap) { + const namespace = knownNamespaces.find(key => messageId.startsWith(`${key}.`)); + if (!namespace) { + throw createFailError(`Unknown namespace in id ${messageId}.`); + } + + if (!localizedMessagesByNamespace.has(namespace)) { + localizedMessagesByNamespace.set(namespace, []); + } + + localizedMessagesByNamespace + .get(namespace) + .push([ + messageId, + { message: typeof messageValue === 'string' ? messageValue : messageValue.text }, + ]); + } + + return localizedMessagesByNamespace; +} + +async function writeMessages( + localizedMessagesByNamespace: GroupedMessageMap, + formats: typeof i18n.formats, + options: IntegrateOptions +) { + // If target file name is specified we need to write all the translations into one file, + // irrespective to the namespace. + if (options.targetFileName) { + await writeFileAsync( + options.targetFileName, + serializeToJson( + [...localizedMessagesByNamespace.values()].reduce((acc, val) => acc.concat(val), []), + formats + ) + ); + + return options.log.success( + `Translations have been integrated to ${normalizePath(options.targetFileName)}` + ); + } + + // Use basename of source file name to write the same locale name as the source file has. + const fileName = path.basename(options.sourceFileName); + for (const [namespace, messages] of localizedMessagesByNamespace) { + const destPath = path.resolve(options.config.paths[namespace], 'translations'); + + try { + await accessAsync(destPath); + } catch (_) { + await makeDirAsync(destPath); + } + + const writePath = path.resolve(destPath, fileName); + await writeFileAsync(writePath, serializeToJson(messages, formats)); + options.log.success(`Translations have been integrated to ${normalizePath(writePath)}`); + } +} + +export async function integrateLocaleFiles( + defaultMessagesMap: MessageMap, + options: IntegrateOptions +) { + const localizedMessages = JSON.parse((await readFileAsync(options.sourceFileName)).toString()); + if (!localizedMessages.formats) { + throw createFailError(`Locale file should contain formats object.`); + } + + const localizedMessagesMap: LocalizedMessageMap = new Map( + Object.entries(localizedMessages.messages) + ); + verifyMessages(localizedMessagesMap, defaultMessagesMap, options); + + const knownNamespaces = Object.keys(options.config.paths); + const groupedLocalizedMessagesMap = groupMessagesByNamespace( + localizedMessagesMap, + knownNamespaces + ); + + if (!options.dryRun) { + await writeMessages(groupedLocalizedMessagesMap, localizedMessages.formats, options); + } +} diff --git a/src/dev/i18n/serializers/__snapshots__/json.test.js.snap b/src/dev/i18n/serializers/__snapshots__/json.test.ts.snap similarity index 100% rename from src/dev/i18n/serializers/__snapshots__/json.test.js.snap rename to src/dev/i18n/serializers/__snapshots__/json.test.ts.snap diff --git a/src/dev/i18n/serializers/__snapshots__/json5.test.js.snap b/src/dev/i18n/serializers/__snapshots__/json5.test.ts.snap similarity index 100% rename from src/dev/i18n/serializers/__snapshots__/json5.test.js.snap rename to src/dev/i18n/serializers/__snapshots__/json5.test.ts.snap diff --git a/src/dev/i18n/serializers/index.ts b/src/dev/i18n/serializers/index.ts new file mode 100644 index 0000000000000..94c0a786c7449 --- /dev/null +++ b/src/dev/i18n/serializers/index.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; + +export { serializeToJson } from './json'; +export { serializeToJson5 } from './json5'; + +export type Serializer = ( + messages: Array<[string, { message: string; description?: string }]>, + formats?: typeof i18n.formats +) => string; diff --git a/src/dev/i18n/serializers/json.test.js b/src/dev/i18n/serializers/json.test.ts similarity index 93% rename from src/dev/i18n/serializers/json.test.js rename to src/dev/i18n/serializers/json.test.ts index b910b3b9fbcc3..fa817adbe65f7 100644 --- a/src/dev/i18n/serializers/json.test.js +++ b/src/dev/i18n/serializers/json.test.ts @@ -21,7 +21,7 @@ import { serializeToJson } from './json'; describe('dev/i18n/serializers/json', () => { test('should serialize default messages to JSON', () => { - const messages = [ + const messages: Array<[string, { message: string; description?: string }]> = [ ['plugin1.message.id-1', { message: 'Message text 1 ' }], [ 'plugin2.message.id-2', diff --git a/src/dev/i18n/serializers/json.js b/src/dev/i18n/serializers/json.ts similarity index 82% rename from src/dev/i18n/serializers/json.js rename to src/dev/i18n/serializers/json.ts index edf51e66ac651..b2b3985778fb1 100644 --- a/src/dev/i18n/serializers/json.js +++ b/src/dev/i18n/serializers/json.ts @@ -18,9 +18,13 @@ */ import { i18n } from '@kbn/i18n'; +import { Serializer } from '.'; -export function serializeToJson(messages, formats = i18n.formats) { - const resultJsonObject = { formats, messages: {} }; +export const serializeToJson: Serializer = (messages, formats = i18n.formats) => { + const resultJsonObject = { + formats, + messages: {} as Record, + }; for (const [mapKey, mapValue] of messages) { if (mapValue.description) { @@ -31,4 +35,4 @@ export function serializeToJson(messages, formats = i18n.formats) { } return JSON.stringify(resultJsonObject, undefined, 2); -} +}; diff --git a/src/dev/i18n/serializers/json5.test.js b/src/dev/i18n/serializers/json5.test.ts similarity index 93% rename from src/dev/i18n/serializers/json5.test.js rename to src/dev/i18n/serializers/json5.test.ts index 6c5ece278989d..ccaa567dd2951 100644 --- a/src/dev/i18n/serializers/json5.test.js +++ b/src/dev/i18n/serializers/json5.test.ts @@ -21,7 +21,7 @@ import { serializeToJson5 } from './json5'; describe('dev/i18n/serializers/json5', () => { test('should serialize default messages to JSON5', () => { - const messages = [ + const messages: Array<[string, { message: string; description?: string }]> = [ [ 'plugin1.message.id-1', { diff --git a/src/dev/i18n/serializers/json5.js b/src/dev/i18n/serializers/json5.ts similarity index 92% rename from src/dev/i18n/serializers/json5.js rename to src/dev/i18n/serializers/json5.ts index 1af7c56d40676..5b09764ce4d9b 100644 --- a/src/dev/i18n/serializers/json5.js +++ b/src/dev/i18n/serializers/json5.ts @@ -17,12 +17,13 @@ * under the License. */ -import JSON5 from 'json5'; import { i18n } from '@kbn/i18n'; +import JSON5 from 'json5'; +import { Serializer } from '.'; const ESCAPE_SINGLE_QUOTE_REGEX = /\\([\s\S])|(')/g; -export function serializeToJson5(messages, formats = i18n.formats) { +export const serializeToJson5: Serializer = (messages, formats = i18n.formats) => { // .slice(0, -4): remove closing curly braces from json to append messages let jsonBuffer = Buffer.from( JSON5.stringify({ formats, messages: {} }, { quote: `'`, space: 2 }) @@ -46,5 +47,5 @@ export function serializeToJson5(messages, formats = i18n.formats) { // append previously removed closing curly braces jsonBuffer = Buffer.concat([jsonBuffer, Buffer.from(' },\n}\n')]); - return jsonBuffer; -} + return jsonBuffer.toString(); +}; diff --git a/src/dev/i18n/tasks/extract_default_translations.ts b/src/dev/i18n/tasks/extract_default_translations.ts new file mode 100644 index 0000000000000..02e45350e249a --- /dev/null +++ b/src/dev/i18n/tasks/extract_default_translations.ts @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; +import Listr from 'listr'; + +import { ErrorReporter, extractMessagesFromPathToMap, filterConfigPaths, I18nConfig } from '..'; +import { createFailError } from '../../run'; + +export async function extractDefaultMessages({ + path, + config, +}: { + path?: string | string[]; + config: I18nConfig; +}) { + const filteredPaths = filterConfigPaths(Array.isArray(path) ? path : [path || './'], config); + if (filteredPaths.length === 0) { + throw createFailError( + `${chalk.white.bgRed( + ' I18N ERROR ' + )} None of input paths is covered by the mappings in .i18nrc.json.` + ); + } + + const reporter = new ErrorReporter(); + + const list = new Listr( + filteredPaths.map(filteredPath => ({ + task: async (messages: Map) => { + const initialErrorsNumber = reporter.errors.length; + + // Return result if no new errors were reported for this path. + const result = await extractMessagesFromPathToMap(filteredPath, messages, config, reporter); + if (reporter.errors.length === initialErrorsNumber) { + return result; + } + + // Throw an empty error to make Listr mark the task as failed without any message. + throw new Error(''); + }, + title: filteredPath, + })), + { + exitOnError: false, + } + ); + + try { + return await list.run(new Map()); + } catch (error) { + if (error.name === 'ListrError' && reporter.errors.length) { + throw createFailError(reporter.errors.join('\n\n')); + } + + throw error; + } +} diff --git a/src/dev/i18n/tasks/index.ts b/src/dev/i18n/tasks/index.ts new file mode 100644 index 0000000000000..00b3466d59276 --- /dev/null +++ b/src/dev/i18n/tasks/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { extractDefaultMessages } from './extract_default_translations'; diff --git a/src/dev/i18n/utils.js b/src/dev/i18n/utils.js index 922a774d50934..a0022915c1476 100644 --- a/src/dev/i18n/utils.js +++ b/src/dev/i18n/utils.js @@ -177,8 +177,10 @@ function extractValueReferencesFromIcuAst(node, keys = new Set()) { * @throws if "values" and "defaultMessage" don't correspond to each other */ export function checkValuesProperty(prefixedValuesKeys, defaultMessage, messageId) { - // skip validation if defaultMessage doesn't use ICU and values prop has no keys - if (!prefixedValuesKeys.length && !defaultMessage.includes('{')) { + // Skip validation if `defaultMessage` doesn't include any ICU values and + // `values` prop has no keys. + const defaultMessageValueReferences = extractValueReferencesFromMessage(defaultMessage, messageId); + if (!prefixedValuesKeys.length && defaultMessageValueReferences.length === 0) { return; } @@ -186,13 +188,39 @@ export function checkValuesProperty(prefixedValuesKeys, defaultMessage, messageI key.startsWith(HTML_KEY_PREFIX) ? key.slice(HTML_KEY_PREFIX.length) : key ); - let defaultMessageAst; + const missingValuesKeys = difference(defaultMessageValueReferences, valuesKeys); + if (missingValuesKeys.length) { + throw createFailError( + `some properties are missing in "values" object ("${messageId}"): [${missingValuesKeys}].` + ); + } + const unusedValuesKeys = difference(valuesKeys, defaultMessageValueReferences); + if (unusedValuesKeys.length) { + throw createFailError( + `"values" object contains unused properties ("${messageId}"): [${unusedValuesKeys}].` + ); + } +} + +/** + * Extracts value references from the ICU message. + * @param message ICU message. + * @param messageId ICU message id + * @returns {string[]} + */ +export function extractValueReferencesFromMessage(message, messageId) { + // Skip validation if message doesn't use ICU. + if (!message.includes('{')) { + return []; + } + + let messageAST; try { - defaultMessageAst = parser.parse(defaultMessage); + messageAST = parser.parse(message); } catch (error) { if (error.name === 'SyntaxError') { - const errorWithContext = createParserErrorMessage(defaultMessage, { + const errorWithContext = createParserErrorMessage(message, { loc: { line: error.location.start.line, column: error.location.start.column - 1, @@ -208,26 +236,12 @@ export function checkValuesProperty(prefixedValuesKeys, defaultMessage, messageI throw error; } - // skip validation if intl-messageformat-parser didn't return an AST with nonempty elements array - if (!defaultMessageAst || !defaultMessageAst.elements || !defaultMessageAst.elements.length) { - return; - } - - const defaultMessageValueReferences = extractValueReferencesFromIcuAst(defaultMessageAst); - - const missingValuesKeys = difference(defaultMessageValueReferences, valuesKeys); - if (missingValuesKeys.length) { - throw createFailError( - `some properties are missing in "values" object ("${messageId}"):\n[${missingValuesKeys}].` - ); + // Skip extraction if intl-messageformat-parser didn't return an AST with nonempty elements array. + if (!messageAST || !messageAST.elements || !messageAST.elements.length) { + return []; } - const unusedValuesKeys = difference(valuesKeys, defaultMessageValueReferences); - if (unusedValuesKeys.length) { - throw createFailError( - `"values" object contains unused properties ("${messageId}"):\n[${unusedValuesKeys}].` - ); - } + return extractValueReferencesFromIcuAst(messageAST); } export function extractMessageIdFromNode(node) { diff --git a/src/dev/run/index.d.ts b/src/dev/run/index.d.ts index 8d809f0e94d3b..8f49f5a84f3fd 100644 --- a/src/dev/run/index.d.ts +++ b/src/dev/run/index.d.ts @@ -17,4 +17,9 @@ * under the License. */ +import { ToolingLog } from '@kbn/dev-utils'; + export function createFailError(msg: string, exitCode?: number): Error; +export function run( + body: (args: { flags: Record; log: ToolingLog }) => void +): Promise; diff --git a/src/dev/run_i18n_check.js b/src/dev/run_i18n_check.js deleted file mode 100644 index f0933d9e25caf..0000000000000 --- a/src/dev/run_i18n_check.js +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import chalk from 'chalk'; -import Listr from 'listr'; -import { resolve } from 'path'; - -import { run, createFailError } from './run'; -import config from '../../.i18nrc.json'; -import { - filterPaths, - extractMessagesFromPathToMap, - writeFileAsync, - readFileAsync, - serializeToJson, - serializeToJson5, - ErrorReporter, - normalizePath, -} from './i18n/'; - -run(async ({ flags: { path, output, 'output-format': outputFormat, include = [] } }) => { - const paths = Array.isArray(path) ? path : [path || './']; - const additionalI18nConfigPaths = Array.isArray(include) ? include : [include]; - const mergedConfig = { exclude: [], ...config }; - - for (const configPath of additionalI18nConfigPaths) { - const additionalConfig = JSON.parse(await readFileAsync(resolve(configPath))); - - for (const [pathNamespace, pathValue] of Object.entries(additionalConfig.paths)) { - mergedConfig.paths[pathNamespace] = normalizePath(resolve(configPath, '..', pathValue)); - } - - for (const exclude of additionalConfig.exclude || []) { - mergedConfig.exclude.push(normalizePath(resolve(configPath, '..', exclude))); - } - } - - const filteredPaths = filterPaths(paths, mergedConfig.paths); - - if (filteredPaths.length === 0) { - throw createFailError( - `${chalk.white.bgRed(' I18N ERROR ')} \ -None of input paths is available for extraction or validation. See .i18nrc.json.` - ); - } - - const reporter = new ErrorReporter(); - - const list = new Listr( - filteredPaths.map(filteredPath => ({ - task: async messages => { - const initialErrorsNumber = reporter.errors.length; - - // Return result if no new errors were reported for this path. - const result = await extractMessagesFromPathToMap( - filteredPath, - messages, - mergedConfig, - reporter - ); - if (reporter.errors.length === initialErrorsNumber) { - return result; - } - - // throw an empty error to make listr mark the task as failed without any message - throw new Error(''); - }, - title: filteredPath, - })), - { - exitOnError: false, - } - ); - - try { - // messages shouldn't be extracted to a file if output is not supplied - const messages = await list.run(new Map()); - if (!output || !messages.size) { - return; - } - - const sortedMessages = [...messages].sort(([key1], [key2]) => key1.localeCompare(key2)); - await writeFileAsync( - resolve(output, 'en.json'), - outputFormat === 'json5' ? serializeToJson5(sortedMessages) : serializeToJson(sortedMessages) - ); - } catch (error) { - if (error.name === 'ListrError' && reporter.errors.length) { - throw createFailError(reporter.errors.join('\n\n')); - } - - throw error; - } -}); diff --git a/src/dev/run_i18n_check.ts b/src/dev/run_i18n_check.ts new file mode 100644 index 0000000000000..28d518f008cfb --- /dev/null +++ b/src/dev/run_i18n_check.ts @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; +import Listr from 'listr'; + +import { integrateLocaleFiles, mergeConfigs } from './i18n'; +import { extractDefaultMessages } from './i18n/tasks'; +import { createFailError, run } from './run'; + +run( + async ({ + flags: { + 'ignore-incompatible': ignoreIncompatible, + 'ignore-missing': ignoreMissing, + 'ignore-unused': ignoreUnused, + 'include-config': includeConfig, + fix = false, + path, + }, + log, + }) => { + if ( + fix && + (ignoreIncompatible !== undefined || + ignoreUnused !== undefined || + ignoreMissing !== undefined) + ) { + throw createFailError( + `${chalk.white.bgRed( + ' I18N ERROR ' + )} none of the --ignore-incompatible, --ignore-unused or --ignore-missing is allowed when --fix is set.` + ); + } + + const config = await mergeConfigs(includeConfig); + const defaultMessages = await extractDefaultMessages({ path, config }); + + if (config.translations.length === 0) { + return; + } + + const list = new Listr( + config.translations.map(translationsPath => ({ + task: async () => { + // If `--fix` is set we should try apply all possible fixes and override translations file. + await integrateLocaleFiles(defaultMessages, { + sourceFileName: translationsPath, + targetFileName: fix ? translationsPath : undefined, + dryRun: !fix, + ignoreIncompatible: fix || !!ignoreIncompatible, + ignoreUnused: fix || !!ignoreUnused, + ignoreMissing: fix || !!ignoreMissing, + config, + log, + }); + }, + title: `Compatibility check with ${translationsPath}`, + })), + { + concurrent: true, + exitOnError: false, + } + ); + + try { + await list.run(); + } catch (error) { + process.exitCode = 1; + + if (!error.errors) { + log.error('Unhandled exception!'); + log.error(error); + process.exit(); + } + + for (const e of error.errors) { + log.error(e); + } + } + } +); diff --git a/src/dev/run_i18n_extract.ts b/src/dev/run_i18n_extract.ts new file mode 100644 index 0000000000000..0610674a70e1c --- /dev/null +++ b/src/dev/run_i18n_extract.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; +import { resolve } from 'path'; + +import { mergeConfigs, serializeToJson, serializeToJson5, writeFileAsync } from './i18n'; +import { extractDefaultMessages } from './i18n/tasks'; +import { createFailError, run } from './run'; + +run( + async ({ + flags: { + path, + 'output-dir': outputDir, + 'output-format': outputFormat, + 'include-config': includeConfig, + }, + }) => { + if (!outputDir || typeof outputDir !== 'string') { + throw createFailError( + `${chalk.white.bgRed(' I18N ERROR ')} --output-dir option should be specified.` + ); + } + + const config = await mergeConfigs(includeConfig); + const defaultMessages = await extractDefaultMessages({ path, config }); + + // Messages shouldn't be written to a file if output is not supplied. + if (!outputDir || !defaultMessages.size) { + return; + } + + const sortedMessages = [...defaultMessages].sort(([key1], [key2]) => key1.localeCompare(key2)); + await writeFileAsync( + resolve(outputDir, 'en.json'), + outputFormat === 'json5' ? serializeToJson5(sortedMessages) : serializeToJson(sortedMessages) + ); + } +); diff --git a/src/dev/run_i18n_integrate.js b/src/dev/run_i18n_integrate.js deleted file mode 100644 index e81d37d5c1bc6..0000000000000 --- a/src/dev/run_i18n_integrate.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import chalk from 'chalk'; - -import { createFailError, run } from './run'; -import { integrateLocaleFiles } from './i18n/integrate_locale_files'; - -run(async ({ flags: { path }, log }) => { - if (!path || typeof path === 'boolean') { - throw createFailError(`${chalk.white.bgRed(' I18N ERROR ')} --path option isn't provided.`); - } - - if (Array.isArray(path)) { - throw createFailError( - `${chalk.white.bgRed(' I18N ERROR ')} --path should be specified only once` - ); - } - - await integrateLocaleFiles(path, log); -}); diff --git a/src/dev/run_i18n_integrate.ts b/src/dev/run_i18n_integrate.ts new file mode 100644 index 0000000000000..3e5877bfa924a --- /dev/null +++ b/src/dev/run_i18n_integrate.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; + +import { integrateLocaleFiles, mergeConfigs } from './i18n'; +import { extractDefaultMessages } from './i18n/tasks'; +import { createFailError, run } from './run'; + +run( + async ({ + flags: { + 'dry-run': dryRun = false, + 'ignore-incompatible': ignoreIncompatible = false, + 'ignore-missing': ignoreMissing = false, + 'ignore-unused': ignoreUnused = false, + 'include-config': includeConfig, + path, + source, + target, + }, + log, + }) => { + if (!source || typeof source === 'boolean') { + throw createFailError(`${chalk.white.bgRed(' I18N ERROR ')} --source option isn't provided.`); + } + + if (Array.isArray(source)) { + throw createFailError( + `${chalk.white.bgRed(' I18N ERROR ')} --source should be specified only once.` + ); + } + + if (Array.isArray(target)) { + throw createFailError( + `${chalk.white.bgRed(' I18N ERROR ')} --target should be specified only once.` + ); + } + + const config = await mergeConfigs(includeConfig); + const defaultMessages = await extractDefaultMessages({ path, config }); + + await integrateLocaleFiles(defaultMessages, { + sourceFileName: source, + targetFileName: target, + dryRun, + ignoreIncompatible, + ignoreUnused, + ignoreMissing, + config, + log, + }); + } +); diff --git a/tasks/config/run.js b/tasks/config/run.js index b991e76fa4c8f..a8028e386c764 100644 --- a/tasks/config/run.js +++ b/tasks/config/run.js @@ -118,6 +118,7 @@ module.exports = function (grunt) { cmd: process.execPath, args: [ require.resolve('../../scripts/i18n_check'), + '--ignore-missing', ] }, diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a4695cda04502..0f8e142114f39 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -587,15 +587,12 @@ "common.ui.queryBar.luceneDocsDescription": "尚未就绪?在{docsLink}查找我们的 lucene 文档。", "common.ui.queryBar.luceneDocsDescription.docsLinkText": "此处", "common.ui.queryBar.optionsButtonLabel": "选项", - "common.ui.queryBar.refreshButtonLabel": "刷新", - "common.ui.queryBar.searchButtonAriaLabel": "搜索", "common.ui.queryBar.searchInputAriaLabel": "搜索输入", "common.ui.queryBar.searchInputPlaceholder": "搜索……(例如,status:200 AND extension:PHP)", "common.ui.queryBar.syntaxOptionsDescription": "我们的实验性自动完成功能及简单语法功能可以帮助您创建自己的查询。只需开始键入,便会看到与您的数据相关的匹配。请参阅{docsLink}的文档。", "common.ui.queryBar.syntaxOptionsDescription.docsLinkText": "此处", "common.ui.queryBar.syntaxOptionsTitle": "语法选项", "common.ui.queryBar.turnOnQueryFeaturesLabel": "打开查询功能", - "common.ui.queryBar.updateButtonLabel": "更新", "common.ui.savedObjectFinder.addNewItemButtonLabel": "添加新的 {item}", "common.ui.savedObjectFinder.manageItemsButtonLabel": "管理 {items}", "common.ui.savedObjectFinder.noMatchesFoundDescription": "未找到任何匹配的 {items}。", @@ -732,7 +729,6 @@ "common.ui.vislib.colormaps.greysText": "灰色", "common.ui.vislib.colormaps.redsText": "红色", "common.ui.vislib.colormaps.yellowToRedText": "黄到红", - "common.ui.welcomeError": "Kibana 未正确加载。检查服务器输出以了解详情。", "common.ui.welcomeMessage": "正在加载 Kibana", "console.autocomplete.addMethodMetaText": "方法", "console.consoleDisplayName": "Console", @@ -1260,9 +1256,6 @@ "kbn.dashboard.dashboardLinkLabel": "仪表板", "kbn.dashboard.dashboardWasNotSavedDangerMessage": "仪表板 “{dashTitle}” 未保存。错误:{errorMessage}", "kbn.dashboard.dashboardWasSavedSuccessMessage": "仪表板 “{dashTitle}” 已保存", - "kbn.dashboard.exitFullScreenButton.exitFullScreenModeButtonAreaLabel": "退出全屏模式", - "kbn.dashboard.exitFullScreenButton.exitFullScreenModeButtonLabel": "退出全屏", - "kbn.dashboard.exitFullScreenButton.fullScreenModeDescription": "在全屏模式下,按 ESC 键可退出。", "kbn.dashboard.featureCatalogue.dashboardDescription": "显示和共享可视化和已保存搜索的集合。", "kbn.dashboard.featureCatalogue.dashboardTitle": "仪表板", "kbn.dashboard.fillDashboardTitle": "此仪表板是空的。让我们来填充它!", @@ -3658,7 +3651,6 @@ "xpack.beatsManagement.beatsListAssignmentOptions.unenrollBeatsWarninigTitle": "取消注册选定的 Beats?", "xpack.beatsManagement.beatsListAssignmentOptions.unenrollButtonLabel": "取消注册选定", "xpack.beatsManagement.beatsTable.beatNameTitle": "Beat 名称", - "xpack.beatsManagement.beatsTable.configStatus.errorLabel": "错误", "xpack.beatsManagement.beatsTable.configStatus.errorTooltip": "请查看此 Beat 的日志以了解错误详情", "xpack.beatsManagement.beatsTable.configStatus.noConnectionTooltip": "此 Beat 未连接到 Kibana 的时间已超过 10 分钟", "xpack.beatsManagement.beatsTable.configStatus.notStartedTooltip": "此 Beat 尚未启动。", @@ -3667,21 +3659,17 @@ "xpack.beatsManagement.beatsTable.configStatus.okTooltip": "Beat 成功应用最新的配置", "xpack.beatsManagement.beatsTable.configStatusTitle": "配置状态", "xpack.beatsManagement.beatsTable.disenrollSelectedLabel": "取消注册选定", - "xpack.beatsManagement.beatsTable.lastConfigUpdateTitle": "上次配置更新", "xpack.beatsManagement.beatsTable.tagsTitle": "标记", "xpack.beatsManagement.beatsTable.typeLabel": "类型", "xpack.beatsManagement.beatsTable.typeTitle": "类型", "xpack.beatsManagement.beatTagsTable.addTagLabel": "添加标记", - "xpack.beatsManagement.beatTagsTable.configurationsTitle": "配置", "xpack.beatsManagement.beatTagsTable.lastUpdateTitle": "上次更新", "xpack.beatsManagement.beatTagsTable.removeSelectedLabel": "删除选定", "xpack.beatsManagement.beatTagsTable.tagNameTitle": "标记名称", "xpack.beatsManagement.breadcrumb.beatDetails": "{beatId} 的 Beat 详情", - "xpack.beatsManagement.breadcrumb.beatsTitle": "Beats", "xpack.beatsManagement.breadcrumb.beatTags": "{beatId} 的 Beat 标记", "xpack.beatsManagement.breadcrumb.configurationTags": "配置标记", "xpack.beatsManagement.breadcrumb.enrolledBeats": "已注册 Beats", - "xpack.beatsManagement.breadcrumb.managementTitle": "管理", "xpack.beatsManagement.centralManagementLinkLabel": "集中管理(公测版)", "xpack.beatsManagement.centralManagementSectionLabel": "Beats", "xpack.beatsManagement.confirmModal.cancelButtonLabel": "取消", @@ -3761,7 +3749,6 @@ "xpack.beatsManagement.tag.tagNameLabel": "标记名称", "xpack.beatsManagement.tag.tagNamePlaceholder": "标记名称(必填)", "xpack.beatsManagement.tag.updateTagTitle": "创建标记:{tagId}", - "xpack.beatsManagement.tagConfig.addConfigurationTitle": "添加配置块", "xpack.beatsManagement.tagConfig.closeButtonLabel": "关闭", "xpack.beatsManagement.tagConfig.configurationTypeText": "{configType} 配置", "xpack.beatsManagement.tagConfig.descriptionLabel": "描述", @@ -3772,9 +3759,7 @@ "xpack.beatsManagement.tagConfig.metricbeatModuleLabel": "Metricbeat 模块", "xpack.beatsManagement.tagConfig.outputLabel": "输出", "xpack.beatsManagement.tagConfig.saveButtonLabel": "保存", - "xpack.beatsManagement.tagConfig.selectOptionLabel": "请选择选项", "xpack.beatsManagement.tagConfig.typeLabel": "类型", - "xpack.beatsManagement.tagConfig.viewConfigurationTitle": "查看配置块", "xpack.beatsManagement.tagConfigAssignmentOptions.removeTagsButtonLabel": "删除标记", "xpack.beatsManagement.tagConfigAssignmentOptions.removeTagsWarninigMessage": "从选定 Beats 删除该标记?", "xpack.beatsManagement.tagConfigAssignmentOptions.removeTagsWarninigTitle": "删除标记", @@ -3783,7 +3768,6 @@ "xpack.beatsManagement.tagListAssignmentOptions.removeTagWarninigMessage": "删除标记?", "xpack.beatsManagement.tags.addTagButtonLabel": "添加标记", "xpack.beatsManagement.tags.someTagsMightBeAssignedToBeatsTitle": "以下部分标记可能已分配给 Beats。请确保正要删除的标记未分配", - "xpack.beatsManagement.tagsTable.configurationsTitle": "配置", "xpack.beatsManagement.tagsTable.lastUpdateTitle": "上次更新", "xpack.beatsManagement.tagsTable.removeSelectedLabel": "删除选定", "xpack.beatsManagement.tagsTable.tagNameTitle": "标记名称", @@ -3797,8 +3781,6 @@ "xpack.crossClusterReplication.addAutoFollowPatternButtonLabel": "创建自动跟随模式", "xpack.crossClusterReplication.addBreadcrumbTitle": "添加", "xpack.crossClusterReplication.appTitle": "跨集群复制(公测版)", - "xpack.crossClusterReplication.autoFollowPattern.addAction.successMultipleNotificationTitle": "自动跟随模式 “{name}” 已成功更新", - "xpack.crossClusterReplication.autoFollowPattern.addAction.successSingleNotificationTitle": "已添加自动跟随模式“{name}”", "xpack.crossClusterReplication.autoFollowPattern.addTitle": "添加自动跟随模式", "xpack.crossClusterReplication.autoFollowPattern.editTitle": "编辑自动跟随模式", "xpack.crossClusterReplication.autoFollowPattern.leaderIndexPatternValidation.illegalCharacters": "从索引模式中删除{characterListLength, plural, one {字符} other {字符}} {characterList}。", @@ -3817,14 +3799,7 @@ "xpack.crossClusterReplication.autoFollowPattern.removeAction.successSingleNotificationTitle": "自动跟随模式 “{name}” 已删除", "xpack.crossClusterReplication.autoFollowPattern.suffixValidation.illegalCharacters": "从后缀中删除{characterListLength, plural, one {字符} other {字符}} {characterList}。", "xpack.crossClusterReplication.autoFollowPattern.suffixValidation.noEmptySpace": "后缀中不能使用空格。", - "xpack.crossClusterReplication.autoFollowPatternCreateForm.addRemoteClusterButtonLabel": "添加远程集群", - "xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutDescription": "自动跟随模式捕获远程集群上的索引。必须添加远程集群。", - "xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutTitle": "未找到任何远程集群", "xpack.crossClusterReplication.autoFollowPatternCreateForm.loadingRemoteClusters": "正在加载远程集群……", - "xpack.crossClusterReplication.autoFollowPatternCreateForm.loadingRemoteClustersErrorTitle": "加载远程集群时出错", - "xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutDescription": "您的任何集群都未连接。先确认您的集群设置并确保至少一个集群已连接,然后再创建自动跟随模式。", - "xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutTitle": "远程集群连接错误", - "xpack.crossClusterReplication.autoFollowPatternCreateForm.viewRemoteClusterButtonLabel": "查看远程集群", "xpack.crossClusterReplication.autoFollowPatternDetailPanel.closeButtonLabel": "关闭", "xpack.crossClusterReplication.autoFollowPatternDetailPanel.deleteButtonLabel": "删除", "xpack.crossClusterReplication.autoFollowPatternDetailPanel.editButtonLabel": "编辑", @@ -3839,14 +3814,10 @@ "xpack.crossClusterReplication.autoFollowPatternDetailPanel.suffixEmptyValue": "无后缀", "xpack.crossClusterReplication.autoFollowPatternDetailPanel.suffixLabel": "后缀", "xpack.crossClusterReplication.autoFollowPatternDetailPanel.viewIndicesLink": "在“索引管理”中查看您的 Follower 索引", - "xpack.crossClusterReplication.autoFollowPatternEditForm.emptyRemoteClustersDescription": "远程集群 “{remoteCluster}” 不存在或未连接。先确认其已连接,然后再编辑 “{name}” 自动跟随模式。", - "xpack.crossClusterReplication.autoFollowPatternEditForm.emptyRemoteClustersTitle": "远程集群缺失", "xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle": "加载自动跟随模式时出错", "xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClusters": "正在加载远程集群……", - "xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersErrorTitle": "加载远程集群时出错", "xpack.crossClusterReplication.autoFollowPatternEditForm.loadingTitle": "正在加载自动跟随模式……", "xpack.crossClusterReplication.autoFollowPatternEditForm.viewAutoFollowPatternsButtonLabel": "查看自动跟随模式", - "xpack.crossClusterReplication.autoFollowPatternEditForm.viewRemoteClustersButtonLabel": "查看远程集群", "xpack.crossClusterReplication.autoFollowPatternForm.actions.savingText": "正在保存", "xpack.crossClusterReplication.autoFollowPatternForm.autoFollowPattern.fieldPrefixLabel": "前缀", "xpack.crossClusterReplication.autoFollowPatternForm.autoFollowPattern.fieldSuffixLabel": "后缀", @@ -3860,7 +3831,6 @@ "xpack.crossClusterReplication.autoFollowPatternForm.indicesPreviewTitle": "索引名称示例", "xpack.crossClusterReplication.autoFollowPatternForm.leaderIndexPatternError.duplicateMessage": "不允许重复的 Leader 索引模式。", "xpack.crossClusterReplication.autoFollowPatternForm.remoteCluster.fieldClusterLabel": "远程集群", - "xpack.crossClusterReplication.autoFollowPatternForm.saveButtonLabel": "保存", "xpack.crossClusterReplication.autoFollowPatternForm.savingErrorTitle": "创建自动跟随模式时出错", "xpack.crossClusterReplication.autoFollowPatternForm.sectionAutoFollowPatternDescription": "应用于 Follower 索引名称的定制前缀或后缀,以便您可以更容易辨识复制的索引。默认情况下,Follower 索引与 Leader 索引有相同的名称。", "xpack.crossClusterReplication.autoFollowPatternForm.sectionAutoFollowPatternNameDescription": "自动跟随模式的唯一名称。", @@ -3873,7 +3843,6 @@ "xpack.crossClusterReplication.autoFollowPatternForm.sectionRemoteClusterDescription": "要从其中复制 Leader 索引的远程索引。", "xpack.crossClusterReplication.autoFollowPatternForm.sectionRemoteClusterTitle": "远程集群", "xpack.crossClusterReplication.autoFollowPatternForm.validationErrorTitle": "继续前请解决错误。", - "xpack.crossClusterReplication.autoFollowPatternList.addAutofollowPatternButtonLabel": "创建自动跟随模式", "xpack.crossClusterReplication.autoFollowPatternList.autoFollowPatternsDescription": "自动跟随模式将远程集群的 Leader 索引复制到本地集群上的 Follower 索引。", "xpack.crossClusterReplication.autoFollowPatternList.autoFollowPatternsTitle": "自动跟随模式", "xpack.crossClusterReplication.autoFollowPatternList.crossClusterReplicationTitle": "跨集群复制(公测版)", @@ -3882,7 +3851,6 @@ "xpack.crossClusterReplication.autoFollowPatternList.loadingErrorTitle": "加载自动跟随模式时出错", "xpack.crossClusterReplication.autoFollowPatternList.loadingTitle": "正在加载自动跟随模式……", "xpack.crossClusterReplication.autoFollowPatternList.noPermissionText": "您无权查看或添加自动跟随模式。", - "xpack.crossClusterReplication.autofollowPatternList.table.actionDeleteDescription": "删除自动跟随模式", "xpack.crossClusterReplication.autoFollowPatternList.table.actionsColumnTitle": "操作", "xpack.crossClusterReplication.autoFollowPatternList.table.clusterColumnTitle": "集群", "xpack.crossClusterReplication.autoFollowPatternList.table.leaderPatternsColumnTitle": "Leader 模式", @@ -3899,12 +3867,8 @@ "xpack.crossClusterReplication.deleteAutoFollowPattern.confirmModal.multipleDeletionDescription": "您即将删除以下自动跟随模式:", "xpack.crossClusterReplication.deleteAutoFollowPatternButtonLabel": "删除自动跟随 {total, plural, one { 个模式} other { 个模式}}", "xpack.crossClusterReplication.editBreadcrumbTitle": "编辑", - "xpack.crossClusterReplication.editIndexPattern.fields.table.actionEditDescription": "编辑", - "xpack.crossClusterReplication.editIndexPattern.fields.table.actionEditLabel": "编辑", "xpack.crossClusterReplication.homeBreadcrumbTitle": "跨集群复制(公测版)", "xpack.crossClusterReplication.indexMgmtBadge.followerLabel": "Follower", - "xpack.crossClusterReplication.readDocsButtonLabel": "自动跟随模式文档", - "xpack.crossClusterReplication.remoteClusterList.noPermissionTitle": "权限错误", "xpack.dashboardMode.dashboardViewer.dashboardDescription": "仪表板查看器", "xpack.dashboardMode.dashboardViewer.dashboardTitle": "仪表板", "xpack.dashboardMode.dashboardViewerDescription": "查看仪表板", @@ -4197,8 +4161,6 @@ "xpack.indexLifecycleMgmt.confirmDelete.title": "删除策略“{name}”", "xpack.indexLifecycleMgmt.confirmDelete.undoneWarning": "无法恢复删除的策略。", "xpack.indexLifecycleMgmt.editPolicy.cancelButton": "取消", - "xpack.indexLifecycleMgmt.editPolicy.coldhase.deactivateColdPhaseButton": "停用冷阶段", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.activateColdPhaseButton": "激活冷阶段", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText": "您查询自己索引的频率较低,因此您可以在效率较低的硬件上分配分片。因为您的查询较为缓慢,所以您可以减少副本分片数目。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel": "冷阶段", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText": "冻结的索引在集群上有很少的开销,已被阻止进行写操作。您可以搜索冻结的索引,但查询应会较慢。", @@ -4206,8 +4168,6 @@ "xpack.indexLifecycleMgmt.editPolicy.createdMessage": "创建于", "xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage": "创建索引生命周期策略", "xpack.indexLifecycleMgmt.editPolicy.daysLabel": "天({fromMessage})", - "xpack.indexLifecycleMgmt.editPolicy.deletePhase.activateDeletePhaseButton": "激活删除阶段", - "xpack.indexLifecycleMgmt.editPolicy.deletePhase.deactivateDeletePhaseButton": "停用删除阶段", "xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseDescriptionText": "您不再需要自己的索引。 您可以定义安全删除它的时间。", "xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseLabel": "删除阶段", "xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError": "策略名称必须不同。", @@ -4260,8 +4220,6 @@ "xpack.indexLifecycleMgmt.editPolicy.updatedMessage": "已更新", "xpack.indexLifecycleMgmt.editPolicy.validPolicyNameMessage": "策略名称不能以下划线开头,且不能包含问号或空格。", "xpack.indexLifecycleMgmt.editPolicy.viewNodeDetailsButton": "查看附加到此配置的节点列表", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.activateWarmPhaseButton": "激活温阶段", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.deactivateWarmPhaseButton": "停用温阶段", "xpack.indexLifecycleMgmt.editPolicy.warmPhase.forceMergeDataExplanationText": "通过合并较小文件并清除已删除文件,来减少分片中的段数目。", "xpack.indexLifecycleMgmt.editPolicy.warmPhase.forceMergeDataText": "强制合并", "xpack.indexLifecycleMgmt.editPolicy.warmPhase.indexPriorityExplanationText": "设置在节点重新启动后恢复索引的优先级。较高优先级的索引会在较低优先级的索引之前恢复。", @@ -4384,7 +4342,6 @@ "xpack.infra.errorPage.tryAgainDescription ": "请点击后退按钮,然后重试。", "xpack.infra.errorPage.unexpectedErrorTitle": "糟糕!", "xpack.infra.header.infrastructureTitle": "基础设施", - "xpack.infra.homePage.noMetricsIndicesActionLabel": "设置说明", "xpack.infra.homePage.noMetricsIndicesDescription": "让我们添加一些!", "xpack.infra.homePage.noMetricsIndicesTitle": "似乎您没有任何指标索引。", "xpack.infra.homePage.toolbar.dockerContainersTitle": "Docker 容器", @@ -4424,7 +4381,6 @@ "xpack.infra.logs.streamingDescription": "正在流式传输……", "xpack.infra.logs.streamingNewEntriesText": "正在流式传输新条目", "xpack.infra.logsPage.logsBreadcrumbsText": "日志", - "xpack.infra.logsPage.noLoggingIndicesActionLabel": "设置说明", "xpack.infra.logsPage.noLoggingIndicesDescription": "让我们添加一些!", "xpack.infra.logsPage.noLoggingIndicesTitle": "似乎您没有任何日志索引。", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "搜索日志条目……(例如 host.name:host-1)", @@ -4547,17 +4503,8 @@ "xpack.infra.sourceErrorPage.failedToLoadDataSourcesMessage": "无法加载数据源。", "xpack.infra.sourceLoadingPage.loadingDataSourcesMessage": "正在加载数据源", "xpack.infra.waffle.checkNewDataButtonLabel": "检查新数据", - "xpack.infra.waffle.containerGroupByOptions.availabilityZoneLabel": "可用区", - "xpack.infra.waffle.containerGroupByOptions.hostLabel": "主机", - "xpack.infra.waffle.containerGroupByOptions.machineTypeLabel": "机器类型", - "xpack.infra.waffle.containerGroupByOptions.projectIDLabel": "项目 ID", - "xpack.infra.waffle.containerGroupByOptions.providerLabel": "提供商", "xpack.infra.waffle.groupByAllTitle": "全部", "xpack.infra.waffle.groupByButtonLabel": "分组依据:", - "xpack.infra.waffle.hostGroupByOptions.availabilityZoneLabel": "可用区", - "xpack.infra.waffle.hostGroupByOptions.cloudProviderLabel": "云提供商", - "xpack.infra.waffle.hostGroupByOptions.machineTypeLabel": "机器类型", - "xpack.infra.waffle.hostGroupByOptions.projectIDLabel": "项目 ID", "xpack.infra.waffle.loadingDataText": "正在加载数据", "xpack.infra.waffle.metricButtonLabel": "指标:{selectedMetric}", "xpack.infra.waffle.metricOptions.cpuUsageText": "CPU 使用", @@ -4569,8 +4516,6 @@ "xpack.infra.waffle.noDataDescription": "尝试调整您的时间或筛选。", "xpack.infra.waffle.noDataTitle": "没有可显示的数据。", "xpack.infra.waffle.nodeTypeSwitcher.hostsLabel": "主机", - "xpack.infra.waffle.podGroupByOptions.namespaceLabel": "命名空间", - "xpack.infra.waffle.podGroupByOptions.nodeLabel": "节点", "xpack.infra.waffle.removeGroupingItemAriaLabel": "删除 {groupingItem} 分组", "xpack.infra.waffle.selectTwoGroupingsTitle": "选择最多两个分组", "xpack.infra.waffle.unableToSelectGroupErrorMessage": "无法选择 {nodeType} 的分组依据选项", @@ -4793,7 +4738,6 @@ "xpack.ml.annotationsTable.openInSingleMetricViewerAriaLabel": "在 Single Metric Viewer 中打开", "xpack.ml.annotationsTable.openInSingleMetricViewerTooltip": "在 Single Metric Viewer 中打开", "xpack.ml.annotationsTable.toColumnName": "到", - "xpack.ml.annotationsTable.viewColumnName": "查看", "xpack.ml.anomaliesTable.actionsColumnName": "操作", "xpack.ml.anomaliesTable.actualSortColumnName": "实际", "xpack.ml.anomaliesTable.anomalyDetails.actualTitle": "实际", @@ -7350,11 +7294,9 @@ "xpack.remoteClusters.detailPanel.statusTitle": "状态", "xpack.remoteClusters.edit.backToRemoteClustersButtonLabel": "返回远程集群", "xpack.remoteClusters.edit.loadingLabel": "正在加载远程集群……", - "xpack.remoteClusters.edit.notFoundLabel": "找不到远程集群", "xpack.remoteClusters.editAction.errorTitle": "编辑集群时出错", "xpack.remoteClusters.editAction.failedDefaultErrorMessage": "请求失败,显示 {statusCode} 错误。{message}", "xpack.remoteClusters.editBreadcrumbTitle": "编辑", - "xpack.remoteClusters.editTitle": "编辑 {name}", "xpack.remoteClusters.form.errors.illegalCharacters": "名称包含无效字符。", "xpack.remoteClusters.form.errors.nameMissing": "“名称”必填", "xpack.remoteClusters.form.errors.seedMissing": "至少需要一个种子节点。", @@ -7376,7 +7318,6 @@ "xpack.remoteClusters.remoteClusterForm.sectionNameDescription": "远程集群的唯一名称。", "xpack.remoteClusters.remoteClusterForm.sectionNameTitle": "名称", "xpack.remoteClusters.remoteClusterForm.sectionSeedsDescription1": "要查询集群状态的远程集群节点的列表。指定多个种子节点,以便在节点不可用时发现不会失败。", - "xpack.remoteClusters.remoteClusterForm.sectionSeedsHelpText": "IP 地址或主机名,后跟远程集群的传输端口。", "xpack.remoteClusters.remoteClusterForm.sectionSeedsTitle": "用于集群发现的种子节点", "xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription": "默认情况下,如果任何查询的远程集群不可用,请求将失败。要在此集群不可用时继续向其他远程集群发送请求,请启用 {optionName}。{learnMoreLink}", "xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription.learnMoreLinkLabel": "了解详情。", @@ -7437,7 +7378,6 @@ "xpack.reporting.jobStatuses.failedText": "失败", "xpack.reporting.jobStatuses.pendingText": "待处理", "xpack.reporting.jobStatuses.processingText": "正在处理", - "xpack.reporting.listing.reportsTitle": "报告", "xpack.reporting.listing.table.downloadReportAriaLabel": "下载报告", "xpack.reporting.listing.table.loadingReportsDescription": "正在载入报告", "xpack.reporting.listing.table.maxSizeReachedTooltip": "已达到最大大小,包含部分数据。", @@ -7474,8 +7414,6 @@ "xpack.rollupJobs.checkLicense.errorUnavailableMessage": "您不能使用 {pluginName},因为许可证信息当前不可用。", "xpack.rollupJobs.checkLicense.errorUnsupportedMessage": "您的 {licenseType} 许可证不支持 {pluginName}。请升级您的许可。", "xpack.rollupJobs.create.backButton.label": "上一步", - "xpack.rollupJobs.create.breadcrumbs.createText": "创建", - "xpack.rollupJobs.create.breadcrumbs.jobsText": "汇总/打包作业", "xpack.rollupJobs.create.errors.dateHistogramFieldMissing": "“日期”字段必填。", "xpack.rollupJobs.create.errors.dateHistogramIntervalInvalidCalendarInterval": "“{unit}” 单位仅允许值为 1。请尝试 {suggestion}。", "xpack.rollupJobs.create.errors.dateHistogramIntervalInvalidCalendarIntervalSuggestion": "1{unit}", @@ -8041,7 +7979,6 @@ "xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel": "索引", "xpack.upgradeAssistant.checkupTab.deprecations.warningActionTooltip": "建议在升级之前先解决此问题,但这不是必需的。", "xpack.upgradeAssistant.checkupTab.deprecations.warningLabel": "警告", - "xpack.upgradeAssistant.checkupTab.errorCallout.calloutTitle": "检索检查结果时发生网络错误。", "xpack.upgradeAssistant.checkupTab.indexLabel": "索引", "xpack.upgradeAssistant.checkupTab.indicesBadgeLabel": "{numIndices, plural, one { 个索引} other { 个索引}}", "xpack.upgradeAssistant.checkupTab.indicesTabLabel": "索引", @@ -8092,7 +8029,6 @@ "xpack.uptime.emptyState.configureHeartbeatToGetStartedMessage": "{configureHeartbeatLink} 以开始记录运行时间数据。", "xpack.uptime.emptyState.errorMessage": "错误 {message}", "xpack.uptime.emptyState.loadingMessage": "正在加载……", - "xpack.uptime.emptyState.noDataDescription": "没有可用的运行时间数据。", "xpack.uptime.emptyState.noDataTitle": "没有运行时间数据", "xpack.uptime.errorList.CountColumnLabel": "计数", "xpack.uptime.errorList.errorMessage": "错误 {message}", @@ -8135,9 +8071,6 @@ "xpack.uptime.monitorList.statusColumnLabel": "状态", "xpack.uptime.monitorList.typeColumnLabel": "类型", "xpack.uptime.monitorList.upLineSeries.upLabel": "运行", - "xpack.uptime.monitorPage.header.salutation": "检测:", - "xpack.uptime.monitorSelect.errorMessage": "错误 {message}", - "xpack.uptime.monitorSelect.loadingMessage": "正在加载……", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "监测持续时间(毫秒)", "xpack.uptime.monitorStatusBar.errorMessage": "错误 {message}", "xpack.uptime.monitorStatusBar.healthStatus.durationInMillisecondsMessage": "{duration}ms", @@ -8156,7 +8089,6 @@ "xpack.uptime.pingList.errorTypeColumnLabel": "错误类型", "xpack.uptime.pingList.idColumnLabel": "ID", "xpack.uptime.pingList.ipAddressColumnLabel": "IP", - "xpack.uptime.pingList.maxSearchSizeLabel": "最大搜索大小", "xpack.uptime.pingList.responseCodeColumnLabel": "响应代码", "xpack.uptime.pingList.statusColumnHealthDownLabel": "关闭", "xpack.uptime.pingList.statusColumnHealthUpLabel": "运行", @@ -8169,7 +8101,6 @@ "xpack.uptime.pluginDescription": "运行时间监测", "xpack.uptime.snapshot.endpointStatusTitle": "终端节点状态", "xpack.uptime.snapshot.errorMessage": "错误 {message}", - "xpack.uptime.snapshot.loadingMessage": "正在加载……", "xpack.uptime.snapshot.noDataDescription": "抱歉,没有可用于该直方图的数据", "xpack.uptime.snapshot.noDataTitle": "没有可用的直方图数据", "xpack.uptime.snapshot.stats.downDescription": "关闭", diff --git a/yarn.lock b/yarn.lock index 227cf5139f2a0..5f9f03bb96b90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1556,6 +1556,11 @@ resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz#121f6917c4389db3923640b2e68de5fa64dda88e" integrity sha512-q9Q6+eUEGwQkv4Sbst3J4PNgDOvpuVuKj79Hl/qnmBMEIPzB5QoFRUtjcgcg2xNUZyYUGXBk5wYIBKHt0A+Mxw== +"@types/json5@^0.0.30": + version "0.0.30" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818" + integrity sha512-sqm9g7mHlPY/43fcSNrCYfOeX9zkTTK+euO5E6+CVijSMm5tTjkVdwdqRkY3ljjIAf8679vps5jKUoJBCLsMDA== + "@types/jsonwebtoken@^7.2.7": version "7.2.8" resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz#8d199dab4ddb5bba3234f8311b804d2027af2b3a"