diff --git a/.prettierignore b/.prettierignore index 55ca4ae5b..077e012bf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ types lib package.json **/*.js.snap +translations.ts diff --git a/README.md b/README.md index 94825f43f..3a37470bc 100644 --- a/README.md +++ b/README.md @@ -131,13 +131,19 @@ For the most part, `brs` attempts to emulate BrightScript as closely as possible ### `_brs_.process` -Allows you to access the command line arguments. Usage: +Allows you to access the command line arguments and locale. Locale changes will be reflected in related `RoDeviceInfo` functions and the standard library `Tr` function. Usage: ```brightscript print _brs_.process ' { -' argv: [ "some", "arg" ] +' argv: [ "some", "arg" ], +' getLocale: [Function getLocale] +' setLocale: [Function setLocale] ' } + +_brs_.process.setLocale("fr_CA") +print _brs_.process.getLocale() ' => "fr_CA" +print createObject("roDeviceInfo").getCurrentLocale() ' => "fr_CA" ``` ### `_brs_.global` diff --git a/src/brsTypes/components/RoDeviceInfo.ts b/src/brsTypes/components/RoDeviceInfo.ts index 1aa7a0ebc..9ad1a7401 100644 --- a/src/brsTypes/components/RoDeviceInfo.ts +++ b/src/brsTypes/components/RoDeviceInfo.ts @@ -17,6 +17,9 @@ export class RoDeviceInfo extends BrsComponent implements BrsValue { private enableAudioGuideChanged = BrsBoolean.True; private enableCodecCapChanged = BrsBoolean.True; + /** A user-specified locale to use for Brightscript functions. */ + private static _locale: string | undefined; + constructor() { super("roDeviceInfo"); this.registerMethods({ @@ -77,6 +80,19 @@ export class RoDeviceInfo extends BrsComponent implements BrsValue { }); } + /** Sets the locale for Brightscript functions. */ + public static set locale(newLocale: string) { + RoDeviceInfo._locale = newLocale; + } + + /** + * Returns the locale for Brightscript functions. If a custom + * locale has been specified, use that. Otherwise, default to the Node process. + */ + public static get locale(): string { + return RoDeviceInfo._locale || process.env.LOCALE || ""; + } + toString(parent?: BrsType): string { return ""; } @@ -191,8 +207,7 @@ export class RoDeviceInfo extends BrsComponent implements BrsValue { returns: ValueKind.String, }, impl: (_interpreter) => { - let countryCode = process.env.LOCALE; - return countryCode ? new BrsString(countryCode) : new BrsString(""); + return new BrsString(RoDeviceInfo.locale); }, }); @@ -234,8 +249,7 @@ export class RoDeviceInfo extends BrsComponent implements BrsValue { returns: ValueKind.String, }, impl: (_interpreter) => { - let locale = process.env.LOCALE; - return locale ? new BrsString(locale) : new BrsString(""); + return new BrsString(RoDeviceInfo.locale); }, }); @@ -245,8 +259,7 @@ export class RoDeviceInfo extends BrsComponent implements BrsValue { returns: ValueKind.String, }, impl: (_interpreter) => { - let countryCode = process.env.LOCALE; - return countryCode ? new BrsString(countryCode) : new BrsString(""); + return new BrsString(RoDeviceInfo.locale); }, }); diff --git a/src/extensions/Process.ts b/src/extensions/Process.ts index 0390f7466..ebeb1a3db 100644 --- a/src/extensions/Process.ts +++ b/src/extensions/Process.ts @@ -1,8 +1,43 @@ -import { RoAssociativeArray, BrsString, RoArray } from "../brsTypes"; +import { + RoAssociativeArray, + BrsString, + RoArray, + Callable, + ValueKind, + RoDeviceInfo, + BrsInvalid, + StdlibArgument, +} from "../brsTypes"; +import { Interpreter } from "../interpreter"; export const Process = new RoAssociativeArray([ { name: new BrsString("argv"), value: new RoArray(process.argv.map((arg) => new BrsString(arg))), }, + { + name: new BrsString("getLocale"), + value: new Callable("getLocale", { + signature: { + returns: ValueKind.String, + args: [], + }, + impl: (_: Interpreter) => { + return new BrsString(RoDeviceInfo.locale); + }, + }), + }, + { + name: new BrsString("setLocale"), + value: new Callable("setLocale", { + signature: { + returns: ValueKind.Invalid, + args: [new StdlibArgument("newLocale", ValueKind.String)], + }, + impl: (_: Interpreter, newLocale: BrsString) => { + RoDeviceInfo.locale = newLocale.value; + return BrsInvalid.Instance; + }, + }), + }, ]); diff --git a/src/index.ts b/src/index.ts index 8e4861c3e..44077f7a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { Interpreter, ExecutionOptions, defaultExecutionOptions } from "./interp import * as BrsError from "./Error"; import * as LexerParser from "./LexerParser"; import { CoverageCollector } from "./coverage"; +import { loadTranslationFiles } from "./stdlib"; import * as _lexer from "./lexer"; export { _lexer as lexer }; @@ -69,6 +70,8 @@ export async function execute(filenames: string[], options: Partial([ + "en_US", // US English + "en_GB", // British English + "fr_CA", // Canadian French + "es_ES", // International Spanish + "de_DE", // German + "it_IT", // Italian + "pt_BR", // Brazilian Portuguese +]); + +/** Source string to translated string */ +type Translations = Map; + +/** File names to translation maps */ +let fileTranslations = new Map(); + +/** + * Parses and stores the actual translations of a given XML file. + * @param xmlNode The XmlDocument node that represents this translation file + * @param locale The locale for this translation file + */ +function parseTranslations(xmlNode: XmlDocument, locale: string) { + let translations: Translations = new Map(); + let contextNode = xmlNode.childNamed("context"); + contextNode?.childrenNamed("message").forEach((messageElement) => { + let source = messageElement.childNamed("source"); + let translation = messageElement.childNamed("translation"); + if (source && translation) { + translations.set(source.val, translation.val); + } + }); + + fileTranslations.set(locale, translations); +} + +/** + * Finds and records all of the translation files that exist in the locale/ folder. + * @param rootDir The root package directory + */ +export async function loadTranslationFiles(interpreter: Interpreter, rootDir: string) { + locales.forEach(async (locale) => { + const filePath = path.join(rootDir, "locale", locale, "translations.ts"); + if (fs.existsSync(filePath)) { + let xmlNode: XmlDocument; + try { + let contents = await readFile(filePath, "utf-8"); + let xmlStr = contents.toString().replace(/\r?\n|\r/g, ""); + xmlNode = new XmlDocument(xmlStr); + } catch (err) { + interpreter.stderr.write(`Error reading translations file ${filePath}: ${err}`); + return; + } + + parseTranslations(xmlNode, locale); + } + }); +} + +export const Tr = new Callable("Tr", { + signature: { + returns: ValueKind.String, + args: [new StdlibArgument("source", ValueKind.String)], + }, + impl: (_: Interpreter, source: BrsString) => { + let locale = RoDeviceInfo.locale; + let translationFile = fileTranslations.get(locale); + let translatedString = translationFile?.get(source.value); + + if (translatedString) { + return new BrsString(translatedString); + } + + // If there was no translation found, RBI returns the input string. + return source; + }, +}); diff --git a/src/stdlib/index.ts b/src/stdlib/index.ts index 4a1b9674e..8589d8d2e 100644 --- a/src/stdlib/index.ts +++ b/src/stdlib/index.ts @@ -18,6 +18,7 @@ export * from "./GlobalUtilities"; export * from "./CreateObject"; export * from "./File"; export * from "./Json"; +export * from "./Localization"; export * from "./Math"; export * from "./Print"; export { Run } from "./Run"; diff --git a/test/e2e/BrsComponents.test.js b/test/e2e/BrsComponents.test.js index 344545352..09a152bb6 100644 --- a/test/e2e/BrsComponents.test.js +++ b/test/e2e/BrsComponents.test.js @@ -676,12 +676,15 @@ describe("end to end brightscript functions", () => { "", "true", "", - "en_US", "36", "PST", "false", "en_US", "en_US", + "en_US", + "fr_CA", + "fr_CA", + "fr_CA", "", "0", "0", diff --git a/test/e2e/Localization.test.js b/test/e2e/Localization.test.js new file mode 100644 index 000000000..9c5499a20 --- /dev/null +++ b/test/e2e/Localization.test.js @@ -0,0 +1,34 @@ +const { execute } = require("../../lib"); +const { createMockStreams, resourceFile, allArgs } = require("./E2ETests"); + +describe("Localization", () => { + let outputStreams; + + beforeAll(() => { + outputStreams = createMockStreams(); + outputStreams.root = __dirname + "/resources"; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test("components/localization/main.brs", async () => { + await execute([resourceFile("components", "localization", "source", "main.brs")], { + ...outputStreams, + root: "test/e2e/resources/components/localization", + }); + + expect(allArgs(outputStreams.stdout.write).filter((arg) => arg !== "\n")).toEqual([ + "Bonjour", + "hello", + "Au revoir", + "world", + "123", + ]); + }); +}); diff --git a/test/e2e/resources/components/localization/locale/en_US/translations.ts b/test/e2e/resources/components/localization/locale/en_US/translations.ts new file mode 100644 index 000000000..8e03cf12a --- /dev/null +++ b/test/e2e/resources/components/localization/locale/en_US/translations.ts @@ -0,0 +1,20 @@ + + + +UTF-8 + + default + + Hello + Bonjour + + + Fare thee well + Au revoir + + + Not a straightforward string + { "hello": ["world"], "foo": 123 } + + + diff --git a/test/e2e/resources/components/localization/source/main.brs b/test/e2e/resources/components/localization/source/main.brs new file mode 100644 index 000000000..7139233a1 --- /dev/null +++ b/test/e2e/resources/components/localization/source/main.brs @@ -0,0 +1,10 @@ +sub main() + _brs_.process.setLocale("en_US") + print tr("Hello") ' => Bonjour + print tr("hello") ' => hello + print tr("Fare thee well") ' => Au revoir + + obj = parseJson(tr("Not a straightforward string")) + print obj.hello[0] ' => "world" + print obj.foo ' => 123 +end sub diff --git a/test/e2e/resources/components/roDeviceInfo.brs b/test/e2e/resources/components/roDeviceInfo.brs index c48bc3fd6..4df79d920 100644 --- a/test/e2e/resources/components/roDeviceInfo.brs +++ b/test/e2e/resources/components/roDeviceInfo.brs @@ -11,13 +11,19 @@ sub main() print deviceInfo.getRIDA() print deviceInfo.isRIDADisabled() print deviceInfo.getChannelClientId() - print deviceInfo.getUserCountryCode() uuid = deviceInfo.getRandomUUID() print len(uuid) print deviceInfo.getTimeZone() print deviceInfo.hasFeature("on") + print deviceInfo.getCurrentLocale() print deviceInfo.getCountryCode() + print deviceInfo.getUserCountryCode() + _brs_.process.setLocale("fr_CA") + print deviceInfo.getCurrentLocale() + print deviceInfo.getCountryCode() + print deviceInfo.getUserCountryCode() + print deviceInfo.getPreferredCaptionLanguage() print deviceInfo.timeSinceLastKeyPress() print deviceInfo.getDrmInfo().count()