Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 5f51ba1

Browse files
authored
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
1 parent a5ce1c9 commit 5f51ba1

File tree

3 files changed

+160
-4
lines changed

3 files changed

+160
-4
lines changed

src/SdkConfig.ts

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface ConfigOptions {
2929
// sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate
3030
sso_immediate_redirect?: boolean;
3131
sso_redirect_options?: ISsoRedirectOptions;
32+
33+
custom_translations_url?: string;
3234
}
3335
/* eslint-enable camelcase*/
3436

src/languageHandler.tsx

+88-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/*
22
Copyright 2017 MTRNord and Cooperative EITA
33
Copyright 2017 Vector Creations Ltd.
4-
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
54
Copyright 2019 Michael Telatynski <[email protected]>
5+
Copyright 2019 - 2022 The Matrix.org Foundation C.I.C.
66
77
Licensed under the Apache License, Version 2.0 (the "License");
88
you may not use this file except in compliance with the License.
@@ -21,11 +21,13 @@ import request from 'browser-request';
2121
import counterpart from 'counterpart';
2222
import React from 'react';
2323
import { logger } from "matrix-js-sdk/src/logger";
24+
import { Optional } from "matrix-events-sdk";
2425

2526
import SettingsStore from "./settings/SettingsStore";
2627
import PlatformPeg from "./PlatformPeg";
2728
import { SettingLevel } from "./settings/SettingLevel";
2829
import { retry } from "./utils/promise";
30+
import SdkConfig from "./SdkConfig";
2931

3032
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
3133
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
@@ -394,10 +396,11 @@ export function setLanguage(preferredLangs: string | string[]) {
394396
}
395397

396398
return getLanguageRetry(i18nFolder + availLangs[langToUse].fileName);
397-
}).then((langData) => {
399+
}).then(async (langData) => {
398400
counterpart.registerTranslations(langToUse, langData);
401+
await registerCustomTranslations();
399402
counterpart.setLocale(langToUse);
400-
SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse);
403+
await SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse);
401404
// Adds a lot of noise to test runs, so disable logging there.
402405
if (process.env.NODE_ENV !== "test") {
403406
logger.log("set language to " + langToUse);
@@ -407,8 +410,9 @@ export function setLanguage(preferredLangs: string | string[]) {
407410
if (langToUse !== "en") {
408411
return getLanguageRetry(i18nFolder + availLangs['en'].fileName);
409412
}
410-
}).then((langData) => {
413+
}).then(async (langData) => {
411414
if (langData) counterpart.registerTranslations('en', langData);
415+
await registerCustomTranslations();
412416
});
413417
}
414418

@@ -581,3 +585,83 @@ function getLanguage(langPath: string): Promise<object> {
581585
);
582586
});
583587
}
588+
589+
export interface ICustomTranslations {
590+
// Format is a map of english string to language to override
591+
[str: string]: {
592+
[lang: string]: string;
593+
};
594+
}
595+
596+
let cachedCustomTranslations: Optional<ICustomTranslations> = null;
597+
let cachedCustomTranslationsExpire = 0; // zero to trigger expiration right away
598+
599+
// This awkward class exists so the test runner can get at the function. It is
600+
// not intended for practical or realistic usage.
601+
export class CustomTranslationOptions {
602+
public static lookupFn: (url: string) => ICustomTranslations;
603+
604+
private constructor() {
605+
// static access for tests only
606+
}
607+
}
608+
609+
/**
610+
* If a custom translations file is configured, it will be parsed and registered.
611+
* If no customization is made, or the file can't be parsed, no action will be
612+
* taken.
613+
*
614+
* This function should be called *after* registering other translations data to
615+
* ensure it overrides strings properly.
616+
*/
617+
export async function registerCustomTranslations() {
618+
const lookupUrl = SdkConfig.get().custom_translations_url;
619+
if (!lookupUrl) return; // easy - nothing to do
620+
621+
try {
622+
let json: ICustomTranslations;
623+
if (Date.now() >= cachedCustomTranslationsExpire) {
624+
json = CustomTranslationOptions.lookupFn
625+
? CustomTranslationOptions.lookupFn(lookupUrl)
626+
: (await (await fetch(lookupUrl)).json() as ICustomTranslations);
627+
cachedCustomTranslations = json;
628+
629+
// Set expiration to the future, but not too far. Just trying to avoid
630+
// repeated, successive, calls to the server rather than anything long-term.
631+
cachedCustomTranslationsExpire = Date.now() + (5 * 60 * 1000);
632+
} else {
633+
json = cachedCustomTranslations;
634+
}
635+
636+
// If the (potentially cached) json is invalid, don't use it.
637+
if (!json) return;
638+
639+
// We convert the operator-friendly version into something counterpart can
640+
// consume.
641+
const langs: {
642+
// same structure, just flipped key order
643+
[lang: string]: {
644+
[str: string]: string;
645+
};
646+
} = {};
647+
for (const [str, translations] of Object.entries(json)) {
648+
for (const [lang, newStr] of Object.entries(translations)) {
649+
if (!langs[lang]) langs[lang] = {};
650+
langs[lang][str] = newStr;
651+
}
652+
}
653+
654+
// Finally, tell counterpart about our translations
655+
for (const [lang, translations] of Object.entries(langs)) {
656+
counterpart.registerTranslations(lang, translations);
657+
}
658+
} catch (e) {
659+
// We consume all exceptions because it's considered non-fatal for custom
660+
// translations to break. Most failures will be during initial development
661+
// of the json file and not (hopefully) at runtime.
662+
logger.warn("Ignoring error while registering custom translations: ", e);
663+
664+
// Like above: trigger a cache of the json to avoid successive calls.
665+
cachedCustomTranslationsExpire = Date.now() + (5 * 60 * 1000);
666+
}
667+
}

test/languageHandler-test.ts

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import SdkConfig from "../src/SdkConfig";
18+
import {
19+
_t,
20+
CustomTranslationOptions,
21+
ICustomTranslations,
22+
registerCustomTranslations,
23+
setLanguage,
24+
} from "../src/languageHandler";
25+
26+
describe('languageHandler', () => {
27+
afterEach(() => {
28+
SdkConfig.unset();
29+
CustomTranslationOptions.lookupFn = undefined;
30+
});
31+
32+
it('should support overriding translations', async () => {
33+
const str = "This is a test string that does not exist in the app.";
34+
const enOverride = "This is the English version of a custom string.";
35+
const deOverride = "This is the German version of a custom string.";
36+
const overrides: ICustomTranslations = {
37+
[str]: {
38+
"en": enOverride,
39+
"de": deOverride,
40+
},
41+
};
42+
43+
const lookupUrl = "/translations.json";
44+
const fn = (url: string): ICustomTranslations => {
45+
expect(url).toEqual(lookupUrl);
46+
return overrides;
47+
};
48+
49+
// First test that overrides aren't being used
50+
51+
await setLanguage("en");
52+
expect(_t(str)).toEqual(str);
53+
54+
await setLanguage("de");
55+
expect(_t(str)).toEqual(str);
56+
57+
// Now test that they *are* being used
58+
SdkConfig.add({
59+
custom_translations_url: lookupUrl,
60+
});
61+
CustomTranslationOptions.lookupFn = fn;
62+
await registerCustomTranslations();
63+
64+
await setLanguage("en");
65+
expect(_t(str)).toEqual(enOverride);
66+
67+
await setLanguage("de");
68+
expect(_t(str)).toEqual(deOverride);
69+
});
70+
});

0 commit comments

Comments
 (0)