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
}));