Integrate localized strings into web and desktop app (#2730)

* Added fake localizer

* Switch desktop app to use new translations

* Add script to combine translated files by language

* Add global build-translations command

* Update comments and docs accordingly with changes

* Run merge-translations script on Azure DevOps path

* Add localization for Batch Explorer web version

* Generate translations for Create Account buttons

* Add basic i18n support for web package

* Rename StandardLocalizer to BrowserLocalizer

* Add merge translations script for web and desktop

* Remove powershell merge translations script

* Add localization support for desktop app

* Copy translations to web too and not just desktop

* Add translations for playground buttons

* Gitignore generated localization files

* Update localization docs with setup instructions

* Add package translations to mergeTranslations

* Optimize package file creation and fix unit test

* Update Electron app localization unit tests

* Address all feedback on PR

* Fix desktop localizer, translation function, tests

* Fix Prettier issue with account yml file

* Update client translations unit test accordingly

* Add http-localizer unit test and minor fixes

* Remove CustomGlobal and simplify navigator object

* Add getLocale function to each localizer

---------

Co-authored-by: David Watrous <509299+dpwatrous@users.noreply.github.com>
This commit is contained in:
Sanjana Kapur 2023-08-01 09:48:59 -07:00 коммит произвёл Shiran Pasternak
Родитель 0c99872d82
Коммит 782b0f9fde
44 изменённых файлов: 607 добавлений и 167 удалений

3
.gitignore поставляемый
Просмотреть файл

@ -13,4 +13,5 @@ Localize/out/
/packages/playground/resources/i18n/*
/packages/react/resources/i18n/*
/packages/service/resources/i18n/*
/util/bux/resources/i18n/json
web/dev-server/resources/i18n/*
desktop/resources/i18n/*

Просмотреть файл

@ -17,7 +17,7 @@ steps:
echo "No changes. Nothing to do"
exit 0
else
echo "Text changes detected: $changed. Pipeline failed."
echo "Text changes detected: $changed. Pipeline failed. Please run 'npm run build' on your local machine and push the generated files."
exit 1
fi
workingDirectory: desktop

Просмотреть файл

@ -17,6 +17,9 @@
},
{
"SourceFile": "desktop\\i18n\\resources.resjson"
},
{
"SourceFile": "web\\i18n\\resources.resjson"
}
]
}

Просмотреть файл

@ -12,14 +12,11 @@ if ($artifactsPath -eq "") {
$sourceRoot = $artifactsPath
}
$packageNames = @("common", "service", "playground", "react")
$packageNames = @("common", "service", "playground", "react", "web", "desktop")
# Get language directories
$languageDirs = Get-ChildItem -Path $sourceRoot -Directory
# If this script is run locally, in addition to the resjson files, it adds a json directory containing json files for local development
# If script is run on ADO, it only adds the resjson files used in production
# Strip out resjson comments and resjson-specific formatting before writing the result to json file
function Convert-ResjsonToJson {
param (
@ -43,63 +40,60 @@ function Convert-ResjsonToJson {
Set-Content -Path $targetPath -Value $cleanedJsonContent
}
function Copy-Resources {
param (
[string]$packageName,
[string]$languageDirFullName,
[string]$languageId
)
if ($packageName -eq "web" -or $packageName -eq "desktop") {
$sourcePath = Join-Path $languageDirFullName "$packageName/i18n/resources.resjson"
if ($packageName -eq "web") {
$targetDir = Join-Path $scriptDir "../$packageName/dev-server/resources/i18n"
} else {
$targetDir = Join-Path $scriptDir "../$packageName/resources/i18n"
}
$targetPath = Join-Path $targetDir "resources.$languageId.json"
} else {
$sourcePath = Join-Path $languageDirFullName "packages/$packageName/i18n/resources.resjson"
$targetDir = Join-Path $scriptDir ".." -Resolve | Join-Path -ChildPath "packages/$packageName/resources/i18n/resjson"
$targetPath = Join-Path $targetDir "resources.$languageId.resjson"
}
Write-Verbose "Checking $packageName source path: $sourcePath"
if (Test-Path $sourcePath) {
Write-Verbose "$packageName source path exists, preparing target directory"
if (-not (Test-Path $targetDir)) {
New-Item -ItemType Directory -Force -Path $targetDir > $null
}
Write-Verbose "Copying file from $sourcePath to $targetPath"
Copy-Item -Path $sourcePath -Destination $targetPath
if ($packageName -ne "web" -and $packageName -ne "desktop") {
$jsonTargetDir = $targetDir.Replace("resjson", "json")
$jsonTargetPath = Join-Path $jsonTargetDir "resources.$languageId.json"
if (-not (Test-Path $jsonTargetDir)) {
New-Item -ItemType Directory -Force -Path $jsonTargetDir > $null
}
} else {
$jsonTargetPath = $targetPath
}
Write-Verbose "Converting $packageName resjson to json: $jsonTargetPath"
Convert-ResjsonToJson -sourcePath $sourcePath -targetPath $jsonTargetPath
}
}
Write-Host "Copying translation files"
foreach ($languageDir in $languageDirs) {
$languageId = $languageDir.Name
Write-Verbose "Processing language: $languageId"
# Process package directories
# Copy files to each of the package directories
foreach ($packageName in $packageNames) {
$sourcePath = Join-Path $($languageDir.FullName) "packages/$packageName/i18n/resources.resjson"
$targetDir = (Join-Path $scriptDir ".." -Resolve) | Join-Path -ChildPath "packages/$packageName/resources/i18n/resjson"
$targetPath = Join-Path $targetDir "resources.$languageId.resjson"
Write-Verbose "Checking source path: $sourcePath"
if (Test-Path $sourcePath) {
Write-Verbose "Source path exists, preparing target directory"
if (-not (Test-Path $targetDir)) {
New-Item -ItemType Directory -Force -Path $targetDir > $null
}
Write-Verbose "Copying file from $sourcePath to $targetPath"
Copy-Item -Path $sourcePath -Destination $targetPath
$jsonTargetDir = $targetDir.Replace("resjson", "json")
$jsonTargetPath = $targetPath.Replace("resjson", "json")
if (-not (Test-Path $jsonTargetDir)) {
New-Item -ItemType Directory -Force -Path $jsonTargetDir > $null
}
Write-Verbose "Converting resjson to json: $jsonTargetPath"
Convert-ResjsonToJson -sourcePath $sourcePath -targetPath $jsonTargetPath
}
}
# Handle the desktop directory exception
$desktopSource = Join-Path $($languageDir.FullName) "desktop/i18n/resources.resjson"
$desktopTargetDir = (Join-Path $scriptDir "../desktop/resources/i18n/resjson")
$desktopTarget = Join-Path $desktopTargetDir "resources.$languageId.resjson"
Write-Verbose "Checking desktop source path: $desktopSource"
if (Test-Path $desktopSource) {
Write-Verbose "Desktop source path exists, preparing target directory"
if (-not (Test-Path $desktopTargetDir)) {
New-Item -ItemType Directory -Force -Path $desktopTargetDir > $null
}
Write-Verbose "Copying file from $desktopSource to $desktopTarget"
Copy-Item -Path $desktopSource -Destination $desktopTarget
$jsonDesktopTargetDir = $desktopTargetDir.Replace("resjson", "json")
$jsonDesktopTarget = $desktopTarget.Replace("resjson", "json")
if (-not (Test-Path $jsonDesktopTargetDir)) {
New-Item -ItemType Directory -Force -Path $jsonDesktopTargetDir > $null
}
Write-Verbose "Converting desktop resjson to json: $jsonDesktopTarget"
Convert-ResjsonToJson -sourcePath $desktopSource -targetPath $jsonDesktopTarget
Copy-Resources -packageName $packageName -languageDirFullName $($languageDir.FullName) -languageId $languageId
}
}

Просмотреть файл

@ -50,7 +50,7 @@
"build:package": "npm run build:prod && npm run build-python && npm run package",
"build:prod": "cross-env NODE_ENV=production npm run build",
"build:test": "npm run build && npm run test",
"build-translations": "bux build-translations --src src --dest i18n",
"build-translations": "bux build-translations --src src --dest i18n --outputPath resources/i18n",
"watch": "npm run webpack -- --watch --progress --profile --colors --display-error-details --display-cached",
"electron": "electron build/client/main.js",
"electron:prod": "cross-env NODE_ENV=production electron build/client/main.js",
@ -76,6 +76,7 @@
"workspace:build:package": "npm run build:package",
"workspace:build:prod": "npm run build:prod",
"workspace:build:test": "npm run build:test",
"workspace:build-translations": "npm run build-translations",
"workspace:clean": "npm run clean",
"workspace:launch:desktop": "npm run dev",
"workspace:lint": "npm run lint",

Просмотреть файл

@ -16,6 +16,7 @@ import { BrowserDependencyName } from "@batch/ui-react";
import { StorageAccountServiceImpl, SubscriptionServiceImpl } from "@batch/ui-service";
import { registerIcons } from "app/config";
import {
AppTranslationsLoaderService,
AuthorizationHttpService,
AuthService,
BatchAccountService,
@ -31,9 +32,9 @@ import { Environment } from "common/constants";
import { Subject, combineLatest } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { DefaultBrowserEnvironment } from "@batch/ui-react/lib/environment";
import {StandardLocalizer} from "@batch/ui-common/lib/localization/standard-localizer";
import { LiveLocationService } from "@batch/ui-service/lib/location";
import { LiveResourceGroupService } from "@batch/ui-service/lib/resource-group";
import { DesktopLocalizer } from "./localizer/desktop-localizer";
@Component({
selector: "bl-app",
@ -68,9 +69,10 @@ export class AppComponent implements OnInit, OnDestroy {
private ncjTemplateService: NcjTemplateService,
private predefinedFormulaService: PredefinedFormulaService,
private workspaceService: WorkspaceService,
private translationsLoaderService: AppTranslationsLoaderService
) {
// Initialize shared component lib environment
initEnvironment(new DefaultBrowserEnvironment(
// Initialize shared component lib environment
initEnvironment(new DefaultBrowserEnvironment(
{
mode: ENV === Environment.prod ? EnvironmentMode.Production : EnvironmentMode.Development
},
@ -78,7 +80,7 @@ export class AppComponent implements OnInit, OnDestroy {
[DependencyName.Clock]: () => new StandardClock(),
// TODO: Create an adapter which hooks up to the desktop logger
[DependencyName.LoggerFactory]: () => createConsoleLogger,
[DependencyName.Localizer]: () => new StandardLocalizer(),
[DependencyName.Localizer]: () => new DesktopLocalizer(this.translationsLoaderService),
[DependencyName.HttpClient]:
() => new BatchExplorerHttpClient(authService),
[BrowserDependencyName.LocationService]: () =>
@ -94,7 +96,7 @@ export class AppComponent implements OnInit, OnDestroy {
[BrowserDependencyName.FormLayoutProvider]:
() => new DefaultFormLayoutProvider(),
}
));
));
this.telemetryService.init(remote.getCurrentWindow().TELEMETRY_ENABLED);
this._initWorkspaces();

Просмотреть файл

@ -0,0 +1,32 @@
import { app } from 'electron';
import { Localizer } from "@batch/ui-common/lib/localization";
import { AppTranslationsLoaderService } from "app/services";
export class DesktopLocalizer implements Localizer {
constructor(
private translationsLoaderService: AppTranslationsLoaderService
) { }
translate(message: string): string {
const translations = this.translationsLoaderService.translations;
if (!translations) {
throw new Error("Translation strings are not loaded: " + message);
}
const translation = translations.get(message);
if (translation != null) {
return translation;
} else {
return message;
}
}
getLocale(): string {
if (process.type === 'renderer') {
return navigator.language;
} else {
return app.getLocale();
}
}
}

Просмотреть файл

@ -58,8 +58,8 @@ describe("ClientTranslationsLoaderService", () => {
it("it only loads the english translations file when locale is english", async () => {
localService.locale = "en";
await loader.load();
expect(fsSpy.readFile).toHaveBeenCalledTimes(1);
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "i18n-deprecated/resources.en.json"));
expect(fsSpy.readFile).toHaveBeenCalledTimes(2);
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "./resources/i18n/resources.en.json"));
expect(loader.translations.get("foo.banana")).toEqual("Banana");
expect(loader.translations.get("foo.potato")).toEqual("Potato");
@ -69,8 +69,8 @@ describe("ClientTranslationsLoaderService", () => {
localService.locale = "fr";
await loader.load();
expect(fsSpy.readFile).toHaveBeenCalledTimes(2);
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "i18n-deprecated/resources.en.json"));
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "i18n-deprecated/resources.fr.json"));
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "./resources/i18n/resources.en.json"));
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "./resources/i18n/resources.fr.json"));
expect(loader.translations.get("foo.potato")).toEqual("Pomme de terre");
expect(loader.translations.get("foo.banana")).toEqual("Banana", "Use english translation when not present");
@ -90,7 +90,7 @@ describe("ClientTranslationsLoaderService", () => {
localService.locale = "en";
await loader.load();
expect(devTranslationLoaderSpy.load).toHaveBeenCalledTimes(1);
expect(fsSpy.readFile).not.toHaveBeenCalled();
expect(fsSpy.readFile).toHaveBeenCalledTimes(1);
expect(loader.translations.get("foo.banana")).toEqual("Banana");
expect(loader.translations.get("foo.potato")).toEqual("Potato");
@ -102,7 +102,7 @@ describe("ClientTranslationsLoaderService", () => {
await loader.load();
expect(devTranslationLoaderSpy.load).toHaveBeenCalledTimes(1);
expect(fsSpy.readFile).toHaveBeenCalledTimes(1);
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "i18n-deprecated/resources.fr.json"));
expect(fsSpy.readFile).toHaveBeenCalledWith(path.join(Constants.resourcesFolder, "./resources/i18n/resources.fr.json"));
expect(loader.translations.get("foo.potato")).toEqual("Pomme de terre");
expect(loader.translations.get("foo.banana")).toEqual("Banana", "Use english translation when not present");

Просмотреть файл

@ -41,7 +41,7 @@ export class ClientTranslationsLoaderService extends TranslationsLoaderService {
}
private async _loadProductionTranslations() {
const englishTranslationFile = path.join(ClientConstants.resourcesFolder, "./i18n-deprecated/resources.en.json");
const englishTranslationFile = path.join(ClientConstants.resourcesFolder, "./resources/i18n/resources.en.json");
await this._loadProductionTranslationFile(englishTranslationFile);
await this._loadLocaleTranslations();
}
@ -51,8 +51,7 @@ export class ClientTranslationsLoaderService extends TranslationsLoaderService {
*/
private async _loadLocaleTranslations() {
const locale = this.localeService.locale;
if (locale === Locale.English) { return; }
const localeTranslationFile = path.join(ClientConstants.resourcesFolder, `./i18n-deprecated/resources.${locale}.json`);
const localeTranslationFile = path.join(ClientConstants.resourcesFolder, `./resources/i18n/resources.${locale}.json`);
if (await this.fs.exists(localeTranslationFile)) {
await this._loadProductionTranslationFile(localeTranslationFile);
} else {

Просмотреть файл

@ -1,26 +1,24 @@
# Building Localization on Local Machine
## Building the English File
## Building the English File (Any Machine)
To build the latest English translation resjson file from the YAML files:
To build the latest English translation file from the YAML files:
* Run `npm run build` to build translations for the entire repository
* Run `npm run build-translations` in any package directory (desktop, packages/*) to build translations for a specific package
* Run `npm run build-translations`
The output will be in `{packageName}/i18n/resources.resjson`
* `web/dev-server/resources/i18n/resources.en.json` contains web English strings (web + all packages)
* `desktop/resources/i18n/resources.en.json` contains desktop English strings (desktop + all packages)
## Building Translations Files for Other Languages (Windows-Only)
To build the localization translations for all languages besides English:
* Follow the steps above first
* Follow the step above first (build the English file)
* Install the latest, recommended version of nuget.exe from <https://www.nuget.org/downloads> at C:\Users\{userName}, for instance.
* Navigate to the root of the repository
* Run `npm run loc:restore` to install all dependencies
* Run `npm run loc:build` to build the translations and move them to their correct directories
* If needed, run `npm run clean` to clear out all previously built translation files
* Run `npm run loc:build` to build the translations, move them to the package directories, and combine them altogether in one directory
* Run `npm run build-translations` to build the full, compiled translations for the web and desktop packages
The output will be in `{packageName}/resources/i18n`
* `{packageName}/resources/i18n/resjson` contains RESJSON translations
* `{packageName}/resources/i18n/json` contains JSON translations (RESJSON syntax and comments have been stripped out)
* `web/dev-server/resources/i18n` contains web translations (web + all packages)
* `desktop/resources/i18n` contains desktop translations (desktop + all packages)

Просмотреть файл

@ -54,7 +54,8 @@
"build:package": "lerna run workspace:build:package --stream",
"build:prod": "lerna run workspace:build:prod --stream",
"build:test": "lerna run workspace:build:test --stream",
"clean": "lerna run --parallel workspace:clean --stream && npm run loc:clean",
"build-translations": "lerna run workspace:build-translations --stream",
"clean": "lerna run --parallel workspace:clean --stream && bux rmrf ./Localize/out",
"gather-build-results": "bux gather-build-results",
"launch": "npm run -s launch:web",
"launch:desktop": "lerna run --parallel workspace:launch:desktop --stream",
@ -64,7 +65,6 @@
"lint:fix": "prettier -w . && lerna run --parallel workspace:lint:fix --stream && npm run -s lint:markdown",
"lint:markdown": "markdownlint-cli2 \"**/*.md\" \"#**/node_modules/**/*\" \"#SECURITY.md\"",
"loc:build": "powershell -ExecutionPolicy Bypass -File ./Localize/build.ps1 && powershell -ExecutionPolicy Bypass -File ./Localize/copy-translations.ps1",
"loc:clean": "bux rmrf ./Localize/out",
"loc:restore": "cd Localize && powershell -ExecutionPolicy Bypass -File restore.ps1",
"start": "npm run -s start:web",
"start:desktop": "lerna run --parallel workspace:start:desktop --stream",

Просмотреть файл

@ -1,5 +1,3 @@
{
"lib.common.localizer.account": "Account",
"lib.common.localizer.resourceGroup": "Resource Group",
"lib.common.localizer.subscription": "Subscription"
"lib.common.form.validationError": "Value must be a boolean"
}

Просмотреть файл

@ -17,7 +17,7 @@
"build": "npm run compile && npm run build-translations",
"build:clean": "run-s clean compile",
"build:test": "run-s build test",
"build-translations": "bux build-translations --src src/ui-common --dest i18n --packageName lib.common",
"build-translations": "bux build-translations --src src/ui-common --dest i18n --outputPath resources/i18n/json --packageName lib.common",
"compile": "run-p compile:*",
"compile:esm": "tsc -b ./config/tsconfig.build.json",
"compile:cjs": "tsc -b ./config/tsconfig.cjs.json",
@ -41,6 +41,7 @@
"workspace:build:package": "npm run build:clean",
"workspace:build:prod": "npm run build:clean",
"workspace:build:test": "npm run build:test",
"workspace:build-translations": "npm run build-translations",
"workspace:clean": "npm run clean",
"workspace:launch:desktop": "npm run watch",
"workspace:launch:web": "npm run watch",

Просмотреть файл

@ -0,0 +1,84 @@
import { HttpLocalizer } from "../localization/http-localizer";
describe("HttpLocalizer", () => {
let httpLocalizer: HttpLocalizer;
let fetchMock: jest.Mock;
const testTranslations = { hello: "world" };
const frenchTranslations = { bonjour: "monde" };
beforeEach(() => {
httpLocalizer = new HttpLocalizer();
fetchMock = jest.fn();
global.fetch = fetchMock;
});
test("Load the correct translation file based on the locale", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(testTranslations),
})
);
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("en-US");
await httpLocalizer.loadTranslations();
expect(fetchMock).toHaveBeenCalledWith(
"/resources/i18n/resources.en.json"
);
expect(httpLocalizer.translate("hello")).toEqual("world");
});
test("Load the correct translation file for French locale", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(frenchTranslations),
})
);
// Simulate a French locale
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("fr-FR");
await httpLocalizer.loadTranslations();
expect(fetchMock).toHaveBeenCalledWith(
"/resources/i18n/resources.fr.json"
);
expect(httpLocalizer.translate("bonjour")).toEqual("monde");
});
test("Default to English if locale not found", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(testTranslations),
})
);
// Simulate an invalid locale
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("abc");
await httpLocalizer.loadTranslations();
expect(fetchMock).toHaveBeenCalledWith(
"/resources/i18n/resources.en.json"
);
});
test("Throw error if translations have not been loaded", () => {
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("en-US");
expect(() => httpLocalizer.translate("hello")).toThrowError(
"Translation strings are not loaded hello"
);
});
test("Return original message if no translation found", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(testTranslations),
})
);
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("en-US");
await httpLocalizer.loadTranslations();
expect(httpLocalizer.translate("notFound")).toEqual("notFound");
});
});

Просмотреть файл

@ -7,7 +7,9 @@ describe("Localization utilities", () => {
});
test("Can return simple english strings", () => {
expect(translate("subscription")).toEqual("Subscription");
expect(translate("lib.common.form.validationError")).toEqual(
"Value must be a boolean"
);
});
test("Throw error if string is unknown", () => {

Просмотреть файл

@ -1,6 +1,6 @@
import { FakeClock } from "../../datetime/fake-clock";
import { MockHttpClient } from "../../http";
import { StandardLocalizer } from "../../localization/standard-localizer";
import { FakeLocalizer } from "../../localization/fake-localizer";
import { createMockLogger } from "../../logging";
import { AbstractEnvironment } from "../abstract-environment";
import {
@ -150,7 +150,7 @@ describe("Environment tests", () => {
super(config, {
clock: () => new FakeClock(),
loggerFactory: () => createMockLogger,
localizer: () => new StandardLocalizer(),
localizer: () => new FakeLocalizer(),
httpClient: () => new MockHttpClient(),
animal: () => new Dog(),
});
@ -175,7 +175,7 @@ describe("Environment tests", () => {
super(config, {
clock: () => new FakeClock(),
loggerFactory: () => createMockLogger,
localizer: () => new StandardLocalizer(),
localizer: () => new FakeLocalizer(),
httpClient: () => new MockHttpClient(),
animal: () => new Cat(),
});

Просмотреть файл

@ -2,7 +2,7 @@ import { DependencyFactories } from ".";
import { setLocalTimeZoneOffset } from "../datetime";
import { FakeClock } from "../datetime/fake-clock";
import { MockHttpClient } from "../http/mock-http-client";
import { StandardLocalizer } from "../localization/standard-localizer";
import { FakeLocalizer } from "../localization/fake-localizer";
import { createMockLogger } from "../logging";
import { AbstractEnvironment } from "./abstract-environment";
import {
@ -18,7 +18,7 @@ export const mockEnvironmentConfig: EnvironmentConfig = {
export const mockDependencyFactories: DependencyFactories = {
clock: () => new FakeClock(),
loggerFactory: () => createMockLogger,
localizer: () => new StandardLocalizer(),
localizer: () => new FakeLocalizer(),
httpClient: () => new MockHttpClient(),
};

Просмотреть файл

@ -0,0 +1,2 @@
form:
validationError: Value must be a boolean

Просмотреть файл

@ -1,4 +0,0 @@
localizer:
account: Account
resourceGroup: Resource Group
subscription: Subscription

Просмотреть файл

@ -0,0 +1,28 @@
import { Localizer } from "./localizer";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const STRINGS = (globalThis as unknown as any).__TEST_RESOURCE_STRINGS;
export class FakeLocalizer implements Localizer {
private locale: string;
constructor() {
this.locale = "en";
}
translate(message: string): string {
const value = STRINGS[message];
if (value == null) {
throw new Error("Unable to translate string " + message);
}
return value;
}
getLocale(): string {
return this.locale;
}
setLocale(locale: string): void {
this.locale = locale;
}
}

Просмотреть файл

@ -0,0 +1,96 @@
import { Localizer } from "./localizer";
interface Translations {
[key: string]: string;
}
export class HttpLocalizer implements Localizer {
private translations?: Translations;
async loadTranslations(): Promise<void> {
const languageMap: Record<string, string> = {
cs: "cs",
de: "de",
en: "en",
es: "es",
fr: "fr",
hu: "hu",
id: "id",
it: "it",
ja: "ja",
ko: "ko",
nl: "nl",
pl: "pl",
pt: "pt-PT",
"pt-PT": "pt-PT",
"pt-BR": "pt-BR",
ru: "ru",
sv: "sv",
tr: "tr",
zh: "zh-Hans",
"zh-CN": "zh-Hans",
"zh-TW": "zh-Hant",
};
let languageToLoad = languageMap[this.getLocale()];
if (!languageToLoad) {
// If not found, split and check for two-digit codes
const language = this.getLocale().split("-")[0];
languageToLoad = languageMap[language]
? languageMap[language]
: "en";
}
try {
this.translations = await this.fetchTranslations(languageToLoad);
} catch (error) {
// Fall back to English if translations are not available for the selected locale
if (languageToLoad !== "en") {
console.error(
`Failed to load translations for '${languageToLoad}', falling back to English: ${
(error as Error).message
}`
);
languageToLoad = "en";
this.translations = await this.fetchTranslations(
languageToLoad
);
} else {
throw new Error(
`Failed to load translations for '${languageToLoad}': ${
(error as Error).message
}`
);
}
}
}
private async fetchTranslations(lang: string): Promise<Translations> {
const response = await fetch(`/resources/i18n/resources.${lang}.json`);
if (!response.ok) {
throw new Error(
`Failed to load translations for '${lang}': ${response.statusText}`
);
}
return await response.json();
}
translate(message: string): string {
if (!this.translations) {
throw new Error("Translation strings are not loaded " + message);
}
const translation = this.translations[message];
if (translation != null) {
return translation;
} else {
return message;
}
}
getLocale(): string {
return navigator.language;
}
}

Просмотреть файл

@ -1,3 +1,3 @@
export * from "./localization-util";
export * from "./localizer";
export * from "./standard-localizer";
export * from "./http-localizer";

Просмотреть файл

@ -1,3 +1,4 @@
export interface Localizer {
translate(message: string): string;
getLocale(): string;
}

Просмотреть файл

@ -1,20 +0,0 @@
import { Localizer } from "./localizer";
export class StandardLocalizer implements Localizer {
translate(message: string): string {
switch (message) {
case "subscription":
return "Subscription";
case "resourceGroup":
return "Resource Group";
case "accountName":
return "Account Name";
case "form.buttons.apply":
return "Apply";
case "form.buttons.discardChanges":
return "Discard changes";
}
throw new Error("Unable to translate string " + message);
}
}

Просмотреть файл

@ -18,7 +18,7 @@
"build:clean": "run-s clean compile",
"build:test": "run-s build test",
"bux": "bux",
"build-translations": "bux build-translations --src src/ui-playground --dest i18n --packageName lib.playground",
"build-translations": "bux build-translations --src src/ui-playground --dest i18n --outputPath resources/i18n/json --packageName lib.playground",
"compile": "run-p compile:*",
"compile:esm": "tsc -b ./config/tsconfig.build.json",
"compile:cjs": "tsc -b ./config/tsconfig.cjs.json",
@ -42,6 +42,7 @@
"workspace:build:package": "npm run build:clean",
"workspace:build:prod": "npm run build:clean",
"workspace:build:test": "npm run build:test",
"workspace:build-translations": "npm run build-translations",
"workspace:clean": "npm run clean",
"workspace:launch:desktop": "npm run watch",
"workspace:launch:web": "npm run watch",

Просмотреть файл

@ -1,4 +1,9 @@
{
"lib.react.create-account-action.location": "Location",
"lib.react.create-account-action.storageAccount": "Storage Account"
"lib.react.account.parameter.name.label": "Account name",
"lib.react.arm.location": "Location",
"lib.react.arm.resourceGroup": "Resource group",
"lib.react.arm.storageAccount": "Storage account",
"lib.react.arm.subscription": "Subscription",
"lib.react.form.buttons.apply": "Apply",
"lib.react.form.buttons.discardChanges": "Discard changes"
}

Просмотреть файл

@ -18,7 +18,7 @@
"build:clean": "run-s clean compile",
"build:test": "run-s build test",
"bux": "bux",
"build-translations": "bux build-translations --src src/ui-react --dest i18n --packageName lib.react",
"build-translations": "bux build-translations --src src/ui-react --dest i18n --outputPath resources/i18n/json --packageName lib.react",
"compile": "run-p compile:*",
"compile:esm": "tsc -b ./config/tsconfig.build.json",
"compile:cjs": "tsc -b ./config/tsconfig.cjs.json",
@ -44,6 +44,7 @@
"workspace:build:package": "npm run build:clean",
"workspace:build:prod": "npm run build:clean",
"workspace:build:test": "npm run build:test",
"workspace:build-translations": "npm run build-translations",
"workspace:clean": "npm run clean",
"workspace:launch:desktop": "npm run watch",
"workspace:launch:web": "npm run watch",

Просмотреть файл

@ -0,0 +1,4 @@
account:
parameter:
name:
label: Account name

Просмотреть файл

@ -0,0 +1,5 @@
arm:
location: Location
resourceGroup: Resource group
storageAccount: Storage account
subscription: Subscription

Просмотреть файл

@ -52,18 +52,18 @@ export class CreateAccountAction extends AbstractAction<CreateAccountFormValues>
values: initialValues,
});
form.param("subscriptionId", SubscriptionParameter, {
label: translate("subscription"),
label: translate("lib.react.arm.subscription"),
required: true,
});
form.param("resourceGroupId", ResourceGroupParameter, {
dependencies: {
subscriptionId: "subscriptionId",
},
label: translate("resourceGroup"),
label: translate("lib.react.arm.resourceGroup"),
required: true,
});
form.param("accountName", StringParameter, {
label: translate("accountName"),
label: translate("lib.react.account.parameter.name.label"),
required: true,
description:
"This is how you identify your Batch account. It must be unique.",
@ -96,14 +96,14 @@ export class CreateAccountAction extends AbstractAction<CreateAccountFormValues>
dependencies: {
subscriptionId: "subscriptionId",
},
label: "Location",
label: translate("lib.react.arm.location"),
required: true,
});
form.param("storageAccountId", StorageAccountParameter, {
dependencies: {
subscriptionId: "subscriptionId",
},
label: "Storage account",
label: translate("lib.react.arm.storageAccount"),
description:
"Optional. For best performance we recommend a storage account (general purpose v2) located in the same region as the associated Batch account.",
});

Просмотреть файл

@ -1,3 +0,0 @@
create-account-action:
location: Location
storageAccount: Storage Account

Просмотреть файл

@ -148,7 +148,9 @@ export const ActionForm = <V extends FormValues>(
if (hideSubmitButton !== true) {
// Default submit button
allButtons.push({
label: props.submitButtonLabel ?? translate("form.buttons.apply"),
label:
props.submitButtonLabel ??
translate("lib.react.form.buttons.apply"),
primary: true,
submitForm: true,
disabled: submitting === true,
@ -195,7 +197,8 @@ export const ActionForm = <V extends FormValues>(
// Default reset (discard) button
allButtons.push({
label:
submitButtonLabel ?? translate("form.buttons.discardChanges"),
submitButtonLabel ??
translate("lib.react.form.buttons.discardChanges"),
disabled: submitting === true,
onClick: () => {
action.form.reset();

Просмотреть файл

@ -0,0 +1,4 @@
form:
buttons:
apply: Apply
discardChanges: Discard changes

Просмотреть файл

@ -17,7 +17,7 @@
"build": "npm run compile && npm run build-translations",
"build:clean": "run-s clean compile",
"build:test": "run-s build test",
"build-translations": "bux build-translations --src src/ui-service --dest i18n --packageName lib.service",
"build-translations": "bux build-translations --src src/ui-service --dest i18n --outputPath resources/i18n/json --packageName lib.service",
"compile": "run-p compile:*",
"compile:esm": "tsc -b ./config/tsconfig.build.json",
"compile:cjs": "tsc -b ./config/tsconfig.cjs.json",
@ -41,6 +41,7 @@
"workspace:build:package": "npm run build:clean",
"workspace:build:prod": "npm run build:clean",
"workspace:build:test": "npm run build:test",
"workspace:build-translations": "npm run build-translations",
"workspace:clean": "npm run clean",
"workspace:launch:desktop": "npm run watch",
"workspace:launch:web": "npm run watch",

Просмотреть файл

@ -54,19 +54,22 @@ describe("CLI", () => {
if (err) throw err;
});
//Test that localization build script works correctly WITHOUT packageName parameter
//Test that localization build script works correctly
test("Localization", async () => {
await util.buildTranslations(
"./__tests__/loc-source",
"./build/test-results/loc-results-1"
"./build/test-results/loc-results-1/resjson",
"./build/test-results/loc-results-1/json",
"lib.common"
);
});
//Test that localization build script works correctly WITH packageName parameter
//Test that localization build script works correctly
test("Localization with package name", async () => {
await util.buildTranslations(
"./__tests__/loc-source",
"./build/test-results/loc-results-2",
"./build/test-results/loc-results-2/resjson",
"./build/test-results/loc-results-2/json",
"lib.common"
);
});
@ -74,7 +77,7 @@ describe("CLI", () => {
//Check that the RESJSON file with the packageName parameter contains the package name in its contents
test("Check if RESJSON file with packageName parameter contains the package name", () => {
const data = fs.readFileSync(
"./build/test-results/loc-results-2/resources.resjson"
"./build/test-results/loc-results-2/resjson/resources.resjson"
);
expect(data.toString()).toContain("lib.common");
});
@ -85,12 +88,15 @@ describe("CLI", () => {
await expect(
createEnglishTranslations(
"./__tests__/loc-source-2",
"./build/test-results/loc-results-3"
"./build/test-results/loc-results-3/resjson",
"./build/test-results/loc-results-3/json"
)
).rejects.toThrow(Error);
// expect destination directory to be empty due to error
const files = fs.readdirSync("./build/test-results/loc-results-3");
expect(files.length).toBe(0);
const directoryContents = fs.readdirSync(
"./build/test-results/loc-results-3"
);
expect(directoryContents.length).toBe(0);
});
});

Просмотреть файл

@ -34,7 +34,7 @@ yargs
command: "build-translations",
aliases: ["translate"],
describe:
"Localize YAML files in src and convert them to RESJSON in dest",
"Localize YAML files, convert them to RESJSON, and generate English JSON file",
builder: (yargs: yargs.Argv) =>
yargs
.option("src", {
@ -44,7 +44,13 @@ yargs
})
.option("dest", {
describe:
"The destination directory for RESJSON output files",
"The destination directory for the English RESJSON file used by the localization team",
default: "",
demandOption: true,
})
.option("outputPath", {
describe:
"The directory that contains the generated English JSON translation file",
default: "",
demandOption: true,
})
@ -55,7 +61,12 @@ yargs
demandOption: false,
}),
handler: (argv) =>
buildTranslations(argv.src, argv.dest, argv.packageName),
buildTranslations(
argv.src,
argv.dest,
argv.outputPath,
argv.packageName
),
})
.command({
command: "configure",

Просмотреть файл

@ -13,9 +13,130 @@ type NestedStringMap<V> = StringMap<V> | StringMap<StringMap<V>>;
const writeFile = promisify(fs.writeFile);
const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);
export async function mergeAllTranslations(outputPath: string) {
const currentDirectory = process.cwd();
const rootDir = path.resolve(currentDirectory, "..");
// Ensure the output directory exists
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
// Define resource directories (absolute paths)
const resourceDirs = [
path.join(rootDir, "packages/common/resources/i18n/json"),
path.join(rootDir, "packages/playground/resources/i18n/json"),
path.join(rootDir, "packages/react/resources/i18n/json"),
path.join(rootDir, "packages/service/resources/i18n/json"),
];
// Build the English file for each package before building web/desktop translations (it is a prerequisite)
const buildPackageEnglishPromises = [];
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/react/src/ui-react"),
path.join(rootDir, "packages/react/i18n"),
path.join(rootDir, "packages/react/resources/i18n/json"),
"lib.react"
)
);
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/playground/src/ui-playground"),
path.join(rootDir, "packages/playground/i18n"),
path.join(rootDir, "packages/playground/resources/i18n/json"),
"lib.playground"
)
);
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/common/src/ui-common"),
path.join(rootDir, "packages/common/i18n"),
path.join(rootDir, "packages/common/resources/i18n/json"),
"lib.common"
)
);
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/service/src/ui-service"),
path.join(rootDir, "packages/service/i18n"),
path.join(rootDir, "packages/service/resources/i18n/json"),
"lib.service"
)
);
await Promise.all(buildPackageEnglishPromises);
// Initialize an empty object to store the merged translations
const mergedTranslations: Record<string, Record<string, string>> = {};
// Iterate through each resource directory
for (const dir of resourceDirs) {
// Iterate through each JSON file in the directory
for (const file of fs.readdirSync(dir)) {
if (file.startsWith("resources.") && file.endsWith(".json")) {
const langID = file.split(".")[1];
// If the language ID is not in the object, add it
if (!mergedTranslations[langID]) {
mergedTranslations[langID] = {};
}
// Read the JSON content and parse it
const content = JSON.parse(
await readFileAsync(path.join(dir, file), "utf-8")
);
// Merge the content into the object
Object.assign(mergedTranslations[langID], content);
}
}
}
// Write the merged translations to the output directory
for (const langID of Object.keys(mergedTranslations)) {
const outputFile = path.join(outputPath, `resources.${langID}.json`);
// Read existing translations in the output file if it exists
let existingTranslations = {};
if (fs.existsSync(outputFile)) {
existingTranslations = JSON.parse(
await readFileAsync(outputFile, "utf-8")
);
}
// Merge existing translations with new translations
const combinedTranslations = {
...existingTranslations,
...mergedTranslations[langID],
};
// Sort keys alphabetically
const sortedTranslations = Object.fromEntries(
Object.entries(combinedTranslations).sort()
);
// Write the sorted translations to the output file
await writeFileAsync(
outputFile,
JSON.stringify(sortedTranslations, null, 2),
"utf-8"
);
}
console.log(`Merged translations have been saved in ${outputPath}`);
}
// Function to generate English file for a package from its YAML files
export async function createEnglishTranslations(
sourcePath: string,
destPath: string,
outputPath: string,
packageName?: string
) {
const translations = await loadDevTranslations(sourcePath, packageName);
@ -50,14 +171,7 @@ export async function createEnglishTranslations(
}
const cleanedJsonContent = JSON.stringify(cleanContent, null, 2);
const resourcesJsonPath = path.join(
path.dirname(path.dirname(sourcePath)),
"resources",
"i18n",
"json",
"resources.en.json"
);
const resourcesJsonPath = path.join(outputPath, "resources.en.json");
// Check if the directory exists and create it if it doesn't
if (!fs.existsSync(path.dirname(resourcesJsonPath))) {
@ -78,13 +192,11 @@ export async function loadDevTranslations(
packageName?: string
): Promise<{ [key: string]: string }> {
const loader = new DevTranslationsLoader();
console.log("Loading dev translations...");
let hasDuplicate = false;
const translations = await loader.load(sourcePath, (key, file) => {
console.warn(`${key} is being duplicated. "${file}"`);
hasDuplicate = true;
});
console.log(`Loaded dev translations`);
if (hasDuplicate) {
throw new Error();
}

Просмотреть файл

@ -9,7 +9,10 @@ import inquirer from "inquirer";
import * as os from "os";
import * as path from "path";
import * as shell from "shelljs";
import { createEnglishTranslations } from "./translation-functions";
import {
createEnglishTranslations,
mergeAllTranslations,
} from "./translation-functions";
export const defaultBatchExplorerHome = path.resolve(__dirname, "../../");
export const configFile = path.resolve(os.homedir(), ".config/batch/bux.json");
@ -104,6 +107,7 @@ export function copyFiles(sourcePath: string, destPath: string) {
export async function buildTranslations(
sourcePath: string,
destPathRESJSON: string,
outputPath: string,
packageName?: string
) {
if (!sourcePath) {
@ -126,7 +130,22 @@ export async function buildTranslations(
});
}
await createEnglishTranslations(sourcePath, destPathRESJSON, packageName);
if (!outputPath) {
error("Failed to build translations: No output path specified");
return;
}
await createEnglishTranslations(
sourcePath,
destPathRESJSON,
outputPath,
packageName
);
// If no packageName, merge all translations (for web/desktop). packageName indicates package-specific operation.
if (!packageName) {
await mergeAllTranslations(outputPath);
}
}
export function mkdirp(targetPath: string) {

Просмотреть файл

@ -4,6 +4,11 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { readFileSync } = require("fs");
const path = require("path");
const combinedResourceStrings = getCombinedResourceStrings();
module.exports = {
/**
* Creates a Jest configuration object with optional overrides.
@ -50,6 +55,7 @@ module.exports = {
],
],
globals: {
__TEST_RESOURCE_STRINGS: combinedResourceStrings,
"ts-jest": {
diagnostics: {
// Squelch a warning with outputting ES6 modules (in tsconfig.json)
@ -95,3 +101,31 @@ module.exports = {
return Object.assign({}, baseConfig, overrides);
},
};
function getCombinedResourceStrings() {
const resourceStrings = [
require(path.join(
__dirname,
"../../packages/common/resources/i18n/json/resources.en.json"
)),
require(path.join(
__dirname,
"../../packages/service/resources/i18n/json/resources.en.json"
)),
require(path.join(
__dirname,
"../../packages/react/resources/i18n/json/resources.en.json"
)),
require(path.join(
__dirname,
"../../packages/playground/resources/i18n/json/resources.en.json"
)),
];
const allResourceStrings = {};
resourceStrings.forEach((strings) => {
Object.assign(allResourceStrings, strings);
});
return allResourceStrings;
}

Просмотреть файл

@ -0,0 +1,5 @@
{
"application.buttons.editor": "Editor",
"application.buttons.home": "Home",
"application.buttons.playground": "Playground"
}

Просмотреть файл

@ -19,15 +19,17 @@
"build:clean": "run-s clean compile bundle:dev",
"build:prod": "run-s clean compile bundle:prod",
"build:test": "run-s build test",
"build-translations": "bux build-translations --src src/explorer-web --dest i18n --outputPath ./dev-server/resources/i18n",
"bundle": "npm run bundle:dev",
"bundle:analyze": "webpack --env.analyze",
"bundle:dev": "webpack --env.dev",
"bundle:prod": "webpack",
"bux": "bux",
"compile": "tsc -b ./config/tsconfig.build.json",
"compile": "npm run build-translations && tsc -b ./config/tsconfig.build.json",
"clean": "run-p clean:*",
"clean:build": "bux rmrf ./build",
"clean:esm": "bux rmrf ./lib",
"clean:resources": "bux rmrf ./dev-server/resources",
"clean:umd": "bux rmrf ./lib-umd",
"test": "jest",
"test:coverage": "jest --collect-coverage",
@ -37,7 +39,7 @@
"lint": "eslint .",
"server": "webpack-dev-server --inline --hot",
"start": "npm run start:dev",
"start:dev": "npm run clean && npm run server -- --env.dev",
"start:dev": "npm run clean && npm run build-translations && npm run server -- --env.dev",
"start:prod": "npm run clean && npm run server",
"watch": "npm run compile -- --env.watch",
"workspace:build": "npm run build",
@ -46,6 +48,7 @@
"workspace:build:package": "npm run build:prod",
"workspace:build:prod": "npm run build:prod",
"workspace:build:test": "npm run build:test",
"workspace:build-translations": "npm run build-translations",
"workspace:clean": "npm run clean",
"workspace:launch:web": "npm run start -- --env.launch",
"workspace:lint": "npm run lint",

Просмотреть файл

@ -0,0 +1,5 @@
application:
buttons:
home: Home
editor: Editor
playground: Playground

Просмотреть файл

@ -15,6 +15,7 @@ import { Header } from "./layout/header";
import { Main } from "./layout/main";
import { Stack, IStackTokens } from "@fluentui/react/";
import { PrimaryButton } from "@fluentui/react/lib/Button";
import { translate } from "@batch/ui-common";
//DefaultButton
const dropdownStyles: Partial<IDropdownStyles> = {
@ -51,13 +52,16 @@ export const Application: React.FC = () => {
<BrowserRouter>
<Header>
<Stack horizontal tokens={stackTokens}>
<PrimaryButton text="Home" href="/"></PrimaryButton>
<PrimaryButton
text="Editor"
text={translate("application.buttons.home")}
href="/"
></PrimaryButton>
<PrimaryButton
text={translate("application.buttons.editor")}
href="/editor"
></PrimaryButton>
<PrimaryButton
text="Playground"
text={translate("application.buttons.playground")}
href="/playground"
></PrimaryButton>

Просмотреть файл

@ -13,7 +13,7 @@ import {
DefaultFormLayoutProvider,
DefaultFormControlResolver,
} from "@batch/ui-react/lib/components/form";
import { StandardLocalizer } from "@batch/ui-common/lib/localization";
import { HttpLocalizer } from "@batch/ui-common/lib/localization";
import {
FakeStorageAccountService,
FakeSubscriptionService,
@ -27,7 +27,9 @@ declare const ENV: {
MODE: EnvironmentMode;
};
export function init(rootEl: HTMLElement): void {
export async function init(rootEl: HTMLElement): Promise<void> {
const localizer = new HttpLocalizer();
await localizer.loadTranslations();
initEnvironment(
new DefaultBrowserEnvironment(
{
@ -36,7 +38,7 @@ export function init(rootEl: HTMLElement): void {
{
[DependencyName.Clock]: () => new StandardClock(),
[DependencyName.LoggerFactory]: () => createConsoleLogger,
[DependencyName.Localizer]: () => new StandardLocalizer(),
[DependencyName.Localizer]: () => localizer,
[DependencyName.HttpClient]: () => new MockHttpClient(),
[BrowserDependencyName.LocationService]: () =>
new FakeLocationService(),