diff --git a/apps/web/src/app/core/i18n.service.ts b/apps/web/src/app/core/i18n.service.ts index 843bb32291..132b8c40bc 100644 --- a/apps/web/src/app/core/i18n.service.ts +++ b/apps/web/src/app/core/i18n.service.ts @@ -1,5 +1,7 @@ import { I18nService as BaseI18nService } from "@bitwarden/common/services/i18n.service"; +import { SupportedTranslationLocales } from "../../translation-constants"; + export class I18nService extends BaseI18nService { constructor(systemLanguage: string, localesDirectory: string) { super(systemLanguage || "en-US", localesDirectory, async (formattedLocale: string) => { @@ -14,61 +16,6 @@ export class I18nService extends BaseI18nService { return locales; }); - // Please leave 'en' where it is, as it's our fallback language in case no translation can be found - this.supportedTranslationLocales = [ - "en", - "af", - "ar", - "az", - "be", - "bg", - "bn", - "bs", - "ca", - "cs", - "da", - "de", - "el", - "en-GB", - "en-IN", - "eo", - "es", - "et", - "eu", - "fi", - "fil", - "fr", - "he", - "hi", - "hr", - "hu", - "id", - "it", - "ja", - "ka", - "km", - "kn", - "ko", - "lv", - "ml", - "nb", - "nl", - "nn", - "pl", - "pt-PT", - "pt-BR", - "ro", - "ru", - "si", - "sk", - "sl", - "sr", - "sv", - "tr", - "uk", - "vi", - "zh-CN", - "zh-TW", - ]; + this.supportedTranslationLocales = SupportedTranslationLocales; } } diff --git a/apps/web/src/connectors/translation.service.ts b/apps/web/src/connectors/translation.service.ts new file mode 100644 index 0000000000..3bf73c66c1 --- /dev/null +++ b/apps/web/src/connectors/translation.service.ts @@ -0,0 +1,31 @@ +import { TranslationService as BaseTranslationService } from "@bitwarden/common/services/translation.service"; + +import { SupportedTranslationLocales } from "../translation-constants"; + +export class TranslationService extends BaseTranslationService { + private _translationLocale: string; + + constructor(systemLanguage: string, localesDirectory: string) { + super(systemLanguage || "en-US", localesDirectory, async (formattedLocale: string) => { + const filePath = + this.localesDirectory + + "/" + + formattedLocale + + "/messages.json?cache=" + + process.env.CACHE_TAG; + const localesResult = await fetch(filePath); + const locales = await localesResult.json(); + return locales; + }); + + this.supportedTranslationLocales = SupportedTranslationLocales; + } + + get translationLocale(): string { + return this._translationLocale; + } + + set translationLocale(locale: string) { + this._translationLocale = locale; + } +} diff --git a/apps/web/src/connectors/webauthn-fallback.ts b/apps/web/src/connectors/webauthn-fallback.ts index 60d53e0324..bae95a0ad5 100644 --- a/apps/web/src/connectors/webauthn-fallback.ts +++ b/apps/web/src/connectors/webauthn-fallback.ts @@ -1,5 +1,6 @@ import { b64Decode, getQsParam } from "./common"; import { buildDataString, parseWebauthnJson } from "./common-webauthn"; +import { TranslationService } from "./translation.service"; require("./webauthn.scss"); @@ -7,9 +8,8 @@ let parsed = false; let webauthnJson: any; let parentUrl: string = null; let sentSuccess = false; -let locale = "en"; - -let locales: any = {}; +let locale: string = null; +let localeService: TranslationService = null; function parseParameters() { if (parsed) { @@ -24,7 +24,7 @@ function parseParameters() { parentUrl = decodeURIComponent(parentUrl); } - locale = getQsParam("locale").replace("-", "_"); + locale = getQsParam("locale") ?? "en"; const version = getQsParam("v"); @@ -61,18 +61,19 @@ function parseParametersV2() { document.addEventListener("DOMContentLoaded", async () => { parseParameters(); try { - locales = await loadLocales(locale); + localeService = new TranslationService(locale, "locales"); } catch { - // eslint-disable-next-line - console.error("Failed to load the locale", locale); - locales = await loadLocales("en"); + error("Failed to load the provided locale " + locale); + localeService = new TranslationService("en", "locales"); } - document.getElementById("msg").innerText = translate("webAuthnFallbackMsg"); - document.getElementById("remember-label").innerText = translate("rememberMe"); + await localeService.init(); + + document.getElementById("msg").innerText = localeService.t("webAuthnFallbackMsg"); + document.getElementById("remember-label").innerText = localeService.t("rememberMe"); const button = document.getElementById("webauthn-button"); - button.innerText = translate("webAuthnAuthenticate"); + button.innerText = localeService.t("webAuthnAuthenticate"); button.onclick = start; document.getElementById("spinner").classList.add("d-none"); @@ -81,23 +82,13 @@ document.addEventListener("DOMContentLoaded", async () => { content.classList.remove("d-none"); }); -async function loadLocales(newLocale: string) { - const filePath = `locales/${newLocale}/messages.json?cache=${process.env.CACHE_TAG}`; - const localesResult = await fetch(filePath); - return await localesResult.json(); -} - -function translate(id: string) { - return locales[id]?.message || ""; -} - function start() { if (sentSuccess) { return; } if (!("credentials" in navigator)) { - error(translate("webAuthnNotSupported")); + error(localeService.t("webAuthnNotSupported")); return; } @@ -133,7 +124,7 @@ async function initWebAuthn(obj: any) { window.postMessage({ command: "webAuthnResult", data: dataString, remember: remember }, "*"); sentSuccess = true; - success(translate("webAuthnSuccess")); + success(localeService.t("webAuthnSuccess")); } catch (err) { error(err); } diff --git a/apps/web/src/translation-constants.ts b/apps/web/src/translation-constants.ts new file mode 100644 index 0000000000..dfdcc7c1f5 --- /dev/null +++ b/apps/web/src/translation-constants.ts @@ -0,0 +1,56 @@ +// Please leave 'en' where it is, as it's our fallback language in case no translation can be found +export const SupportedTranslationLocales: string[] = [ + "en", + "af", + "ar", + "az", + "be", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en-GB", + "en-IN", + "eo", + "es", + "et", + "eu", + "fi", + "fil", + "fr", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ka", + "km", + "kn", + "ko", + "lv", + "ml", + "nb", + "nl", + "nn", + "pl", + "pt-PT", + "pt-BR", + "ro", + "ru", + "si", + "sk", + "sl", + "sr", + "sv", + "tr", + "uk", + "vi", + "zh-CN", + "zh-TW", +]; diff --git a/libs/common/src/abstractions/i18n.service.ts b/libs/common/src/abstractions/i18n.service.ts index f4a9b8ceef..f2c8117e36 100644 --- a/libs/common/src/abstractions/i18n.service.ts +++ b/libs/common/src/abstractions/i18n.service.ts @@ -1,11 +1,7 @@ import { Observable } from "rxjs"; -export abstract class I18nService { +import { TranslationService } from "./translation.service"; + +export abstract class I18nService extends TranslationService { locale$: Observable; - supportedTranslationLocales: string[]; - translationLocale: string; - collator: Intl.Collator; - localeNames: Map; - t: (id: string, p1?: string | number, p2?: string | number, p3?: string | number) => string; - translate: (id: string, p1?: string, p2?: string, p3?: string) => string; } diff --git a/libs/common/src/abstractions/translation.service.ts b/libs/common/src/abstractions/translation.service.ts new file mode 100644 index 0000000000..797965038a --- /dev/null +++ b/libs/common/src/abstractions/translation.service.ts @@ -0,0 +1,8 @@ +export abstract class TranslationService { + supportedTranslationLocales: string[]; + translationLocale: string; + collator: Intl.Collator; + localeNames: Map; + t: (id: string, p1?: string | number, p2?: string | number, p3?: string | number) => string; + translate: (id: string, p1?: string, p2?: string, p3?: string) => string; +} diff --git a/libs/common/src/services/i18n.service.ts b/libs/common/src/services/i18n.service.ts index 022fe194dd..23ce790730 100644 --- a/libs/common/src/services/i18n.service.ts +++ b/libs/common/src/services/i18n.service.ts @@ -2,184 +2,27 @@ import { Observable, ReplaySubject } from "rxjs"; import { I18nService as I18nServiceAbstraction } from "../abstractions/i18n.service"; -export class I18nService implements I18nServiceAbstraction { - protected _locale = new ReplaySubject(1); - locale$: Observable = this._locale.asObservable(); - // First locale is the default (English) - supportedTranslationLocales: string[] = ["en"]; - defaultLocale = "en"; - translationLocale: string; - collator: Intl.Collator; - localeNames = new Map([ - ["af", "Afrikaans"], - ["ar", "العربية الفصحى"], - ["az", "Azərbaycanca"], - ["be", "Беларуская"], - ["bg", "български"], - ["bn", "বাংলা"], - ["bs", "bosanski jezik"], - ["ca", "català"], - ["cs", "čeština"], - ["da", "dansk"], - ["de", "Deutsch"], - ["el", "Ελληνικά"], - ["en", "English"], - ["en-GB", "English (British)"], - ["en-IN", "English (India)"], - ["eo", "Esperanto"], - ["es", "español"], - ["et", "eesti"], - ["eu", "euskara"], - ["fa", "فارسی"], - ["fi", "suomi"], - ["fil", "Wikang Filipino"], - ["fr", "français"], - ["he", "עברית"], - ["hi", "हिन्दी"], - ["hr", "hrvatski"], - ["hu", "magyar"], - ["id", "Bahasa Indonesia"], - ["it", "italiano"], - ["ja", "日本語"], - ["ka", "ქართული"], - ["km", "ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ"], - ["kn", "ಕನ್ನಡ"], - ["ko", "한국어"], - ["lt", "lietuvių kalba"], - ["lv", "Latvietis"], - ["me", "црногорски"], - ["ml", "മലയാളം"], - ["nb", "norsk (bokmål)"], - ["nl", "Nederlands"], - ["nn", "Norsk Nynorsk"], - ["pl", "polski"], - ["pt-BR", "português do Brasil"], - ["pt-PT", "português"], - ["ro", "română"], - ["ru", "русский"], - ["si", "සිංහල"], - ["sk", "slovenčina"], - ["sl", "Slovenski jezik, Slovenščina"], - ["sr", "Српски"], - ["sv", "svenska"], - ["th", "ไทย"], - ["tr", "Türkçe"], - ["uk", "українська"], - ["vi", "Tiếng Việt"], - ["zh-CN", "中文(中国大陆)"], - ["zh-TW", "中文(台灣)"], - ]); +import { TranslationService } from "./translation.service"; - protected inited: boolean; - protected defaultMessages: any = {}; - protected localeMessages: any = {}; +export class I18nService extends TranslationService implements I18nServiceAbstraction { + protected _locale = new ReplaySubject(1); + private _translationLocale: string; + locale$: Observable = this._locale.asObservable(); constructor( protected systemLanguage: string, protected localesDirectory: string, protected getLocalesJson: (formattedLocale: string) => Promise ) { - this.systemLanguage = systemLanguage.replace("_", "-"); + super(systemLanguage, localesDirectory, getLocalesJson); } - async init(locale?: string) { - if (this.inited) { - throw new Error("i18n already initialized."); - } - if (this.supportedTranslationLocales == null || this.supportedTranslationLocales.length === 0) { - throw new Error("supportedTranslationLocales not set."); - } - - this.inited = true; - this.translationLocale = locale != null ? locale : this.systemLanguage; - this._locale.next(this.translationLocale); - - try { - this.collator = new Intl.Collator(this.translationLocale, { - numeric: true, - sensitivity: "base", - }); - } catch { - this.collator = null; - } - - if (this.supportedTranslationLocales.indexOf(this.translationLocale) === -1) { - this.translationLocale = this.translationLocale.slice(0, 2); - - if (this.supportedTranslationLocales.indexOf(this.translationLocale) === -1) { - this.translationLocale = this.defaultLocale; - } - } - - if (this.localesDirectory != null) { - await this.loadMessages(this.translationLocale, this.localeMessages); - if (this.translationLocale !== this.defaultLocale) { - await this.loadMessages(this.defaultLocale, this.defaultMessages); - } - } + get translationLocale(): string { + return this._translationLocale; } - t(id: string, 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 { - let result: string; - // eslint-disable-next-line - if (this.localeMessages.hasOwnProperty(id) && this.localeMessages[id]) { - result = this.localeMessages[id]; - // eslint-disable-next-line - } else if (this.defaultMessages.hasOwnProperty(id) && this.defaultMessages[id]) { - result = this.defaultMessages[id]; - } else { - result = ""; - } - - if (result !== "") { - if (p1 != null) { - result = result.split("__$1__").join(p1.toString()); - } - if (p2 != null) { - result = result.split("__$2__").join(p2.toString()); - } - if (p3 != null) { - result = result.split("__$3__").join(p3.toString()); - } - } - - return result; - } - - private async loadMessages(locale: string, messagesObj: any): Promise { - const formattedLocale = locale.replace("-", "_"); - const locales = await this.getLocalesJson(formattedLocale); - for (const prop in locales) { - // eslint-disable-next-line - if (!locales.hasOwnProperty(prop)) { - continue; - } - messagesObj[prop] = locales[prop].message; - - if (locales[prop].placeholders) { - for (const placeProp in locales[prop].placeholders) { - if ( - !locales[prop].placeholders.hasOwnProperty(placeProp) || // eslint-disable-line - !locales[prop].placeholders[placeProp].content - ) { - continue; - } - - const replaceToken = "\\$" + placeProp.toUpperCase() + "\\$"; - let replaceContent = locales[prop].placeholders[placeProp].content; - if (replaceContent === "$1" || replaceContent === "$2" || replaceContent === "$3") { - replaceContent = "__$" + replaceContent + "__"; - } - messagesObj[prop] = messagesObj[prop].replace( - new RegExp(replaceToken, "g"), - replaceContent - ); - } - } - } + set translationLocale(locale: string) { + this._translationLocale = locale; + this._locale.next(locale); } } diff --git a/libs/common/src/services/translation.service.ts b/libs/common/src/services/translation.service.ts new file mode 100644 index 0000000000..dab70f0308 --- /dev/null +++ b/libs/common/src/services/translation.service.ts @@ -0,0 +1,180 @@ +import { TranslationService as TranslationServiceAbstraction } from "../abstractions/translation.service"; + +export abstract class TranslationService implements TranslationServiceAbstraction { + // First locale is the default (English) + supportedTranslationLocales: string[] = ["en"]; + defaultLocale = "en"; + abstract translationLocale: string; + collator: Intl.Collator; + localeNames = new Map([ + ["af", "Afrikaans"], + ["ar", "العربية الفصحى"], + ["az", "Azərbaycanca"], + ["be", "Беларуская"], + ["bg", "български"], + ["bn", "বাংলা"], + ["bs", "bosanski jezik"], + ["ca", "català"], + ["cs", "čeština"], + ["da", "dansk"], + ["de", "Deutsch"], + ["el", "Ελληνικά"], + ["en", "English"], + ["en-GB", "English (British)"], + ["en-IN", "English (India)"], + ["eo", "Esperanto"], + ["es", "español"], + ["et", "eesti"], + ["eu", "euskara"], + ["fa", "فارسی"], + ["fi", "suomi"], + ["fil", "Wikang Filipino"], + ["fr", "français"], + ["he", "עברית"], + ["hi", "हिन्दी"], + ["hr", "hrvatski"], + ["hu", "magyar"], + ["id", "Bahasa Indonesia"], + ["it", "italiano"], + ["ja", "日本語"], + ["ka", "ქართული"], + ["km", "ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ"], + ["kn", "ಕನ್ನಡ"], + ["ko", "한국어"], + ["lt", "lietuvių kalba"], + ["lv", "Latvietis"], + ["me", "црногорски"], + ["ml", "മലയാളം"], + ["nb", "norsk (bokmål)"], + ["nl", "Nederlands"], + ["nn", "Norsk Nynorsk"], + ["pl", "polski"], + ["pt-BR", "português do Brasil"], + ["pt-PT", "português"], + ["ro", "română"], + ["ru", "русский"], + ["si", "සිංහල"], + ["sk", "slovenčina"], + ["sl", "Slovenski jezik, Slovenščina"], + ["sr", "Српски"], + ["sv", "svenska"], + ["th", "ไทย"], + ["tr", "Türkçe"], + ["uk", "українська"], + ["vi", "Tiếng Việt"], + ["zh-CN", "中文(中国大陆)"], + ["zh-TW", "中文(台灣)"], + ]); + + protected inited: boolean; + protected defaultMessages: any = {}; + protected localeMessages: any = {}; + + constructor( + protected systemLanguage: string, + protected localesDirectory: string, + protected getLocalesJson: (formattedLocale: string) => Promise + ) { + this.systemLanguage = systemLanguage.replace("_", "-"); + } + + async init(locale?: string) { + if (this.inited) { + throw new Error("i18n already initialized."); + } + if (this.supportedTranslationLocales == null || this.supportedTranslationLocales.length === 0) { + throw new Error("supportedTranslationLocales not set."); + } + + this.inited = true; + this.translationLocale = locale != null ? locale : this.systemLanguage; + + try { + this.collator = new Intl.Collator(this.translationLocale, { + numeric: true, + sensitivity: "base", + }); + } catch { + this.collator = null; + } + + if (this.supportedTranslationLocales.indexOf(this.translationLocale) === -1) { + this.translationLocale = this.translationLocale.slice(0, 2); + + if (this.supportedTranslationLocales.indexOf(this.translationLocale) === -1) { + this.translationLocale = this.defaultLocale; + } + } + + if (this.localesDirectory != null) { + await this.loadMessages(this.translationLocale, this.localeMessages); + if (this.translationLocale !== this.defaultLocale) { + await this.loadMessages(this.defaultLocale, this.defaultMessages); + } + } + } + + t(id: string, 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 { + let result: string; + // eslint-disable-next-line + if (this.localeMessages.hasOwnProperty(id) && this.localeMessages[id]) { + result = this.localeMessages[id]; + // eslint-disable-next-line + } else if (this.defaultMessages.hasOwnProperty(id) && this.defaultMessages[id]) { + result = this.defaultMessages[id]; + } else { + result = ""; + } + + if (result !== "") { + if (p1 != null) { + result = result.split("__$1__").join(p1.toString()); + } + if (p2 != null) { + result = result.split("__$2__").join(p2.toString()); + } + if (p3 != null) { + result = result.split("__$3__").join(p3.toString()); + } + } + + return result; + } + + protected async loadMessages(locale: string, messagesObj: any): Promise { + const formattedLocale = locale.replace("-", "_"); + const locales = await this.getLocalesJson(formattedLocale); + for (const prop in locales) { + // eslint-disable-next-line + if (!locales.hasOwnProperty(prop)) { + continue; + } + messagesObj[prop] = locales[prop].message; + + if (locales[prop].placeholders) { + for (const placeProp in locales[prop].placeholders) { + if ( + !locales[prop].placeholders.hasOwnProperty(placeProp) || // eslint-disable-line + !locales[prop].placeholders[placeProp].content + ) { + continue; + } + + const replaceToken = "\\$" + placeProp.toUpperCase() + "\\$"; + let replaceContent = locales[prop].placeholders[placeProp].content; + if (replaceContent === "$1" || replaceContent === "$2" || replaceContent === "$3") { + replaceContent = "__$" + replaceContent + "__"; + } + messagesObj[prop] = messagesObj[prop].replace( + new RegExp(replaceToken, "g"), + replaceContent + ); + } + } + } + } +}