Add support for overriding strings in the app (#7886)

* Add support for overriding strings in the app

This is to support a case where certain details of the app need to be slightly different and don't necessarily warrant a complete fork. 

Intended for language-controlled deployments, operators can specify a JSON file with their custom translations that override the in-app/community-supplied ones.

* Fix import grouping

* Add a language handler test

* Appease the linter

* Add comment for why a weird class exists
This commit is contained in:
Travis Ralston 2022-03-01 11:53:09 -07:00 committed by GitHub
parent a5ce1c9dcb
commit 5f51ba1592
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 160 additions and 4 deletions

View file

@ -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*/

View file

@ -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<object> {
);
});
}
export interface ICustomTranslations {
// Format is a map of english string to language to override
[str: string]: {
[lang: string]: string;
};
}
let cachedCustomTranslations: Optional<ICustomTranslations> = 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);
}
}

View file

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