-
-
Notifications
You must be signed in to change notification settings - Fork 823
Add support for overriding strings in the app #7886
Changes from 4 commits
820ddb1
adee559
95443a5
17c5097
6e4893e
2e5f7d6
327002a
f3d27cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 <[email protected]> | ||
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,81 @@ 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 | ||
|
||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How often would we expect this to be called? It looks like it's just called from setLanguage which will do an http hit to get the file for that langauge anyway? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. twice on app startup, for reasons I honestly didn't bother investigating. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, that's fun - we should probably check that out at some point & see if the main translations file gets requested twice. Can I press you to make a bug for it? :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
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. | ||
dbkr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
} | ||
} |
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); | ||
}); | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.