diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index ca18d79..e896732 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -8,6 +8,14 @@ container: node:15 steps: + - name: Install git + run: | + sudo apt-get install -y software-properties-common \ + && sudo apt-get update \ + && sudo add-apt-repository -y ppa:git-core/ppa \ + && sudo apt-get update \ + && sudo apt-get install -y git + - name: Check out repository code uses: actions/checkout@v2 diff --git a/package.json b/package.json index 366aaca..c69b961 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react": "^17.0.1", "react-chartjs-2": "^2.11.1", "react-dom": "^17.0.1", + "react-file-drop": "^3.1.2", "react-hotkeys": "^2.0.0", "react-i18next": "^11.8.8", "react-linkify": "^1.0.0-alpha", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3df7b7..214223b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,7 @@ eslint-plugin-react-hooks: 4.2.0_eslint@7.21.0 eslint-plugin-tsdoc: 0.2.11 git-revision-webpack-plugin: 3.0.6 + react-file-drop: 3.1.2_react-dom@17.0.1+react@17.0.1 react-refresh: 0.9.0 react-scripts: 4.0.3_react@17.0.1+typescript@4.1.5 redux-devtools-extension: 2.13.8_redux@4.0.5 @@ -10904,6 +10905,17 @@ dev: true resolution: integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== + /react-file-drop/3.1.2_react-dom@17.0.1+react@17.0.1: + dependencies: + prop-types: 15.7.2 + react: 17.0.1 + react-dom: 17.0.1_react@17.0.1 + dev: true + peerDependencies: + react: ^16.13.1 + react-dom: ^16.13.1 + resolution: + integrity: sha512-fhujAloK9AIDzu18ltGrYe8Pk9NuXdCc8MrFgLNPJJS22B/GdTwD7Pz4BCCUiX+4sFZr42L4AZM7RsJN2xu1BQ== /react-hotkeys/2.0.0_react@17.0.1: dependencies: prop-types: 15.7.2 @@ -13871,6 +13883,7 @@ react: ^17.0.1 react-chartjs-2: ^2.11.1 react-dom: ^17.0.1 + react-file-drop: ^3.1.2 react-hotkeys: ^2.0.0 react-i18next: ^11.8.8 react-linkify: ^1.0.0-alpha diff --git a/public/locales/en.json b/public/locales/en.json index e768c53..4ac36fb 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -42,7 +42,6 @@ "sidebar": { "totalBalance": "Total Balance", - "guestIndicator": "Browsing as guest", "dashboard": "Dashboard", "myWallets": "My Wallets", "addressBook": "Address Book", @@ -98,14 +97,10 @@ "dialogTitle": "Master password", "passwordPlaceholder": "Master password", "passwordConfirmPlaceholder": "Confirm master password", - "browseAsGuest": "Browse as guest", "createPassword": "Create password", "logIn": "Log in", "forgotPassword": "Forgot password?", - "intro": "Enter a master password to encrypt your wallets, or browse KristWeb as a guest <1>.", "intro2": "Enter a <1>master password to encrypt your wallet private keys. They will be saved in your browser's local storage, and you will be asked for the master password to decrypt them once per session.", - "dontForgetPassword": "Never forget this password. If you forget it, you will have to create a new password and add your wallets all over again.", - "loginIntro": "Enter a master password to access your wallets, or browse KristWeb as a guest.", "learnMore": "learn more", "errorPasswordRequired": "Password is required.", "errorPasswordLength": "Must be at least 1 character.", @@ -116,7 +111,6 @@ "errorNoPassword": "Master password is required.", "errorUnknown": "Unknown error.", "helpWalletStorageTitle": "Help: Wallet storage", - "helpWalletStorage": "When you add a wallet to KristWeb, the private key for the wallet is saved to your browser's local storage and encrypted with your master password.\nEvery wallet you save is encrypted using the same master password, and you will need to enter it every time you open KristWeb. Your actual Krist wallet is not modified in any way.\nWhen browsing KristWeb as a guest, you do not need to enter a master password, but it also means that you will not be able to add or use any wallets. You will still be able to explore the Krist network.", "popoverTitle": "Decrypt wallets", "popoverTitleEncrypt": "Encrypt wallets", "popoverAuthoriseButton": "Authorise", @@ -361,6 +355,7 @@ "columnKey": "Key", "columnEnglishString": "English string", + "importJSON": "Import JSON", "exportCSV": "Export CSV" } }, diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 6a1c091..bd2f78a 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -16,7 +16,7 @@ import { NamePage } from "@pages/names/NamePage"; import { SettingsPage } from "@pages/settings/SettingsPage"; -import { SettingsTranslations } from "@pages/settings/SettingsTranslations"; +import { SettingsTranslations } from "@pages/settings/translations/SettingsTranslations"; import { CreditsPage } from "@pages/credits/CreditsPage"; diff --git a/src/layout/AppLayout.less b/src/layout/AppLayout.less index 965ef8d..f7b6528 100644 --- a/src/layout/AppLayout.less +++ b/src/layout/AppLayout.less @@ -3,177 +3,6 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt @import (reference) "../App.less"; -.site-header + .ant-layout { - z-index: 1; - - margin-top: @layout-header-height; -} - -.site-header { - position: fixed; - top: 0; - left: 0; - right: 0; - - display: flex; - flex-direction: row; - - border-bottom: 1px solid @kw-border-color-darker; - padding: 0; - - z-index: 2; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - - .site-header-brand { - width: @kw-sidebar-width; - - display: flex; - align-items: center; - justify-content: center; - - // Make it full height - align-self: stretch; - // Force the search bar to shrink instead - flex-shrink: 0; - - font-size: 1.25rem; - user-select: none; - - border-right: 1px solid @kw-border-color-darker; - - a { - color: @text-color; - text-align: center; - - &:hover { - text-decoration: none; - } - } - - &-version { - margin-left: 0.25em; - - color: @text-color-secondary; - font-size: 60%; - } - - .ant-tag { - margin-left: 0.5em; - margin-right: 0; - } - } - - > .ant-menu { - background: transparent; - flex-shrink: 0; - - .ant-menu-item { - height: @layout-header-height; - - // Fix background overlapping border - border-bottom: 1px solid @kw-border-color-darker; - } - } - - .site-header-sidebar-toggle { - border-right: 1px solid @kw-border-color-darker; - } - - .site-header-search-container { - padding: 0 1rem; - width: 100%; - - display: flex; - align-items: center; - justify-content: center; - - .site-header-search { - width: 100%; - max-width: 400px; - - margin-left: auto; - - background: @kw-header-search-bg; - color: @kw-header-search-color; - border-radius: @border-radius-base; - - transition: all @animation-duration-base ease; - - &::placeholder { - color: @kw-header-search-placeholder-color; - - transition: all @animation-duration-base ease; - - font-size: @kw-header-search-placeholder-font-size; - vertical-align: middle; - } - - &:hover { - background: @kw-header-search-hover-bg; - color: @kw-header-search-hover-color; - } - - &.ant-select-focused { - background: @kw-header-search-focus-bg; - color: @kw-header-search-focus-color; - - .ant-input { - // Remove the built in focus indicator - box-shadow: none; - } - } - - .ant-input-group, .ant-input { - background: transparent; - border: none; - } - - .ant-input-group-addon { - border: none; - background: transparent; - - .ant-input-search-button { - border: none; - background: transparent; - - .anticon { - vertical-align: middle; - } - } - } - - // Make the search box full width on mobile - @media (max-width: @screen-md) { - max-width: 100%; - } - } - } - - .site-header-element { - flex: 0; - height: 50px; - - display: flex; - align-items: center; - justify-content: center; - - margin-right: 16px; - - .site-header-cymbal { - color: @text-color-secondary; - font-size: 20px; - } - } - - .site-header-settings { - border-left: 1px solid @kw-border-color-darker; - - .anticon { - margin-right: 0; - } - } -} - .site-layout { min-height: calc(100vh - @layout-header-height); diff --git a/src/layout/nav/AppHeader.less b/src/layout/nav/AppHeader.less new file mode 100644 index 0000000..1daf25e --- /dev/null +++ b/src/layout/nav/AppHeader.less @@ -0,0 +1,175 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.site-header + .ant-layout { + z-index: 1; + + margin-top: @layout-header-height; +} + +.site-header { + position: fixed; + top: 0; + left: 0; + right: 0; + + display: flex; + flex-direction: row; + + border-bottom: 1px solid @kw-border-color-darker; + padding: 0; + + z-index: 2; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + + .site-header-brand { + width: @kw-sidebar-width; + + display: flex; + align-items: center; + justify-content: center; + + // Make it full height + align-self: stretch; + // Force the search bar to shrink instead + flex-shrink: 0; + + font-size: 1.25rem; + user-select: none; + + border-right: 1px solid @kw-border-color-darker; + + a { + color: @text-color; + text-align: center; + + &:hover { + text-decoration: none; + } + } + + &-version { + margin-left: 0.25em; + + color: @text-color-secondary; + font-size: 60%; + } + + .ant-tag { + margin-left: 0.5em; + margin-right: 0; + } + } + + > .ant-menu { + background: transparent; + flex-shrink: 0; + + .ant-menu-item { + height: @layout-header-height; + + // Fix background overlapping border + border-bottom: 1px solid @kw-border-color-darker; + } + } + + .site-header-sidebar-toggle { + border-right: 1px solid @kw-border-color-darker; + } + + .site-header-search-container { + padding: 0 1rem; + width: 100%; + + display: flex; + align-items: center; + justify-content: center; + + .site-header-search { + width: 100%; + max-width: 400px; + + margin-left: auto; + + background: @kw-header-search-bg; + color: @kw-header-search-color; + border-radius: @border-radius-base; + + transition: all @animation-duration-base ease; + + &::placeholder { + color: @kw-header-search-placeholder-color; + + transition: all @animation-duration-base ease; + + font-size: @kw-header-search-placeholder-font-size; + vertical-align: middle; + } + + &:hover { + background: @kw-header-search-hover-bg; + color: @kw-header-search-hover-color; + } + + &.ant-select-focused { + background: @kw-header-search-focus-bg; + color: @kw-header-search-focus-color; + + .ant-input { + // Remove the built in focus indicator + box-shadow: none; + } + } + + .ant-input-group, .ant-input { + background: transparent; + border: none; + } + + .ant-input-group-addon { + border: none; + background: transparent; + + .ant-input-search-button { + border: none; + background: transparent; + + .anticon { + vertical-align: middle; + } + } + } + + // Make the search box full width on mobile + @media (max-width: @screen-md) { + max-width: 100%; + } + } + } + + .site-header-element { + flex: 0; + height: 50px; + + display: flex; + align-items: center; + justify-content: center; + + margin-right: 16px; + + .site-header-cymbal { + color: @text-color-secondary; + font-size: 20px; + } + } + + .site-header-settings { + border-left: 1px solid @kw-border-color-darker; + + .anticon { + margin-right: 0; + } + } +} diff --git a/src/layout/nav/AppHeader.tsx b/src/layout/nav/AppHeader.tsx index 624a678..33820e8 100644 --- a/src/layout/nav/AppHeader.tsx +++ b/src/layout/nav/AppHeader.tsx @@ -14,6 +14,8 @@ import { ConditionalLink } from "@comp/ConditionalLink"; +import "./AppHeader.less"; + const { useBreakpoint } = Grid; interface Props { diff --git a/src/pages/backup/backupParser.ts b/src/pages/backup/backupParser.ts index 9679df1..43bfc64 100644 --- a/src/pages/backup/backupParser.ts +++ b/src/pages/backup/backupParser.ts @@ -49,7 +49,7 @@ throw new TranslatedError("import.decodeErrors.atob"); // Invalid json - if (err instanceof SyntaxError) + if (err?.name === "SyntaxError") throw new TranslatedError("import.decodeErrors.json"); throw err; diff --git a/src/pages/settings/LanguageItem.tsx b/src/pages/settings/LanguageItem.tsx deleted file mode 100644 index 7808a3e..0000000 --- a/src/pages/settings/LanguageItem.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2020-2021 Drew Lemmy -// This file is part of KristWeb 2 under GPL-3.0. -// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import React, { FC } from "react"; -import classNames from "classnames"; -import { Menu } from "antd"; - -import { useTranslation } from "react-i18next"; -import { getLanguages, Language } from "@utils/i18n"; - -import { Flag } from "@comp/Flag"; - -interface LanguageItemProps { - code: string; - lang: Language; -} -const LanguageItem: FC = ({ code, lang, ...props }): JSX.Element => { - const { i18n } = useTranslation(); - - const isCurrent = i18n.language === code; - const classes = classNames("settings-language-item", { - "settings-language-item-current": isCurrent - }); - - function changeLanguage() { - i18n.changeLanguage(code); - } - - return - {/* Flag of language country */} - - - {/* Language name */} - {lang.name} - - {/* Native name, if applicable */} - {lang.nativeName && ( - - ({lang.nativeName}) - - )} - ; -}; - -export function getLanguageItems(): JSX.Element[] { - const languages = getLanguages(); - if (!languages) return []; - - return Object.entries(languages) - .map(([code, lang]) => ( - - )); -} diff --git a/src/pages/settings/SettingsPage.less b/src/pages/settings/SettingsPage.less index 606a5b7..86efcbc 100644 --- a/src/pages/settings/SettingsPage.less +++ b/src/pages/settings/SettingsPage.less @@ -30,6 +30,16 @@ } } + .settings-translations-extra { + // Force the fake label button to vertically align correctly + display: flex; + + .ant-btn { + margin-right: @margin-sm; + &:last-child { margin-right: 0; } + } + } + .menu-item-setting-integer { .ant-input-group.ant-input-group-compact { display: inline-block; diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 877fc4a..f173f2e 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -11,7 +11,7 @@ import { PageLayout, PageLayoutProps } from "../../layout/PageLayout"; import { SettingBoolean } from "./SettingBoolean"; import { SettingInteger } from "./SettingInteger"; -import { getLanguageItems } from "./LanguageItem"; +import { getLanguageItems } from "./translations/LanguageItem"; import "./SettingsPage.less"; diff --git a/src/pages/settings/SettingsTranslations.tsx b/src/pages/settings/SettingsTranslations.tsx deleted file mode 100644 index 677ff81..0000000 --- a/src/pages/settings/SettingsTranslations.tsx +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright (c) 2020-2021 Drew Lemmy -// This file is part of KristWeb 2 under GPL-3.0. -// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import React, { useState } from "react"; -import { Table, Progress, Typography, Tooltip, Button } from "antd"; -import { ExclamationCircleOutlined, FileExcelOutlined } from "@ant-design/icons"; - -import { useTranslation } from "react-i18next"; -import { getLanguages, Language } from "@utils/i18n"; -import { useMountEffect } from "@utils"; - -import csvStringify from "csv-stringify"; -import { saveAs } from "file-saver"; - -import { Flag } from "@comp/Flag"; -import { SmallResult } from "@comp/results/SmallResult"; -import { SettingsPageLayout } from "./SettingsPage"; - -const { Text } = Typography; - -interface LangKeys { [key: string]: string } -interface AnalysedLanguage { - code: string; - language: Language; - - error?: string; - keys?: LangKeys; - keyCount: number; - missingKeys?: { k: string; v: string }[]; -} - -const IGNORE_KEYS = /_(?:plural|interval|male|female|\d+)$/; -async function getLanguage([code, language]: [string, Language]): Promise { - const res = await fetch(`/locales/${code}.json`); - if (!res.ok) throw new Error(res.statusText); - - const translation = await res.json(); - - const isObject = (val: any) => typeof val === "object" && !Array.isArray(val); - const addDelimiter = (a: string, b: string) => a ? `${a}.${b}` : b; - - // Find all translation keys recursively - const keys = (obj: any = {}, head = ""): LangKeys => - Object.entries(obj) - .reduce((product, [key, value]) => { - // Ignore plurals, etc. - if (IGNORE_KEYS.test(key)) return product; - - const fullPath = addDelimiter(head, key); - return isObject(value as any) - ? { ...product, ...keys(value, fullPath) } - : {...product, [fullPath]: value }; - }, {}); - - const langKeys = keys(translation); - - return { code, language, keys: langKeys, keyCount: Object.keys(langKeys).length }; -} - -interface CSVRow { - Code: string; - Language?: string; - Key: string; - Value?: string; -} -async function generateLanguageCSV(languages: AnalysedLanguage[]): Promise { - return new Promise((resolve, reject) => { - const en = languages.find(l => l.code === "en"); - if (!en) return reject("en missing"); - const enKeyNames = Object.keys(en.keys || {}); - - // Merge all the languages and their keys together into one array - const data = languages.reduce((out, lang) => { - const { code, language, keys } = lang; - const languageName = language.name; - if (!keys) return out; - - // Keys from both en and this language - const combinedKeys = [...new Set([...enKeyNames, ...Object.keys(keys)])]; - // Find the value for this key from the language, or null if not - const keysWithValues = combinedKeys.map(k => [k, keys[k]]); - - // Generate all the rows for this language - return [ - ...out, - ...keysWithValues.map(([k, v]) => ({ - "Code": code, - "Language": languageName, - "Key": k, "Value": v - })) - ]; - }, [] as CSVRow[]); - - csvStringify(data, { header: true, quoted: true }, (err, data) => { - if (err) return reject(err); - resolve(data); - }); - }); -} - -export function SettingsTranslations(): JSX.Element { - const { t } = useTranslation(); - - const [loading, setLoading] = useState(true); - const [analysed, setAnalysed] = useState<{ - enKeyCount: number; - languages: AnalysedLanguage[]; - } | undefined>(); - - const languages = getLanguages(); - - async function loadLanguages() { - if (!languages) return; - - // Fetch the locale file for each language code - const codes = Object.entries(languages); - const languageData = await Promise.allSettled(codes.map(getLanguage)); - - const en = languageData.find(l => l.status === "fulfilled" && l.value.code === "en"); - const enKeys = en?.status === "fulfilled" ? en?.value.keys || {} : {}; - const enKeyCount = enKeys ? Object.keys(enKeys).length : 1; - - setLoading(false); - setAnalysed({ - enKeyCount, - languages: languageData.map((result, i) => result.status === "fulfilled" - ? { - code: codes[i][0], - language: codes[i][1], - keys: result.value.keys, - keyCount: result.value.keyCount, - missingKeys: result.value.keys - ? Object.entries(enKeys) - .filter(([k]) => result.value.keys && !result.value.keys[k]) - .map(([k, v]) => ({ k, v })) - : [] - } - : { code: codes[i][0], language: codes[i][1], keyCount: 0, error: result.reason.toString() }) - }); - } - - async function exportCSV() { - if (!analysed) return; - - const data = await generateLanguageCSV(analysed.languages); - const blob = new Blob([data], { type: "text/csv;charset=utf-8" }); - saveAs(blob, "kristweb-translations.csv"); - } - - useMountEffect(() => { loadLanguages().catch(console.error); }); - - if (!languages) return ; - - return } onClick={exportCSV}> - {t("settings.translations.exportCSV")} - - }> - <> - - {code} - , - - width: 64, - }, - { - title: t("settings.translations.columnLanguage"), - dataIndex: ["language", "name"], - key: "language", - render: (_, row) => <> - {row?.language?.name} - {row?.language?.nativeName &&  ({row.language.nativeName})} - - }, - { - title: t("settings.translations.columnKeys"), - key: "keys", - render: (_, row) => row.error || !row.keys - ? ( - - - - ) - : <>{row.keyCount.toLocaleString()}, - sorter: (a, b) => a.keyCount - b.keyCount - }, - { - title: t("settings.translations.columnMissingKeys"), - key: "missingKeys", - render: (_, row) => (!row.error && row.keys && - <>{Math.max((analysed?.enKeyCount || 1) - row.keyCount, 0).toLocaleString()}), - sorter: (a, b) => b.keyCount - a.keyCount - }, - { - title: t("settings.translations.columnProgress"), - key: "progress", - render: (_, row) => (!row.error && row.keys && - ) - } - ]} - - expandable={{ - expandedRowRender: row => row.missingKeys &&
t("settings.translations.tableUntranslatedKeys")} - size="small" - - dataSource={row.missingKeys} - rowKey="k" - - columns={[ - { - title: t("settings.translations.columnKey"), - dataIndex: "k", - key: "k", - render: k => {k} - }, - { - title: t("settings.translations.columnEnglishString"), - dataIndex: "v", - key: "v", - render: v => {v} - } - ]} - /> - }} - /> - ; -} diff --git a/src/pages/settings/translations/LanguageItem.tsx b/src/pages/settings/translations/LanguageItem.tsx new file mode 100644 index 0000000..7808a3e --- /dev/null +++ b/src/pages/settings/translations/LanguageItem.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React, { FC } from "react"; +import classNames from "classnames"; +import { Menu } from "antd"; + +import { useTranslation } from "react-i18next"; +import { getLanguages, Language } from "@utils/i18n"; + +import { Flag } from "@comp/Flag"; + +interface LanguageItemProps { + code: string; + lang: Language; +} +const LanguageItem: FC = ({ code, lang, ...props }): JSX.Element => { + const { i18n } = useTranslation(); + + const isCurrent = i18n.language === code; + const classes = classNames("settings-language-item", { + "settings-language-item-current": isCurrent + }); + + function changeLanguage() { + i18n.changeLanguage(code); + } + + return + {/* Flag of language country */} + + + {/* Language name */} + {lang.name} + + {/* Native name, if applicable */} + {lang.nativeName && ( + + ({lang.nativeName}) + + )} + ; +}; + +export function getLanguageItems(): JSX.Element[] { + const languages = getLanguages(); + if (!languages) return []; + + return Object.entries(languages) + .map(([code, lang]) => ( + + )); +} diff --git a/src/pages/settings/translations/SettingsTranslations.tsx b/src/pages/settings/translations/SettingsTranslations.tsx new file mode 100644 index 0000000..72735b2 --- /dev/null +++ b/src/pages/settings/translations/SettingsTranslations.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React, { useState } from "react"; +import { Table, Progress, Typography, Tooltip, Button } from "antd"; +import { + ExclamationCircleOutlined, FileExcelOutlined +} from "@ant-design/icons"; +import { FileDrop } from "react-file-drop"; + +import { useTranslation } from "react-i18next"; +import { useMountEffect } from "@utils"; + +import { saveAs } from "file-saver"; +import { analyseLanguages, AnalysedLanguages } from "./analyseLangs"; +import { importJSON } from "./importJSON"; +import { generateLanguageCSV } from "./exportCSV"; + +import { Flag } from "@comp/Flag"; +import { SmallResult } from "@comp/results/SmallResult"; +import { SettingsPageLayout } from "../SettingsPage"; + +const { Text } = Typography; + +export function SettingsTranslations(): JSX.Element { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(true); + const [analysed, setAnalysed] = useState(); + + async function onExportCSV() { + if (!analysed) return; + + const data = await generateLanguageCSV(analysed.languages); + const blob = new Blob([data], { type: "text/csv;charset=utf-8" }); + saveAs(blob, "kristweb-translations.csv"); + } + + useMountEffect(() => { + analyseLanguages() + .then(setAnalysed) + .catch(console.error) + .finally(() => setLoading(false)); + }); + + if (analysed === false) { + return ; + } + + return + {/* Import JSON button */} + {/* Pretend to be an ant-design button (see ImportBackupModal.tsx) */} + + importJSON(e.target?.files)} + style={{ display: "none" }} + /> + + {/* Export CSV button */} + + } + > + importJSON(e?.dataTransfer?.files)} + > + + + ; +} + +interface LanguagesTableProps { + loading: boolean; + analysed?: AnalysedLanguages | undefined; +} + +function LanguagesTable({ loading, analysed }: LanguagesTableProps): JSX.Element { + const { t } = useTranslation(); + + return
<> + + {code} + , + + width: 64, + }, + { + title: t("settings.translations.columnLanguage"), + dataIndex: ["language", "name"], + key: "language", + render: (_, row) => <> + {row?.language?.name} + {row?.language?.nativeName &&  ({row.language.nativeName})} + + }, + { + title: t("settings.translations.columnKeys"), + key: "keys", + render: (_, row) => row.error || !row.keys + ? ( + + + + ) + : <>{row.keyCount.toLocaleString()}, + sorter: (a, b) => a.keyCount - b.keyCount + }, + { + title: t("settings.translations.columnMissingKeys"), + key: "missingKeys", + render: (_, row) => (!row.error && row.keys && + <>{Math.max((analysed?.enKeyCount || 1) - row.keyCount, 0).toLocaleString()}), + sorter: (a, b) => b.keyCount - a.keyCount + }, + { + title: t("settings.translations.columnProgress"), + key: "progress", + render: (_, row) => (!row.error && row.keys && + ) + } + ]} + + expandable={{ + expandedRowRender: row => row.missingKeys &&
t("settings.translations.tableUntranslatedKeys")} + size="small" + + dataSource={row.missingKeys} + rowKey="k" + + columns={[ + { + title: t("settings.translations.columnKey"), + dataIndex: "k", + key: "k", + render: k => {k} + }, + { + title: t("settings.translations.columnEnglishString"), + dataIndex: "v", + key: "v", + render: v => {v} + } + ]} + /> + }} + />; +} diff --git a/src/pages/settings/translations/analyseLangs.ts b/src/pages/settings/translations/analyseLangs.ts new file mode 100644 index 0000000..372ef3f --- /dev/null +++ b/src/pages/settings/translations/analyseLangs.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { Language, getLanguages } from "@utils/i18n"; + +export interface LangKeys { [key: string]: string } +export interface AnalysedLanguage { + code: string; + language: Language; + + error?: string; + keys?: LangKeys; + keyCount: number; + missingKeys?: { k: string; v: string }[]; +} + +const IGNORE_KEYS = /_(?:plural|interval|male|female|\d+)$/; +export async function getLanguage([code, language]: [string, Language]): Promise { + const res = await fetch(`/locales/${code}.json`); + if (!res.ok) throw new Error(res.statusText); + + const translation = await res.json(); + + const isObject = (val: any) => typeof val === "object" && !Array.isArray(val); + const addDelimiter = (a: string, b: string) => a ? `${a}.${b}` : b; + + // Find all translation keys recursively + const keys = (obj: any = {}, head = ""): LangKeys => + Object.entries(obj) + .reduce((product, [key, value]) => { + // Ignore plurals, etc. + if (IGNORE_KEYS.test(key)) return product; + + const fullPath = addDelimiter(head, key); + return isObject(value as any) + ? { ...product, ...keys(value, fullPath) } + : {...product, [fullPath]: value }; + }, {}); + + const langKeys = keys(translation); + + return { code, language, keys: langKeys, keyCount: Object.keys(langKeys).length }; +} + +export interface AnalysedLanguages { + enKeyCount: number; + languages: AnalysedLanguage[]; +} + +export async function analyseLanguages(): Promise { + const languages = getLanguages(); + if (!languages) return false; + + // Fetch the locale file for each language code + const codes = Object.entries(languages); + const languageData = await Promise.allSettled(codes.map(getLanguage)); + + const en = languageData.find(l => l.status === "fulfilled" && l.value.code === "en"); + const enKeys = en?.status === "fulfilled" ? en?.value.keys || {} : {}; + const enKeyCount = enKeys ? Object.keys(enKeys).length : 1; + + return { + enKeyCount, + languages: languageData.map((result, i) => result.status === "fulfilled" + ? { + code: codes[i][0], + language: codes[i][1], + keys: result.value.keys, + keyCount: result.value.keyCount, + missingKeys: result.value.keys + ? Object.entries(enKeys) + .filter(([k]) => result.value.keys && !result.value.keys[k]) + .map(([k, v]) => ({ k, v })) + : [] + } + : { code: codes[i][0], language: codes[i][1], keyCount: 0, error: result.reason.toString() }) + }; +} diff --git a/src/pages/settings/translations/exportCSV.ts b/src/pages/settings/translations/exportCSV.ts new file mode 100644 index 0000000..a3cd977 --- /dev/null +++ b/src/pages/settings/translations/exportCSV.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import csvStringify from "csv-stringify"; + +import { AnalysedLanguage } from "./analyseLangs"; + +interface CSVRow { + Code: string; + Language?: string; + Key: string; + Value?: string; +} +export async function generateLanguageCSV(languages: AnalysedLanguage[]): Promise { + return new Promise((resolve, reject) => { + const en = languages.find(l => l.code === "en"); + if (!en) return reject("en missing"); + const enKeyNames = Object.keys(en.keys || {}); + + // Merge all the languages and their keys together into one array + const data = languages.reduce((out, lang) => { + const { code, language, keys } = lang; + const languageName = language.name; + if (!keys) return out; + + // Keys from both en and this language + const combinedKeys = [...new Set([...enKeyNames, ...Object.keys(keys)])]; + // Find the value for this key from the language, or null if not + const keysWithValues = combinedKeys.map(k => [k, keys[k]]); + + // Generate all the rows for this language + return [ + ...out, + ...keysWithValues.map(([k, v]) => ({ + "Code": code, + "Language": languageName, + "Key": k, "Value": v + })) + ]; + }, [] as CSVRow[]); + + csvStringify(data, { header: true, quoted: true }, (err, data) => { + if (err) return reject(err); + resolve(data); + }); + }); +} diff --git a/src/pages/settings/translations/importJSON.ts b/src/pages/settings/translations/importJSON.ts new file mode 100644 index 0000000..fad2a97 --- /dev/null +++ b/src/pages/settings/translations/importJSON.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +import { notification } from "antd"; + +import i18n from "@utils/i18n"; + +import Debug from "debug"; +const debug = Debug("kristweb:settings-import-json"); + +function importLanguage(contents: string) { + try { + // Parse the imported language + const resources = JSON.parse(contents); + + // Update the language + i18n.addResourceBundle("und", "translation", resources, true, true); + i18n.changeLanguage("und"); + + notification.success({ + message: "Translation successfully imported.", + description: "Language will be reset to English on next reload." + }); + } catch (err) { + console.error(err); + if (err.name === "SyntaxError") + return notification.error({ message: "Invalid JSON." }); + else + return notification.error({ message: "Unknown error importing custom language." }); + } +} + +export function importJSON(files?: FileList | null): void { + // Note that any errors emitted by this function are intentionally left + // untranslated. This is left as a precaution in case i18n breaks entirely. + if (!files?.[0]) return; + const file = files[0]; + + debug("importing file %s: %o", file.name, file); + + // Disallow non-JSON files + if (file.type !== "application/json") { + notification.error({ message: "Not a JSON file." }); + return; + } + + // Read the file + const reader = new FileReader(); + reader.readAsText(file, "UTF-8"); + reader.onload = e => { + if (!e.target || !e.target.result) return; + + const contents = e.target.result.toString(); + debug("got file contents: %s", contents); + + importLanguage(contents); + }; +} diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 392cb45..ab5cee9 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -1,6 +1,8 @@ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { notification } from "antd"; + import { isLocalhost } from "./"; import i18n from "i18next"; @@ -23,7 +25,8 @@ url?: string; } -export function getLanguages(): { [key: string]: Language } | null { +export type Languages = { [key: string]: Language } | null; +export function getLanguages(): Languages { return languagesJson; } @@ -52,7 +55,7 @@ .use(initReactI18next) .init({ fallbackLng: "en", - supportedLngs: Object.keys(getLanguages() || { "en": {} }), + supportedLngs: [...Object.keys(getLanguages() || { "en": {} }), "und"], debug: isLocalhost, @@ -74,6 +77,17 @@ queryStringParams: { v: packageJson.version }, loadPath: "/locales/{{lng}}.json" } + }) + .then(() => { + // If the language was set to a custom debug language, reset it + if (i18n.language === "und") { + i18n.changeLanguage("en"); + // Intentionally untranslated + notification.info({ + message: "Language reverted to English.", + description: "You were previously using a custom debug translation." + }); + } }); export default i18n;