diff --git a/public/locales/en.json b/public/locales/en.json index 0dc270b..66eb22e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -365,7 +365,9 @@ "columnEnglishString": "English string", "importJSON": "Import JSON", - "exportCSV": "Export CSV" + "exportCSV": "Export CSV", + + "importedLanguageTitle": "Imported language" } }, diff --git a/src/pages/settings/translations/LanguagesTable.tsx b/src/pages/settings/translations/LanguagesTable.tsx new file mode 100644 index 0000000..0d146fe --- /dev/null +++ b/src/pages/settings/translations/LanguagesTable.tsx @@ -0,0 +1,93 @@ +// 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 { Table, Progress, Typography, Tooltip } from "antd"; +import { ExclamationCircleOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import { AnalysedLanguages } from "./analyseLangs"; +import { MissingKeysTable } from "./MissingKeysTable"; + +import { Flag } from "@comp/Flag"; + +const { Text } = Typography; + +interface LanguagesTableProps { + loading?: boolean; + analysed?: AnalysedLanguages | undefined; + showExpandableRow?: boolean; +} + +export function LanguagesTable({ + loading, + analysed, + showExpandableRow +}: LanguagesTableProps): JSX.Element { + const { t } = useTranslation(); + + return <> + + {code} + , + + width: 96, + }, + { + 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={{ + rowExpandable: row => !!row.missingKeys?.length && showExpandableRow !== false, + expandedRowRender: row => + }} + />; +} diff --git a/src/pages/settings/translations/MissingKeysTable.tsx b/src/pages/settings/translations/MissingKeysTable.tsx new file mode 100644 index 0000000..44ed192 --- /dev/null +++ b/src/pages/settings/translations/MissingKeysTable.tsx @@ -0,0 +1,41 @@ +// 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 { Table, Typography } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { AnalysedLanguage } from "./analyseLangs"; + +const { Text } = Typography; + +interface MissingKeysTableProps { + lang: AnalysedLanguage; +} + +export function MissingKeysTable({ lang }: MissingKeysTableProps): JSX.Element { + const { t } = useTranslation(); + + return
t("settings.translations.tableUntranslatedKeys")} + size="small" + + dataSource={lang.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/SettingsTranslations.tsx b/src/pages/settings/translations/SettingsTranslations.tsx index 28c95fe..9449cf7 100644 --- a/src/pages/settings/translations/SettingsTranslations.tsx +++ b/src/pages/settings/translations/SettingsTranslations.tsx @@ -2,25 +2,27 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { useState } from "react"; -import { Table, Progress, Typography, Tooltip, Button } from "antd"; -import { - ExclamationCircleOutlined, FileExcelOutlined -} from "@ant-design/icons"; +import { Button, Typography } from "antd"; +import { FileExcelOutlined } from "@ant-design/icons"; import { FileDrop } from "react-file-drop"; import { useTranslation } from "react-i18next"; import { useMountEffect } from "@utils"; +import { useSelector } from "react-redux"; +import { RootState } from "@store"; + import { saveAs } from "file-saver"; import { analyseLanguages, AnalysedLanguages } from "./analyseLangs"; import { importJSON } from "./importJSON"; import { generateLanguageCSV } from "./exportCSV"; +import { LanguagesTable } from "./LanguagesTable"; +import { MissingKeysTable } from "./MissingKeysTable"; -import { Flag } from "@comp/Flag"; import { SmallResult } from "@comp/results/SmallResult"; import { SettingsPageLayout } from "../SettingsPage"; -const { Text } = Typography; +const { Title } = Typography; export function SettingsTranslations(): JSX.Element { const { t } = useTranslation(); @@ -28,6 +30,8 @@ const [loading, setLoading] = useState(true); const [analysed, setAnalysed] = useState(); + const importedLang = useSelector((s: RootState) => s.settings.importedLang); + async function onExportCSV() { if (!analysed) return; @@ -80,100 +84,20 @@ onFrameDrop={e => importJSON(e?.dataTransfer?.files)} > + + {/* Show stats for imported language */} + {importedLang && <> +
+ + {t("settings.translations.importedLanguageTitle")} + + + {/* Show the progress and key count (lazily) */} + + + {/* Show the missing keys */} + + } ; } - -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 index d627210..0ef0158 100644 --- a/src/pages/settings/translations/analyseLangs.ts +++ b/src/pages/settings/translations/analyseLangs.ts @@ -8,7 +8,7 @@ export interface LangKeys { [key: string]: string } export interface AnalysedLanguage { code: string; - language: Language; + language?: Language; error?: string; keys?: LangKeys; @@ -16,20 +16,13 @@ missingKeys?: { k: string; v: string }[]; } -// Replaced by webpack DefinePlugin and git-revision-webpack-plugin -declare const __GIT_VERSION__: string; -const gitVersion: string = __GIT_VERSION__; - const IGNORE_KEYS = /_(?:plural|interval|male|female|\d+)$/; -export async function getLanguage([code, language]: [string, Language]): Promise { - const res = await fetch(`/locales/${code}.json?v=${encodeURIComponent(gitVersion)}`); - if (!res.ok) throw new Error(res.statusText); - - // Translations now use JSON5 to allow for comments, newlines, and basic - // syntax errors like trailing commas - const data = await res.text(); - const translation = JSON5.parse(data); - +export function analyseLanguage( + code: string, + language: Language | undefined, + enKeys: Record | undefined, + translation: any +): AnalysedLanguage { const isObject = (val: any) => typeof val === "object" && !Array.isArray(val); const addDelimiter = (a: string, b: string) => a ? `${a}.${b}` : b; @@ -48,7 +41,26 @@ const langKeys = keys(translation); - return { code, language, keys: langKeys, keyCount: Object.keys(langKeys).length }; + return { + code, + language, + keys: langKeys, + keyCount: Object.keys(langKeys).length, + missingKeys: enKeys + ? Object.entries(enKeys) + .filter(([k]) => !langKeys[k]) + .map(([k, v]) => ({ k, v })) + : [] + }; +} + +export async function getEnglishData(): Promise { + const languages = getLanguages(); + // Fetch and analyse the data for English first + const enLang = languages!["en"]; + const enData = await getLanguageData("en"); + const en = analyseLanguage("en", enLang, undefined, enData); + return en; } export interface AnalysedLanguages { @@ -60,28 +72,43 @@ 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)); + // Fetch and analyse the data for English first + const en = await getEnglishData(); - 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; + // Fetch the locale file for each language code + const langEntries = Object.entries(languages); + const languageData = await Promise.allSettled( + langEntries.map(e => getLanguageData(e[0])) + ); return { - enKeyCount, + enKeyCount: en.keyCount, + // If a language couldn't be fetched, show an error for it instead 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() }) + ? analyseLanguage( + langEntries[i][0], + langEntries[i][1], + en.keys, + result.value + ) + : { + code: langEntries[i][0], + language: langEntries[i][1], + keyCount: 0, + error: result.reason.toString() + }) }; } + +// Replaced by webpack DefinePlugin and git-revision-webpack-plugin +declare const __GIT_VERSION__: string; +const gitVersion: string = __GIT_VERSION__; +async function getLanguageData(code: string): Promise { + const res = await fetch(`/locales/${code}.json?v=${encodeURIComponent(gitVersion)}`); + if (!res.ok) throw new Error(res.statusText); + + // Translations now use JSON5 to allow for comments, newlines, and basic + // syntax errors like trailing commas + const data = await res.text(); + return JSON5.parse(data); +} diff --git a/src/pages/settings/translations/exportCSV.ts b/src/pages/settings/translations/exportCSV.ts index a3cd977..9f77cb1 100644 --- a/src/pages/settings/translations/exportCSV.ts +++ b/src/pages/settings/translations/exportCSV.ts @@ -20,8 +20,8 @@ // Merge all the languages and their keys together into one array const data = languages.reduce((out, lang) => { const { code, language, keys } = lang; + if (code === "und" || !keys || !language) return out; const languageName = language.name; - if (!keys) return out; // Keys from both en and this language const combinedKeys = [...new Set([...enKeyNames, ...Object.keys(keys)])]; diff --git a/src/pages/settings/translations/importJSON.ts b/src/pages/settings/translations/importJSON.ts index 62f8cd4..566448d 100644 --- a/src/pages/settings/translations/importJSON.ts +++ b/src/pages/settings/translations/importJSON.ts @@ -5,29 +5,34 @@ import i18n from "@utils/i18n"; import JSON5 from "json5"; +import { store } from "@app"; +import { setImportedLang } from "@store/actions/SettingsActions"; + +import { getEnglishData, analyseLanguage } from "./analyseLangs"; + import Debug from "debug"; const debug = Debug("kristweb:settings-import-json"); -function importLanguage(contents: string) { - try { - // Parse the imported language - const resources = JSON5.parse(contents); +async function importLanguage(contents: string) { + // Parse the imported language + const resources = JSON5.parse(contents); - // Update the language - i18n.addResourceBundle("und", "translation", resources, true, true); - i18n.changeLanguage("und"); + // 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." }); - } + // Analyse the language to show on the settings page + const en = await getEnglishData(); + const analysed = analyseLanguage("und", undefined, en.keys, resources); + store.dispatch(setImportedLang({ + enKeyCount: en.keyCount, + languages: [analysed] + })); + + notification.success({ + message: "Translation successfully imported.", + description: "Language will be reset to English on next reload." + }); } export function importJSON(files?: FileList | null): void { @@ -53,6 +58,13 @@ const contents = e.target.result.toString(); debug("got file contents: %s", contents); - importLanguage(contents); + importLanguage(contents) + .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." }); + }); }; } diff --git a/src/store/actions/SettingsActions.ts b/src/store/actions/SettingsActions.ts index 37442af..566ad70 100644 --- a/src/store/actions/SettingsActions.ts +++ b/src/store/actions/SettingsActions.ts @@ -8,6 +8,8 @@ import { State } from "@reducers/SettingsReducer"; +import { AnalysedLanguages } from "@pages/settings/translations/analyseLangs"; + // Boolean settings export interface SetBooleanSettingPayload { settingName: keyof PickByValue; @@ -25,3 +27,6 @@ export const setIntegerSetting = createAction(constants.SET_INTEGER_SETTING, (settingName, value): SetIntegerSettingPayload => ({ settingName, value }))(); + +// Set imported language +export const setImportedLang = createAction(constants.SET_IMPORTED_LANG)(); diff --git a/src/store/constants.ts b/src/store/constants.ts index 7ff91d8..03dc9ae 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -23,6 +23,7 @@ // --- export const SET_BOOLEAN_SETTING = "SET_BOOLEAN_SETTING"; export const SET_INTEGER_SETTING = "SET_INTEGER_SETTING"; +export const SET_IMPORTED_LANG = "SET_IMPORTED_LANG"; // Websockets // --- diff --git a/src/store/reducers/SettingsReducer.ts b/src/store/reducers/SettingsReducer.ts index 9a3577d..22545a3 100644 --- a/src/store/reducers/SettingsReducer.ts +++ b/src/store/reducers/SettingsReducer.ts @@ -3,12 +3,22 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { createReducer } from "typesafe-actions"; import { loadSettings, SettingsState } from "@utils/settings"; -import { setBooleanSetting, setIntegerSetting } from "@actions/SettingsActions"; +import { + setBooleanSetting, setIntegerSetting, setImportedLang +} from "@actions/SettingsActions"; -export type State = SettingsState; +import { AnalysedLanguages } from "@pages/settings/translations/analyseLangs"; + +export type State = SettingsState & { + /** Language imported by JSON in the translations debug page. */ + readonly importedLang?: AnalysedLanguages; +}; export function getInitialSettingsState(): State { - return loadSettings(); + return { + ...loadSettings(), + importedLang: undefined + }; } export const SettingsReducer = createReducer({} as State) @@ -19,4 +29,8 @@ .handleAction(setIntegerSetting, (state, action) => ({ ...state, [action.payload.settingName]: action.payload.value + })) + .handleAction(setImportedLang, (state, { payload }) => ({ + ...state, + importedLang: payload }));