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:
parent
a5ce1c9dcb
commit
5f51ba1592
3 changed files with 160 additions and 4 deletions
|
@ -29,6 +29,8 @@ export interface ConfigOptions {
|
||||||
// sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate
|
// sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate
|
||||||
sso_immediate_redirect?: boolean;
|
sso_immediate_redirect?: boolean;
|
||||||
sso_redirect_options?: ISsoRedirectOptions;
|
sso_redirect_options?: ISsoRedirectOptions;
|
||||||
|
|
||||||
|
custom_translations_url?: string;
|
||||||
}
|
}
|
||||||
/* eslint-enable camelcase*/
|
/* eslint-enable camelcase*/
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2017 MTRNord and Cooperative EITA
|
Copyright 2017 MTRNord and Cooperative EITA
|
||||||
Copyright 2017 Vector Creations Ltd.
|
Copyright 2017 Vector Creations Ltd.
|
||||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
|
||||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 counterpart from 'counterpart';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { Optional } from "matrix-events-sdk";
|
||||||
|
|
||||||
import SettingsStore from "./settings/SettingsStore";
|
import SettingsStore from "./settings/SettingsStore";
|
||||||
import PlatformPeg from "./PlatformPeg";
|
import PlatformPeg from "./PlatformPeg";
|
||||||
import { SettingLevel } from "./settings/SettingLevel";
|
import { SettingLevel } from "./settings/SettingLevel";
|
||||||
import { retry } from "./utils/promise";
|
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
|
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
|
||||||
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
|
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
|
||||||
|
@ -394,10 +396,11 @@ export function setLanguage(preferredLangs: string | string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return getLanguageRetry(i18nFolder + availLangs[langToUse].fileName);
|
return getLanguageRetry(i18nFolder + availLangs[langToUse].fileName);
|
||||||
}).then((langData) => {
|
}).then(async (langData) => {
|
||||||
counterpart.registerTranslations(langToUse, langData);
|
counterpart.registerTranslations(langToUse, langData);
|
||||||
|
await registerCustomTranslations();
|
||||||
counterpart.setLocale(langToUse);
|
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.
|
// Adds a lot of noise to test runs, so disable logging there.
|
||||||
if (process.env.NODE_ENV !== "test") {
|
if (process.env.NODE_ENV !== "test") {
|
||||||
logger.log("set language to " + langToUse);
|
logger.log("set language to " + langToUse);
|
||||||
|
@ -407,8 +410,9 @@ export function setLanguage(preferredLangs: string | string[]) {
|
||||||
if (langToUse !== "en") {
|
if (langToUse !== "en") {
|
||||||
return getLanguageRetry(i18nFolder + availLangs['en'].fileName);
|
return getLanguageRetry(i18nFolder + availLangs['en'].fileName);
|
||||||
}
|
}
|
||||||
}).then((langData) => {
|
}).then(async (langData) => {
|
||||||
if (langData) counterpart.registerTranslations('en', 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
70
test/languageHandler-test.ts
Normal file
70
test/languageHandler-test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue