From ea3f342da5234903d4314294fe3d453eb287ab3d Mon Sep 17 00:00:00 2001 From: Jaco Greeff Date: Sat, 2 May 2020 13:46:10 +0200 Subject: [PATCH] Move i18n app to settings (discoverability) (#2679) --- I18N.md | 6 +- packages/apps/public/locales/en/app-i18n.json | 7 +- .../apps/public/locales/en/app-settings.json | 7 +- .../apps/public/locales/en/translation.json | 1 + packages/page-i18n/src/index.tsx | 224 +--------------- packages/page-i18n/src/translate.ts | 9 - .../src/I18n}/StringInput.tsx | 2 +- packages/page-settings/src/I18n/index.tsx | 245 ++++++++++++++++++ packages/page-settings/src/index.tsx | 8 + packages/react-components/src/i18n/Backend.ts | 39 +-- packages/react-components/src/i18n/cache.ts | 7 + 11 files changed, 289 insertions(+), 266 deletions(-) delete mode 100644 packages/page-i18n/src/translate.ts rename packages/{page-i18n/src => page-settings/src/I18n}/StringInput.tsx (93%) create mode 100644 packages/page-settings/src/I18n/index.tsx create mode 100644 packages/react-components/src/i18n/cache.ts diff --git a/I18N.md b/I18N.md index 6cf284ab3b..7de32bd9f7 100644 --- a/I18N.md +++ b/I18N.md @@ -7,7 +7,7 @@ The apps UI allows all strings to be translated. Additionally it has a basic UI To update translations, the following process is required. - launch the apps UI, either locally or via [https://polkadot.js.org/apps](https://polkadot.js.org/apps) -- explicitly navigate to the i18n page, [https://polkadot.js.org/apps/#/i18n](https://polkadot.js.org/apps/#/i18n) +- explicitly navigate to the i18n page, [https://polkadot.js.org/apps/#/i18n](https://polkadot.js.org/apps/#/settings/i18n) Here you will find a dropdown of all the available languages and all the modules that maps to the UI. On a single screen you will be able to see all the available strings for a specific module. @@ -17,4 +17,6 @@ Here you will find a dropdown of all the available languages and all the modules ## Adding a new language -The process is similar for the above, but does require a new folder with the language identifier to be added. Create `packages/apps/public/locales/` folder with an empty `translation.json` (containing only `{}`). After addition of the folder, run `yarn build:i18n` and then the new language will be available for update as per the process in the previous section. +The process is similar for the above, but does require a new folder with the language identifier to be added. Create [packages/apps/public/locales/](https://github.com/polkadot-js/apps/tree/master/packages/apps/public/locales) folder with an empty `translation.json` (containing only `{}`). After addition of the folder, run `yarn build:i18n` and then the new language will be available for update as per the process in the previous section. + +In addition to the language folder, the language also needs to be added to the dropdown for available languages, this can be found in [packages/apps-config/src/settings.languages.ts](https://github.com/polkadot-js/apps/blob/master/packages/apps-config/src/settings/languages.ts) diff --git a/packages/apps/public/locales/en/app-i18n.json b/packages/apps/public/locales/en/app-i18n.json index a90fde9fe6..9e26dfeeb6 100644 --- a/packages/apps/public/locales/en/app-i18n.json +++ b/packages/apps/public/locales/en/app-i18n.json @@ -1,6 +1 @@ -{ - "Generate {{lng}}/translation.json": "Generate {{lng}}/translation.json", - "the language to display translations for": "the language to display translations for", - "the module to display strings for": "the module to display strings for", - "{{done}}/{{total}}, {{progress}}% done": "{{done}}/{{total}}, {{progress}}% done" -} \ No newline at end of file +{} \ No newline at end of file diff --git a/packages/apps/public/locales/en/app-settings.json b/packages/apps/public/locales/en/app-settings.json index 03aab6ac97..353b8e7750 100644 --- a/packages/apps/public/locales/en/app-settings.json +++ b/packages/apps/public/locales/en/app-settings.json @@ -9,6 +9,7 @@ "Developer": "Developer", "Extensions": "Extensions", "General": "General", + "Generate {{lng}}/translation.json": "Generate {{lng}}/translation.json", "Genesis Hash": "Genesis Hash", "Genesis Hash refers to initial state of the chain, it cannot be changed once the chain is launched": "Genesis Hash refers to initial state of the chain, it cannot be changed once the chain is launched", "Manage your connection to Ledger S": "Manage your connection to Ledger S", @@ -28,6 +29,7 @@ "The RPC node can be selected from the pre-defined list or manually entered, depending on the chain you wish to connect to.": "The RPC node can be selected from the pre-defined list or manually entered, depending on the chain you wish to connect to.", "The color used to distinguish this network with others, use color code with 3 or 6 digits, like \"#FFF\" or \"#111111\"": "The color used to distinguish this network with others, use color code with 3 or 6 digits, like \"#FFF\" or \"#111111\"", "This is not a valid JSON object.": "This is not a valid JSON object.", + "Translate": "Translate", "Unit": "Unit", "Unit decides the name of 1 unit token, e.g. \"DOT\" for Polkadot": "Unit decides the name of 1 unit token, e.g. \"DOT\" for Polkadot", "Update metadata": "Update metadata", @@ -39,5 +41,8 @@ "interface operation mode": "interface operation mode", "manage hardware connections": "manage hardware connections", "remote node/endpoint to connect to": "remote node/endpoint to connect to", - "upgradable extensions": "upgradable extensions" + "the language to display translations for": "the language to display translations for", + "the module to display strings for": "the module to display strings for", + "upgradable extensions": "upgradable extensions", + "{{done}}/{{total}}, {{progress}}% done": "{{done}}/{{total}}, {{progress}}% done" } \ No newline at end of file diff --git a/packages/apps/public/locales/en/translation.json b/packages/apps/public/locales/en/translation.json index 3c7a6458aa..54df3c9e1d 100644 --- a/packages/apps/public/locales/en/translation.json +++ b/packages/apps/public/locales/en/translation.json @@ -566,6 +566,7 @@ "To council": "", "To ensure optimal fund security using the same stash/controller is strongly discouraged, but not forbidden.": "", "Transfer": "", + "Translate": "", "Treasury overview": "", "Type here what you would like your address to contain. This tool will generate the keys and show the associated addresses that best match your search. You can use \"?\" as a wildcard for a character.": "", "Type the amount you want to transfer. Note that you can select the unit on the right e.g sending 1 milli is equivalent to sending 0.001.": "", diff --git a/packages/page-i18n/src/index.tsx b/packages/page-i18n/src/index.tsx index a3ccbdb908..0f5bb5ee3f 100644 --- a/packages/page-i18n/src/index.tsx +++ b/packages/page-i18n/src/index.tsx @@ -2,227 +2,11 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import { AppProps as Props } from '@polkadot/react-components/types'; - -import FileSaver from 'file-saver'; -import React, { useCallback, useEffect, useState } from 'react'; -import styled from 'styled-components'; -import { Button, Columar, Column, Dropdown, Progress, Spinner } from '@polkadot/react-components'; - -import { useTranslation } from './translate'; -import StringInput from './StringInput'; - -interface Option { - text: string; - value: string; -} - -interface Defaults { - english: StringsMod; - keys: Option[]; - languages: Languages; - modules: Option[]; -} - -type Progress = [[number, number], Record]; -type Strings = Record; -type StringsMod = Record; -type Languages = Record; - -async function retrieveJson (url: string): Promise { - const response = await fetch(`locales/${url}`); - - return response.json(); -} - -async function retrieveEnglish (): Promise { - const paths: Array = await retrieveJson('en/index.json'); - const strings: Strings[] = await Promise.all(paths.map((path) => retrieveJson(`en/${path}`))); - - return strings.reduce((language: StringsMod, strings, index): StringsMod => { - language[paths[index]] = strings; - - return language; - }, {}); -} - -async function retrieveAll (): Promise { - const _keys: string[] = await retrieveJson('index.json'); - const keys = _keys.filter((key) => key !== 'en'); - const english = await retrieveEnglish(); - const languages = await Promise.all( - keys.map((lng) => retrieveJson(`${lng}/translation.json`)) - ); - - return { - english, - keys: keys.map((text) => ({ text, value: text })), - languages: languages.reduce((languages: Languages, strings, index): Languages => { - languages[keys[index]] = strings; - - return languages; - }, {}), - modules: Object - .keys(english) - .map((text) => ({ text: text.replace('.json', '').replace('app-', 'page-'), value: text })) - .sort((a, b) => a.text.localeCompare(b.text)) - }; -} - -function calcProgress (english: StringsMod, language: Strings): Progress { - const breakdown: Record = {}; - let done = 0; - let total = 0; - - Object.keys(english).forEach((record): void => { - const mod = english[record]; - const mtotal = Object.keys(mod).length; - let mdone = 0; - - Object.keys(mod).forEach((key): void => { - if (language[key]) { - mdone++; - } - }); - - done += mdone; - total += mtotal; - - breakdown[record] = [mdone, mtotal]; - }); - - return [[done, total], breakdown]; -} - -function doDownload (strings: Strings): void { - const sanitized = Object.keys(strings).sort().reduce((result: Strings, key): Strings => { - const sanitized = strings[key].trim(); - - if (sanitized) { - result[key] = sanitized; - } - - return result; - }, {}); - - FileSaver.saveAs( - new Blob([JSON.stringify(sanitized, null, 2)], { type: 'application/json; charset=utf-8' }), - 'translation.json' - ); -} - -function progressDisplay ([done, total]: [number, number] = [0, 0]): { done: number; progress: string; total: number } { - return { - done, - progress: (total ? (done * 100 / total) : 100).toFixed(2), - total - }; -} - -function TranslateApp ({ className }: Props): React.ReactElement { - const { t } = useTranslation(); - const [{ english, keys, languages, modules }, setDefaults] = useState({ english: {}, keys: [], languages: {}, modules: [] }); - const [lng, setLng] = useState('zh'); - const [[modProgress, allProgress], setProgress] = useState([[0, 0], {}]); - const [record, setRecord] = useState('app-accounts.json'); - const [strings, setStrings] = useState(null); - - useEffect((): void => { - retrieveAll().then(setDefaults); - }, []); - - useEffect((): void => { - setStrings(languages[lng]); - setProgress(calcProgress(english, languages[lng])); - }, [english, languages, lng]); - - const _setString = useCallback( - (key: string, value: string): void => { - setStrings((strings: Strings | null): Strings | null => - strings - ? { ...strings, [key]: value } - : null - ); - - const hasPrevVal = !!languages[lng][key]; - const sanitized = value.trim(); - - languages[lng][key] = value; - - if (hasPrevVal !== !!sanitized) { - setProgress(calcProgress(english, languages[lng])); - } - }, - [english, languages, lng] - ); - - const _onDownload = useCallback( - () => doDownload(strings || {}), - [strings] - ); - - if (!keys.length) { - return ; - } +import React from 'react'; +import { Link } from 'react-router-dom'; +export default function TranslateApp (): React.ReactElement<{}> { return ( -
-
- - - - - {t('{{done}}/{{total}}, {{progress}}% done', { replace: progressDisplay(modProgress) })} - - - - - {t('{{done}}/{{total}}, {{progress}}% done', { replace: progressDisplay(allProgress[record]) })} - - -
- -
+
This app has been moved to #/settings/i18n
); } - -export default React.memo(styled(TranslateApp)` - .ui.progress:last-child { - margin: 0.25rem; - } -`); diff --git a/packages/page-i18n/src/translate.ts b/packages/page-i18n/src/translate.ts deleted file mode 100644 index b0bc7fd7e7..0000000000 --- a/packages/page-i18n/src/translate.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2017-2020 @polkadot/app-i18n authors & contributors -// This software may be modified and distributed under the terms -// of the Apache-2.0 license. See the LICENSE file for details. - -import { useTranslation as useTranslationBase, UseTranslationResponse } from 'react-i18next'; - -export function useTranslation (): UseTranslationResponse { - return useTranslationBase('app-i18n'); -} diff --git a/packages/page-i18n/src/StringInput.tsx b/packages/page-settings/src/I18n/StringInput.tsx similarity index 93% rename from packages/page-i18n/src/StringInput.tsx rename to packages/page-settings/src/I18n/StringInput.tsx index 612d8db46a..3f79fc70fb 100644 --- a/packages/page-i18n/src/StringInput.tsx +++ b/packages/page-settings/src/I18n/StringInput.tsx @@ -1,4 +1,4 @@ -// Copyright 2017-2020 @polkadot/app-i18n authors & contributors +// Copyright 2017-2020 @polkadot/app-settings authors & contributors // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. diff --git a/packages/page-settings/src/I18n/index.tsx b/packages/page-settings/src/I18n/index.tsx new file mode 100644 index 0000000000..48b0117458 --- /dev/null +++ b/packages/page-settings/src/I18n/index.tsx @@ -0,0 +1,245 @@ +// Copyright 2017-2020 @polkadot/app-settings authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import FileSaver from 'file-saver'; +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { Button, Columar, Column, Dropdown, Progress, Spinner } from '@polkadot/react-components'; +import languageCache from '@polkadot/react-components/i18n/cache'; +import uiSettings from '@polkadot/ui-settings'; + +import { useTranslation } from '../translate'; +import StringInput from './StringInput'; + +interface Props { + className?: string; +} + +interface Option { + text: string; + value: string; +} + +interface Defaults { + english: StringsMod; + keys: Option[]; + modules: Option[]; +} + +type Progress = [[number, number, number], Record]; +type Strings = Record; +type StringsMod = Record; + +async function retrieveJson (url: string): Promise { + const response = await fetch(`locales/${url}`); + + return response.json(); +} + +async function retrieveEnglish (): Promise { + const paths: Array = await retrieveJson('en/index.json'); + const strings: Strings[] = await Promise.all(paths.map((path) => retrieveJson(`en/${path}`))); + + return strings.reduce((language: StringsMod, strings, index): StringsMod => { + language[paths[index]] = strings; + + return language; + }, {}); +} + +async function retrieveAll (): Promise { + const _keys: string[] = await retrieveJson('index.json'); + const keys = _keys.filter((lng) => lng !== 'en'); + const missing = keys.filter((lng) => !languageCache[lng]); + const english = await retrieveEnglish(); + const translations = missing.length + ? await Promise.all(missing.map((lng) => retrieveJson(`${lng}/translation.json`))) + : []; + + missing.forEach((lng, index): void => { + languageCache[lng] = translations[index]; + }); + + return { + english, + keys: keys.map((text) => ({ text, value: text })), + modules: Object + .keys(english) + .map((text) => ({ text: text.replace('.json', '').replace('app-', 'page-'), value: text })) + .sort((a, b) => a.text.localeCompare(b.text)) + }; +} + +function calcProgress (english: StringsMod, language: Strings): Progress { + const breakdown: Record = {}; + let done = 0; + let total = 0; + + Object.keys(english).forEach((record): void => { + const mod = english[record]; + const mtotal = Object.keys(mod).length; + let mdone = 0; + + Object.keys(mod).forEach((key): void => { + if (language[key]) { + mdone++; + } + }); + + done += mdone; + total += mtotal; + + breakdown[record] = [mdone, mtotal, 0]; + }); + + return [[done, total, 0], breakdown]; +} + +function doDownload (strings: Strings): void { + const sanitized = Object.keys(strings).sort().reduce((result: Strings, key): Strings => { + const sanitized = strings[key].trim(); + + if (sanitized) { + result[key] = sanitized; + } + + return result; + }, {}); + + FileSaver.saveAs( + new Blob([JSON.stringify(sanitized, null, 2)], { type: 'application/json; charset=utf-8' }), + 'translation.json' + ); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function progressDisplay ([done, total, _]: [number, number, number] = [0, 0, 0]): { done: number; progress: string; total: number } { + return { + done, + progress: (total ? (done * 100 / total) : 100).toFixed(2), + total + }; +} + +function Translate ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const [{ english, keys, modules }, setDefaults] = useState({ english: {}, keys: [], modules: [] }); + const [lng, setLng] = useState('zh'); + const [[modProgress, allProgress], setProgress] = useState([[0, 0, 0], {}]); + const [record, setRecord] = useState('app-accounts.json'); + const [strings, setStrings] = useState(null); + + useEffect((): void => { + retrieveAll().then(setDefaults); + }, []); + + useEffect((): void => { + setStrings(languageCache[lng]); + setProgress(calcProgress(english, languageCache[lng])); + }, [english, lng]); + + useEffect((): void => { + setLng( + keys.some(({ value }) => value === uiSettings.i18nLang) + ? uiSettings.i18nLang + : 'zh' + ); + }, [keys]); + + const _setString = useCallback( + (key: string, value: string): void => { + setStrings((strings: Strings | null): Strings | null => + strings + ? { ...strings, [key]: value } + : null + ); + + const hasPrevVal = !!languageCache[lng][key]; + const sanitized = value.trim(); + + languageCache[lng][key] = value; + + if (hasPrevVal !== !!sanitized) { + const [progress, breakdown] = calcProgress(english, languageCache[lng]); + + setProgress(([counters]): Progress => { + progress[2] = Math.max(0, progress[0] - counters[0]); + + return [progress, breakdown]; + }); + } + }, + [english, lng] + ); + + const _onDownload = useCallback( + () => doDownload(strings || {}), + [strings] + ); + + if (!keys.length) { + return ; + } + + return ( +
+
+ + + + + {t('{{done}}/{{total}}, {{progress}}% done', { replace: progressDisplay(modProgress) })} + + + + + {t('{{done}}/{{total}}, {{progress}}% done', { replace: progressDisplay(allProgress[record]) })} + + +
+ +
+ ); +} + +export default React.memo(styled(Translate)` + .ui.progress:last-child { + margin: 0.25rem; + } +`); diff --git a/packages/page-settings/src/index.tsx b/packages/page-settings/src/index.tsx index df59a26939..b75f94998e 100644 --- a/packages/page-settings/src/index.tsx +++ b/packages/page-settings/src/index.tsx @@ -12,6 +12,7 @@ import uiSettings from '@polkadot/ui-settings'; import md from './md/basics.md'; import { useTranslation } from './translate'; import Developer from './Developer'; +import I18n from './I18n'; import Metadata from './Metadata'; import General from './General'; import useCounter from './useCounter'; @@ -42,6 +43,10 @@ function SettingsApp ({ basePath, onStatusChange }: Props): React.ReactElement

+ + + diff --git a/packages/react-components/src/i18n/Backend.ts b/packages/react-components/src/i18n/Backend.ts index f30b3fa1ac..34068db114 100644 --- a/packages/react-components/src/i18n/Backend.ts +++ b/packages/react-components/src/i18n/Backend.ts @@ -2,9 +2,9 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -type Callback = (error: string | null, data: any) => void; +import languageCache from './cache'; -export const languageCache: Record> = {}; +type Callback = (error: string | null, data: any) => void; export default class Backend { type = 'backend' @@ -12,35 +12,20 @@ export default class Backend { static type: 'backend' = 'backend' async read (lng: string, _namespace: string, responder: Callback): Promise { - const cached = languageCache[lng]; + if (!languageCache[lng]) { + try { + const response = await fetch(`locales/${lng}/translation.json`, {}); - if (cached) { - responder(null, cached); + if (!response.ok) { + return responder(`i18n: failed loading ${lng}`, response.status >= 500 && response.status < 600); + } - return; - } - - try { - const response = await fetch(`locales/${lng}/translation.json`, {}); - const { ok, status } = response; - - if (!ok) { - responder(`i18n: failed loading ${lng}`, status >= 500 && status < 600); - - return; + languageCache[lng] = await response.json(); + } catch (error) { + return responder(error.message, false); } - - const data = await response.json(); - - languageCache[lng] = data; - - return responder(null, data); - } catch (error) { - responder(error.message, false); } - } - create (): void { - // no creation + return responder(null, languageCache[lng]); } } diff --git a/packages/react-components/src/i18n/cache.ts b/packages/react-components/src/i18n/cache.ts new file mode 100644 index 0000000000..a7bf6e7af1 --- /dev/null +++ b/packages/react-components/src/i18n/cache.ts @@ -0,0 +1,7 @@ +// Copyright 2017-2020 @polkadot/react-components authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +const languageCache: Record> = {}; + +export default languageCache;