diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index e8ac0dcee3..f0bfafab9f 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -29,6 +29,8 @@ export interface ConfigOptions { // sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate sso_immediate_redirect?: boolean; sso_redirect_options?: ISsoRedirectOptions; + + custom_translations_url?: string; } /* eslint-enable camelcase*/ diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index e9991dcad0..a2037e640f 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -1,8 +1,8 @@ /* Copyright 2017 MTRNord and Cooperative EITA Copyright 2017 Vector Creations Ltd. -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,11 +21,13 @@ import request from 'browser-request'; import counterpart from 'counterpart'; import React from 'react'; import { logger } from "matrix-js-sdk/src/logger"; +import { Optional } from "matrix-events-sdk"; import SettingsStore from "./settings/SettingsStore"; import PlatformPeg from "./PlatformPeg"; import { SettingLevel } from "./settings/SettingLevel"; import { retry } from "./utils/promise"; +import SdkConfig from "./SdkConfig"; // @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config import webpackLangJsonUrl from "$webapp/i18n/languages.json"; @@ -394,10 +396,11 @@ export function setLanguage(preferredLangs: string | string[]) { } return getLanguageRetry(i18nFolder + availLangs[langToUse].fileName); - }).then((langData) => { + }).then(async (langData) => { counterpart.registerTranslations(langToUse, langData); + await registerCustomTranslations(); counterpart.setLocale(langToUse); - SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse); + await SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse); // Adds a lot of noise to test runs, so disable logging there. if (process.env.NODE_ENV !== "test") { logger.log("set language to " + langToUse); @@ -407,8 +410,9 @@ export function setLanguage(preferredLangs: string | string[]) { if (langToUse !== "en") { return getLanguageRetry(i18nFolder + availLangs['en'].fileName); } - }).then((langData) => { + }).then(async (langData) => { if (langData) counterpart.registerTranslations('en', langData); + await registerCustomTranslations(); }); } @@ -581,3 +585,83 @@ function getLanguage(langPath: string): Promise { ); }); } + +export interface ICustomTranslations { + // Format is a map of english string to language to override + [str: string]: { + [lang: string]: string; + }; +} + +let cachedCustomTranslations: Optional = null; +let cachedCustomTranslationsExpire = 0; // zero to trigger expiration right away + +// This awkward class exists so the test runner can get at the function. It is +// not intended for practical or realistic usage. +export class CustomTranslationOptions { + public static lookupFn: (url: string) => ICustomTranslations; + + private constructor() { + // static access for tests only + } +} + +/** + * If a custom translations file is configured, it will be parsed and registered. + * If no customization is made, or the file can't be parsed, no action will be + * taken. + * + * This function should be called *after* registering other translations data to + * ensure it overrides strings properly. + */ +export async function registerCustomTranslations() { + const lookupUrl = SdkConfig.get().custom_translations_url; + if (!lookupUrl) return; // easy - nothing to do + + try { + let json: ICustomTranslations; + if (Date.now() >= cachedCustomTranslationsExpire) { + json = CustomTranslationOptions.lookupFn + ? CustomTranslationOptions.lookupFn(lookupUrl) + : (await (await fetch(lookupUrl)).json() as ICustomTranslations); + cachedCustomTranslations = json; + + // Set expiration to the future, but not too far. Just trying to avoid + // repeated, successive, calls to the server rather than anything long-term. + cachedCustomTranslationsExpire = Date.now() + (5 * 60 * 1000); + } else { + json = cachedCustomTranslations; + } + + // If the (potentially cached) json is invalid, don't use it. + if (!json) return; + + // We convert the operator-friendly version into something counterpart can + // consume. + const langs: { + // same structure, just flipped key order + [lang: string]: { + [str: string]: string; + }; + } = {}; + for (const [str, translations] of Object.entries(json)) { + for (const [lang, newStr] of Object.entries(translations)) { + if (!langs[lang]) langs[lang] = {}; + langs[lang][str] = newStr; + } + } + + // Finally, tell counterpart about our translations + for (const [lang, translations] of Object.entries(langs)) { + counterpart.registerTranslations(lang, translations); + } + } catch (e) { + // We consume all exceptions because it's considered non-fatal for custom + // translations to break. Most failures will be during initial development + // of the json file and not (hopefully) at runtime. + logger.warn("Ignoring error while registering custom translations: ", e); + + // Like above: trigger a cache of the json to avoid successive calls. + cachedCustomTranslationsExpire = Date.now() + (5 * 60 * 1000); + } +} diff --git a/test/languageHandler-test.ts b/test/languageHandler-test.ts new file mode 100644 index 0000000000..38a9db7be9 --- /dev/null +++ b/test/languageHandler-test.ts @@ -0,0 +1,70 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SdkConfig from "../src/SdkConfig"; +import { + _t, + CustomTranslationOptions, + ICustomTranslations, + registerCustomTranslations, + setLanguage, +} from "../src/languageHandler"; + +describe('languageHandler', () => { + afterEach(() => { + SdkConfig.unset(); + CustomTranslationOptions.lookupFn = undefined; + }); + + it('should support overriding translations', async () => { + const str = "This is a test string that does not exist in the app."; + const enOverride = "This is the English version of a custom string."; + const deOverride = "This is the German version of a custom string."; + const overrides: ICustomTranslations = { + [str]: { + "en": enOverride, + "de": deOverride, + }, + }; + + const lookupUrl = "/translations.json"; + const fn = (url: string): ICustomTranslations => { + expect(url).toEqual(lookupUrl); + return overrides; + }; + + // First test that overrides aren't being used + + await setLanguage("en"); + expect(_t(str)).toEqual(str); + + await setLanguage("de"); + expect(_t(str)).toEqual(str); + + // Now test that they *are* being used + SdkConfig.add({ + custom_translations_url: lookupUrl, + }); + CustomTranslationOptions.lookupFn = fn; + await registerCustomTranslations(); + + await setLanguage("en"); + expect(_t(str)).toEqual(enOverride); + + await setLanguage("de"); + expect(_t(str)).toEqual(deOverride); + }); +});