// 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 "../../components/Flag";
import { SmallResult } from "../../components/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<AnalysedLanguage> {
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<string> {
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 <SmallResult
status="error"
title={t("error")}
subTitle={t("settings.translations.errorMissingLanguages")}
/>;
return <SettingsPageLayout pageName="Translations" extra={
<Button icon={<FileExcelOutlined />} onClick={exportCSV}>
{t("settings.translations.exportCSV")}
</Button>
}>
<Table
loading={loading}
size="small"
dataSource={analysed?.languages}
rowKey="code"
pagination={{ hideOnSinglePage: true }}
columns={[
{
title: t("settings.translations.columnLanguageCode"),
dataIndex: "code",
key: "code",
render: (code, row) => <>
<Flag code={row?.language?.country} style={{ width: 22, height: 15, marginRight: 8 }} />
{code}
</>,
width: 64,
},
{
title: t("settings.translations.columnLanguage"),
dataIndex: ["language", "name"],
key: "language",
render: (_, row) => <>
{row?.language?.name}
{row?.language?.nativeName && <Text type="secondary"> ({row.language.nativeName})</Text>}
</>
},
{
title: t("settings.translations.columnKeys"),
key: "keys",
render: (_, row) => row.error || !row.keys
? (
<Tooltip title={row.error || t("settings.translations.errorNoKeys")}>
<Text type="danger"><ExclamationCircleOutlined /></Text>
</Tooltip>
)
: <>{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 &&
<Progress percent={Math.round(Math.min(row.keyCount / (analysed?.enKeyCount || 1), 1) * 100)} />)
}
]}
expandable={{
expandedRowRender: row => row.missingKeys && <Table
title={() => t("settings.translations.tableUntranslatedKeys")}
size="small"
dataSource={row.missingKeys}
rowKey="k"
columns={[
{
title: t("settings.translations.columnKey"),
dataIndex: "k",
key: "k",
render: k => <Text code copyable>{k}</Text>
},
{
title: t("settings.translations.columnEnglishString"),
dataIndex: "v",
key: "v",
render: v => <Text copyable>{v}</Text>
}
]}
/>
}}
/>
</SettingsPageLayout>;
}