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

Commit

Permalink
Add support for overriding strings in the app (#7886)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
turt2live authored Mar 1, 2022
1 parent a5ce1c9 commit 5f51ba1
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 4 deletions.
2 changes: 2 additions & 0 deletions src/SdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*/

Expand Down
92 changes: 88 additions & 4 deletions src/languageHandler.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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();
});
}

Expand Down Expand Up @@ -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 changes: 70 additions & 0 deletions test/languageHandler-test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 5f51ba1

Please sign in to comment.