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

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