1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 14:34:02 +00:00

wip typesafe i18n

This commit is contained in:
William Martin
2024-11-20 10:08:28 -05:00
parent 9429ae1d06
commit 3be558ac0e
18 changed files with 99 additions and 38 deletions

View File

@@ -19,7 +19,7 @@ import {
import { ThemeType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CloudEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { I18nService } from "@bitwarden/common/platform/services/i18n.service";
import { BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import {
FakeStateProvider,
@@ -73,7 +73,7 @@ describe("OverlayBackground", () => {
}),
);
const autofillSettingsService = mock<AutofillSettingsService>();
const i18nService = mock<I18nService>();
const i18nService = mock<BaseI18nService<["browser"]>>();
const platformUtilsService = mock<BrowserPlatformUtilsService>();
const themeStateService = mock<ThemeStateService>();
const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => {
@@ -429,7 +429,9 @@ describe("OverlayBackground", () => {
it("will query the overlay page translations if they have not been queried", () => {
overlayBackground["overlayPageTranslations"] = undefined;
jest.spyOn(overlayBackground as any, "getTranslations");
jest.spyOn(overlayBackground["i18nService"], "translate").mockImplementation((key) => key);
jest
.spyOn(overlayBackground["i18nService"], "translate")
.mockImplementation((key: any) => key);
jest.spyOn(BrowserApi, "getUILanguage").mockReturnValue("en");
const translations = overlayBackground["getTranslations"]();

View File

@@ -250,7 +250,7 @@ import { BrowserEnvironmentService } from "../platform/services/browser-environm
import BrowserLocalStorageService from "../platform/services/browser-local-storage.service";
import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service";
import { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service";
import I18nService from "../platform/services/i18n.service";
import BrowserI18nService from "../platform/services/i18n.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
@@ -624,7 +624,7 @@ export default class MainBackground {
this.logService,
);
this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider);
this.i18nService = new BrowserI18nService(BrowserApi.getUILanguage(), this.globalStateProvider);
this.biometricsService = new BackgroundBrowserBiometricsService(
runtimeNativeMessagingBackground,
@@ -1275,7 +1275,7 @@ export default class MainBackground {
}
await Promise.all(setUserKeyInMemoryPromises);
await (this.i18nService as I18nService).init();
await (this.i18nService as BrowserI18nService).init();
(this.eventUploadService as EventUploadService).init(true);
this.popupViewCacheBackgroundService.startObservingTabChanges();

View File

@@ -1,7 +1,8 @@
import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { I18nKeys } from "@bitwarden/common/platform/abstractions/translation.service";
import { BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
export default class I18nService extends BaseI18nService {
export default class BrowserI18nService extends BaseI18nService<["browser"]> {
constructor(systemLanguage: string, globalStateProvider: GlobalStateProvider) {
super(
systemLanguage,
@@ -80,11 +81,11 @@ export default class I18nService extends BaseI18nService {
];
}
t(id: string, p1?: string, p2?: string, p3?: string): string {
t(id: I18nKeys<["browser"]>, p1?: string, p2?: string, p3?: string): string {
return this.translate(id, p1, p2, p3);
}
translate(id: string, p1?: string, p2?: string, p3?: string): string {
translate(id: I18nKeys<["browser"]>, p1?: string, p2?: string, p3?: string): string {
if (this.localesDirectory == null) {
const placeholders: string[] = [];
if (p1 != null) {
@@ -104,6 +105,6 @@ export default class I18nService extends BaseI18nService {
}
}
return super.translate(id, p1, p2, p3);
return super.translate(id as any, p1, p2, p3);
}
}

View File

@@ -131,7 +131,7 @@ import { BrowserEnvironmentService } from "../../platform/services/browser-envir
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
import BrowserMemoryStorageService from "../../platform/services/browser-memory-storage.service";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import I18nService from "../../platform/services/i18n.service";
import BrowserI18nService from "../../platform/services/i18n.service";
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service";
import { BrowserSdkClientFactory } from "../../platform/services/sdk/browser-sdk-client-factory";
import { ForegroundTaskSchedulerService } from "../../platform/services/task-scheduler/foreground-task-scheduler.service";
@@ -205,7 +205,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: I18nServiceAbstraction,
useFactory: (globalStateProvider: GlobalStateProvider) => {
return new I18nService(BrowserApi.getUILanguage(), globalStateProvider);
return new BrowserI18nService(BrowserApi.getUILanguage(), globalStateProvider);
},
deps: [GlobalStateProvider],
}),

View File

@@ -11,6 +11,7 @@
"sourceMap": true,
"baseUrl": ".",
"lib": ["ES2021.String"],
"resolveJsonModule": true,
"paths": {
"@bitwarden/admin-console/common": ["../../libs/admin-console/src/common"],
"@bitwarden/angular/*": ["../../libs/angular/src/*"],

View File

@@ -1,10 +1,14 @@
import * as fs from "fs";
import * as path from "path";
import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
export class I18nService extends BaseI18nService {
import CliMessages from "../../locales/en/messages.json";
type CliMessages = typeof CliMessages;
export class CliI18nService extends BaseI18nService<CliMessages> {
constructor(
systemLanguage: string,
localesDirectory: string,

View File

@@ -166,7 +166,7 @@ import {
import { flagEnabled } from "../platform/flags";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { ConsoleLogService } from "../platform/services/console-log.service";
import { I18nService } from "../platform/services/i18n.service";
import { CliI18nService } from "../platform/services/i18n.service";
import { LowdbStorageService } from "../platform/services/lowdb-storage.service";
import { NodeApiService } from "../platform/services/node-api.service";
import { NodeEnvSecureStorageService } from "../platform/services/node-env-secure-storage.service";
@@ -189,7 +189,7 @@ export class ServiceContainer {
secureStorageService: NodeEnvSecureStorageService;
memoryStorageService: MemoryStorageService;
memoryStorageForStateProviders: MemoryStorageServiceForStateProviders;
i18nService: I18nService;
i18nService: CliI18nService;
platformUtilsService: CliPlatformUtilsService;
keyService: KeyService;
tokenService: TokenService;
@@ -331,7 +331,7 @@ export class ServiceContainer {
storageServiceProvider,
);
this.i18nService = new I18nService("en", "./locales", this.globalStateProvider);
this.i18nService = new CliI18nService("en", "./locales", this.globalStateProvider);
this.singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider,

View File

@@ -11,6 +11,7 @@
"allowJs": true,
"sourceMap": true,
"baseUrl": ".",
"resolveJsonModule": true,
"paths": {
"@bitwarden/common/spec": ["../../libs/common/spec"],
"@bitwarden/admin-console/common": ["../../libs/admin-console/src/common"],

View File

@@ -3,10 +3,14 @@ import * as path from "path";
import { app, ipcMain } from "electron";
import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
export class I18nMainService extends BaseI18nService {
import type DesktopMessages from "../../locales/en/messages.json";
type DesktopMessages = typeof DesktopMessages;
export class I18nMainService extends BaseI18nService<DesktopMessages> {
constructor(
systemLanguage: string,
localesDirectory: string,

View File

@@ -1,7 +1,7 @@
import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
export class I18nRendererService extends BaseI18nService {
export class I18nRendererService extends BaseI18nService<["desktop"]> {
constructor(
systemLanguage: string,
localesDirectory: string,

View File

@@ -9,6 +9,7 @@
"sourceMap": true,
"types": [],
"baseUrl": ".",
"resolveJsonModule": true,
"paths": {
"@bitwarden/admin-console/common": ["../../libs/admin-console/src/common"],
"@bitwarden/angular/*": ["../../libs/angular/src/*"],

View File

@@ -17,7 +17,7 @@ import { FakeGlobalState } from "@bitwarden/common/spec/fake-state";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { I18nService } from "../../core/i18n.service";
import { WebI18nService } from "../../core/i18n.service";
import {
AcceptOrganizationInviteService,
@@ -36,7 +36,7 @@ describe("AcceptOrganizationInviteService", () => {
let logService: MockProxy<LogService>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let i18nService: MockProxy<I18nService>;
let i18nService: MockProxy<WebI18nService>;
let globalStateProvider: FakeGlobalStateProvider;
let globalState: FakeGlobalState<OrganizationInvite>;

View File

@@ -94,7 +94,7 @@ import {
} from "../auth";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
import { WebI18nService } from "../core/i18n.service";
import { WebBiometricsService } from "../key-management/web-biometric.service";
import { WebEnvironmentService } from "../platform/web-environment.service";
import { WebMigrationRunner } from "../platform/web-migration-runner";
@@ -133,7 +133,7 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: I18nServiceAbstraction,
useClass: I18nService,
useClass: WebI18nService,
deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY, GlobalStateProvider],
}),
safeProvider({ provide: AbstractStorageService, useClass: HtmlStorageService, deps: [] }),

View File

@@ -1,9 +1,9 @@
import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { BaseI18nService } from "@bitwarden/common/platform/services/i18n.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { SupportedTranslationLocales } from "../../translation-constants";
export class I18nService extends BaseI18nService {
export class WebI18nService extends BaseI18nService<["web"]> {
constructor(
systemLanguage: string,
localesDirectory: string,

View File

@@ -1,8 +1,8 @@
import { Observable } from "rxjs";
import { TranslationService } from "./translation.service";
import { ClientTuple, TranslationService } from "./translation.service";
export abstract class I18nService extends TranslationService {
export abstract class I18nService<T extends ClientTuple> extends TranslationService<T> {
abstract userSetLocale$: Observable<string | undefined>;
abstract locale$: Observable<string>;
abstract setLocale(locale: string): Promise<void>;

View File

@@ -1,8 +1,40 @@
export abstract class TranslationService {
// eslint-disable-next-line import/no-restricted-paths
import type BrowserMessages from "../../../../../apps/browser/src/_locales/en/messages.json";
// eslint-disable-next-line import/no-restricted-paths
import type CliMessages from "../../../../../apps/cli/src/locales/en/messages.json";
// eslint-disable-next-line import/no-restricted-paths
import type DesktopMessages from "../../../../../apps/desktop/src/locales/en/messages.json";
// eslint-disable-next-line import/no-restricted-paths
import type WebMessages from "../../../../../apps/web/src/locales/en/messages.json";
type BrowserMessages = typeof BrowserMessages;
type CliMessages = typeof CliMessages;
type DesktopMessages = typeof DesktopMessages;
type WebMessages = typeof WebMessages;
type Messages = {
browser: BrowserMessages;
cli: CliMessages;
desktop: DesktopMessages;
web: WebMessages;
};
export type ClientTuple = (keyof Messages)[];
export type I18nKeys<TClients extends ClientTuple> = keyof {
[Index in keyof TClients]: Messages[TClients[Index]];
}[number];
export abstract class TranslationService<TClients extends ClientTuple> {
abstract supportedTranslationLocales: string[];
abstract translationLocale: string;
abstract collator: Intl.Collator;
abstract localeNames: Map<string, string>;
abstract t(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string;
abstract translate(id: string, p1?: string, p2?: string, p3?: string): string;
abstract t(
id: I18nKeys<TClients>,
p1?: string | number,
p2?: string | number,
p3?: string | number,
): string;
abstract translate(id: I18nKeys<TClients>, p1?: string, p2?: string, p3?: string): string;
}

View File

@@ -1,6 +1,7 @@
import { Observable, firstValueFrom, map } from "rxjs";
import { I18nService as I18nServiceAbstraction } from "../abstractions/i18n.service";
import { ClientTuple } from "../abstractions/translation.service";
import { GlobalState, GlobalStateProvider, KeyDefinition, TRANSLATION_DISK } from "../state";
import { TranslationService } from "./translation.service";
@@ -9,7 +10,10 @@ const LOCALE_KEY = new KeyDefinition<string>(TRANSLATION_DISK, "locale", {
deserializer: (value) => value,
});
export class I18nService extends TranslationService implements I18nServiceAbstraction {
export abstract class BaseI18nService<T extends ClientTuple>
extends TranslationService<T>
implements I18nServiceAbstraction<T>
{
translationLocale: string;
protected translationLocaleState: GlobalState<string>;
userSetLocale$: Observable<string | undefined>;

View File

@@ -1,6 +1,12 @@
import { TranslationService as TranslationServiceAbstraction } from "../abstractions/translation.service";
import {
ClientTuple,
I18nKeys,
TranslationService as TranslationServiceAbstraction,
} from "../abstractions/translation.service";
export abstract class TranslationService implements TranslationServiceAbstraction {
export abstract class TranslationService<T extends ClientTuple>
implements TranslationServiceAbstraction<T>
{
// First locale is the default (English)
supportedTranslationLocales: string[] = ["en"];
defaultLocale = "en";
@@ -121,11 +127,16 @@ export abstract class TranslationService implements TranslationServiceAbstractio
}
}
t(id: string, p1?: string, p2?: string, p3?: string): string {
t(id: I18nKeys<ClientTuple>, p1?: string, p2?: string, p3?: string): string {
return this.translate(id, p1, p2, p3);
}
translate(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string {
translate(
id: I18nKeys<ClientTuple>,
p1?: string | number,
p2?: string | number,
p3?: string | number,
): string {
let result: string;
// eslint-disable-next-line
if (this.localeMessages.hasOwnProperty(id) && this.localeMessages[id]) {