From b95e4ff8e2660c2324b6145dff47d340305073a8 Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Thu, 9 Jan 2025 18:23:46 +0100 Subject: [PATCH] Support manual override of text blocks --- examples/api-samples/package.json | 3 ++ .../src/browser/api-samples-preload-module.ts | 23 ++++++++++++ .../browser/i18n/i18n-replacement-sample.ts | 37 +++++++++++++++++++ .../src/tests/theia-quick-command.test.ts | 2 +- .../i18n/i18n-frontend-only-module.ts | 10 ++--- .../preload/frontend-only-preload-module.ts | 2 +- .../preload/i18n-preload-contribution.ts | 26 ++++++++++++- .../preload/i18n-replacement-contribution.ts | 21 +++++++++++ .../src/browser/preload/preload-module.ts | 8 ++-- packages/core/src/common/i18n/localization.ts | 14 +++++-- .../node/i18n/localization-contribution.ts | 12 +++--- .../src/node/i18n/localization-provider.ts | 8 ++-- .../notebook-cell-actions-contribution.ts | 6 +-- .../hosted-plugin-localization-service.ts | 4 +- 14 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 examples/api-samples/src/browser/api-samples-preload-module.ts create mode 100644 examples/api-samples/src/browser/i18n/i18n-replacement-sample.ts create mode 100644 packages/core/src/browser/preload/i18n-replacement-contribution.ts diff --git a/examples/api-samples/package.json b/examples/api-samples/package.json index c9ad12d8950b7..9872d43a3b35d 100644 --- a/examples/api-samples/package.json +++ b/examples/api-samples/package.json @@ -35,6 +35,9 @@ }, { "frontendOnly": "lib/browser-only/api-samples-frontend-only-module" + }, + { + "frontendPreload": "lib/browser/api-samples-preload-module" } ], "keywords": [ diff --git a/examples/api-samples/src/browser/api-samples-preload-module.ts b/examples/api-samples/src/browser/api-samples-preload-module.ts new file mode 100644 index 0000000000000..fc4ee90437875 --- /dev/null +++ b/examples/api-samples/src/browser/api-samples-preload-module.ts @@ -0,0 +1,23 @@ +// ***************************************************************************** +// Copyright (C) 2024 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContainerModule } from '@theia/core/shared/inversify'; +import { I18nReplacementContribution } from '@theia/core/lib/browser/preload/i18n-replacement-contribution'; +import { I18nSampleReplacementContribution } from './i18n/i18n-replacement-sample'; + +export default new ContainerModule(bind => { + bind(I18nReplacementContribution).to(I18nSampleReplacementContribution).inSingletonScope(); +}); diff --git a/examples/api-samples/src/browser/i18n/i18n-replacement-sample.ts b/examples/api-samples/src/browser/i18n/i18n-replacement-sample.ts new file mode 100644 index 0000000000000..c42cfed9620a1 --- /dev/null +++ b/examples/api-samples/src/browser/i18n/i18n-replacement-sample.ts @@ -0,0 +1,37 @@ +// ***************************************************************************** +// Copyright (C) 2024 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { I18nReplacementContribution } from '@theia/core/lib/browser/preload/i18n-replacement-contribution'; + +export class I18nSampleReplacementContribution implements I18nReplacementContribution { + + getReplacement(locale: string): Record { + switch (locale) { + case 'en': { + return { + 'About': 'About Theia', + }; + } + case 'de': { + return { + 'About': 'Über Theia', + }; + } + } + return {}; + } + +} diff --git a/examples/playwright/src/tests/theia-quick-command.test.ts b/examples/playwright/src/tests/theia-quick-command.test.ts index f24ee87522df0..966b6f5917e24 100644 --- a/examples/playwright/src/tests/theia-quick-command.test.ts +++ b/examples/playwright/src/tests/theia-quick-command.test.ts @@ -48,7 +48,7 @@ test.describe('Theia Quick Command', () => { test('should trigger \'About\' command after typing', async () => { await quickCommand.type('About'); - await quickCommand.trigger('About'); + await quickCommand.trigger('About Theia'); expect(await quickCommand.isOpen()).toBe(false); const aboutDialog = new TheiaAboutDialog(app); expect(await aboutDialog.isVisible()).toBe(true); diff --git a/packages/core/src/browser-only/i18n/i18n-frontend-only-module.ts b/packages/core/src/browser-only/i18n/i18n-frontend-only-module.ts index e88337fcb65b7..fd7c41a1d81d1 100644 --- a/packages/core/src/browser-only/i18n/i18n-frontend-only-module.ts +++ b/packages/core/src/browser-only/i18n/i18n-frontend-only-module.ts @@ -21,14 +21,10 @@ import { LanguageQuickPickService } from '../../browser/i18n/language-quick-pick export default new ContainerModule(bind => { const i18nMock: AsyncLocalizationProvider = { getCurrentLanguage: async (): Promise => 'en', - setCurrentLanguage: async (_languageId: string): Promise => { - - }, - getAvailableLanguages: async (): Promise => - [] - , + setCurrentLanguage: async (_languageId: string): Promise => { }, + getAvailableLanguages: async (): Promise => [], loadLocalization: async (_languageId: string): Promise => ({ - translations: {}, + translations: new Map(), languageId: 'en' }) }; diff --git a/packages/core/src/browser-only/preload/frontend-only-preload-module.ts b/packages/core/src/browser-only/preload/frontend-only-preload-module.ts index ea0ed3fcb7e2a..2fe31a63964c7 100644 --- a/packages/core/src/browser-only/preload/frontend-only-preload-module.ts +++ b/packages/core/src/browser-only/preload/frontend-only-preload-module.ts @@ -22,7 +22,7 @@ import { Localization } from '../../common/i18n/localization'; // loaded after regular preload module export default new ContainerModule((bind, unbind, isBound, rebind) => { const frontendOnlyLocalizationServer: LocalizationServer = { - loadLocalization: async (languageId: string): Promise => ({ translations: {}, languageId }) + loadLocalization: async (languageId: string): Promise => ({ translations: new Map(), languageId }) }; if (isBound(LocalizationServer)) { rebind(LocalizationServer).toConstantValue(frontendOnlyLocalizationServer); diff --git a/packages/core/src/browser/preload/i18n-preload-contribution.ts b/packages/core/src/browser/preload/i18n-preload-contribution.ts index 0cddfa112e723..f3e7d2cf99e59 100644 --- a/packages/core/src/browser/preload/i18n-preload-contribution.ts +++ b/packages/core/src/browser/preload/i18n-preload-contribution.ts @@ -17,8 +17,10 @@ import { PreloadContribution } from './preloader'; import { FrontendApplicationConfigProvider } from '../frontend-application-config-provider'; import { nls } from '../../common/nls'; -import { inject, injectable } from 'inversify'; +import { inject, injectable, named } from 'inversify'; import { LocalizationServer } from '../../common/i18n/localization-server'; +import { ContributionProvider } from '../../common'; +import { I18nReplacementContribution } from './i18n-replacement-contribution'; @injectable() export class I18nPreloadContribution implements PreloadContribution { @@ -26,6 +28,9 @@ export class I18nPreloadContribution implements PreloadContribution { @inject(LocalizationServer) protected readonly localizationServer: LocalizationServer; + @inject(ContributionProvider) @named(I18nReplacementContribution) + protected readonly i18nReplacementContributions: ContributionProvider; + async initialize(): Promise { const defaultLocale = FrontendApplicationConfigProvider.get().defaultLocale; if (defaultLocale && !nls.locale) { @@ -33,8 +38,9 @@ export class I18nPreloadContribution implements PreloadContribution { locale: defaultLocale }); } + let locale = nls.locale ?? nls.defaultLocale; if (nls.locale && nls.locale !== nls.defaultLocale) { - const localization = await this.localizationServer.loadLocalization(nls.locale); + const localization = await this.localizationServer.loadLocalization(locale); if (localization.languagePack) { nls.localization = localization; } else { @@ -43,8 +49,24 @@ export class I18nPreloadContribution implements PreloadContribution { Object.assign(nls, { locale: defaultLocale || undefined }); + locale = defaultLocale; + } + } + const replacements = this.getReplacements(locale); + if (replacements.size > 0) { + nls.localization ??= { translations: new Map(), languageId: locale }; + nls.localization.replacements = replacements; + } + } + + protected getReplacements(locale: string): Map { + const replacements = new Map(); + for (const contribution of this.i18nReplacementContributions.getContributions()) { + for (const [value, replacement] of Object.entries(contribution.getReplacement(locale))) { + replacements.set(value, replacement); } } + return replacements; } } diff --git a/packages/core/src/browser/preload/i18n-replacement-contribution.ts b/packages/core/src/browser/preload/i18n-replacement-contribution.ts new file mode 100644 index 0000000000000..e8588e1726903 --- /dev/null +++ b/packages/core/src/browser/preload/i18n-replacement-contribution.ts @@ -0,0 +1,21 @@ +// ***************************************************************************** +// Copyright (C) 2023 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export const I18nReplacementContribution = Symbol('I18nReplacementContribution'); + +export interface I18nReplacementContribution { + getReplacement(locale: string): Record; +} diff --git a/packages/core/src/browser/preload/preload-module.ts b/packages/core/src/browser/preload/preload-module.ts index a6dcadcc28a26..b23cafc9798a5 100644 --- a/packages/core/src/browser/preload/preload-module.ts +++ b/packages/core/src/browser/preload/preload-module.ts @@ -21,19 +21,21 @@ import { I18nPreloadContribution } from './i18n-preload-contribution'; import { OSPreloadContribution } from './os-preload-contribution'; import { ThemePreloadContribution } from './theme-preload-contribution'; import { LocalizationServer, LocalizationServerPath } from '../../common/i18n/localization-server'; -import { WebSocketConnectionProvider } from '../messaging/ws-connection-provider'; +import { ServiceConnectionProvider } from '../messaging/service-connection-provider'; import { OSBackendProvider, OSBackendProviderPath } from '../../common/os'; +import { I18nReplacementContribution } from './i18n-replacement-contribution'; export default new ContainerModule(bind => { bind(Preloader).toSelf().inSingletonScope(); bindContributionProvider(bind, PreloadContribution); + bindContributionProvider(bind, I18nReplacementContribution); bind(LocalizationServer).toDynamicValue(ctx => - WebSocketConnectionProvider.createProxy(ctx.container, LocalizationServerPath) + ServiceConnectionProvider.createProxy(ctx.container, LocalizationServerPath) ).inSingletonScope(); bind(OSBackendProvider).toDynamicValue(ctx => - WebSocketConnectionProvider.createProxy(ctx.container, OSBackendProviderPath) + ServiceConnectionProvider.createProxy(ctx.container, OSBackendProviderPath) ).inSingletonScope(); bind(I18nPreloadContribution).toSelf().inSingletonScope(); diff --git a/packages/core/src/common/i18n/localization.ts b/packages/core/src/common/i18n/localization.ts index e33e7995f4377..ab9669fabd94e 100644 --- a/packages/core/src/common/i18n/localization.ts +++ b/packages/core/src/common/i18n/localization.ts @@ -25,7 +25,8 @@ export interface AsyncLocalizationProvider { } export interface Localization extends LanguageInfo { - translations: { [key: string]: string }; + translations: Map; + replacements?: Map; } export interface LanguageInfo { @@ -50,9 +51,14 @@ export namespace Localization { export function localize(localization: Localization | undefined, key: string, defaultValue: string, ...args: FormatType[]): string { let value = defaultValue; if (localization) { - const translation = localization.translations[key]; - if (translation) { - value = normalize(translation); + const replacement = localization.replacements?.get(defaultValue); + if (replacement) { + value = replacement; + } else { + const translation = localization.translations.get(key); + if (translation) { + value = normalize(translation); + } } } return format(value, args); diff --git a/packages/core/src/node/i18n/localization-contribution.ts b/packages/core/src/node/i18n/localization-contribution.ts index 74b163809fb82..2ccb4f449dfd7 100644 --- a/packages/core/src/node/i18n/localization-contribution.ts +++ b/packages/core/src/node/i18n/localization-contribution.ts @@ -66,7 +66,7 @@ export class LocalizationRegistry { })); } - protected createLocalization(locale: string | LanguageInfo, translations: () => Promise>): LazyLocalization { + protected createLocalization(locale: string | LanguageInfo, translations: () => Promise>): LazyLocalization { let localization: LazyLocalization; if (typeof locale === 'string') { localization = { @@ -82,22 +82,22 @@ export class LocalizationRegistry { return localization; } - protected flattenTranslations(localization: unknown): Record { + protected flattenTranslations(localization: unknown): Map { if (isObject(localization)) { - const record: Record = {}; + const record = new Map(); for (const [key, value] of Object.entries(localization)) { if (typeof value === 'string') { - record[key] = value; + record.set(key, value); } else if (isObject(value)) { const flattened = this.flattenTranslations(value); for (const [flatKey, flatValue] of Object.entries(flattened)) { - record[`${key}/${flatKey}`] = flatValue; + record.set(`${key}/${flatKey}`, flatValue); } } } return record; } else { - return {}; + return new Map(); } } diff --git a/packages/core/src/node/i18n/localization-provider.ts b/packages/core/src/node/i18n/localization-provider.ts index 7064aaba3fbc1..82a8f9b132f4a 100644 --- a/packages/core/src/node/i18n/localization-provider.ts +++ b/packages/core/src/node/i18n/localization-provider.ts @@ -25,7 +25,7 @@ import { isObject } from '../../common/types'; * Allows to load localizations on demand when requested by the user. */ export interface LazyLocalization extends LanguageInfo { - getTranslations(): Promise>; + getTranslations(): Promise>; } export namespace LazyLocalization { @@ -110,14 +110,16 @@ export class LocalizationProvider { async loadLocalization(languageId: string): Promise { const merged: Localization = { languageId, - translations: {} + translations: new Map() }; const localizations = await Promise.all(this.localizations.filter(e => e.languageId === languageId).map(LazyLocalization.toLocalization)); for (const localization of localizations) { merged.languageName ||= localization.languageName; merged.localizedLanguageName ||= localization.localizedLanguageName; merged.languagePack ||= localization.languagePack; - Object.assign(merged.translations, localization.translations); + for (const [key, value] of localization.translations.entries()) { + merged.translations.set(key, value); + } } return merged; } diff --git a/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts b/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts index 38c50a753c21a..025a9f2f31568 100644 --- a/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts +++ b/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts @@ -65,19 +65,19 @@ export namespace NotebookCellCommands { export const EXECUTE_SINGLE_CELL_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.cell.execute-cell', category: 'Notebook', - label: nls.localizeByDefault('Execute Cell'), + label: 'Execute Cell', iconClass: codicon('play'), }); /** Parameters: notebookModel: NotebookModel, cell: NotebookCellModel */ export const EXECUTE_SINGLE_CELL_AND_FOCUS_NEXT_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.cell.execute-cell-and-focus-next', - label: nls.localizeByDefault('Execute Notebook Cell and Select Below'), + label: 'Execute Notebook Cell and Select Below', category: 'Notebook', }); /** Parameters: notebookModel: NotebookModel, cell: NotebookCellModel */ export const EXECUTE_SINGLE_CELL_AND_INSERT_BELOW_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.cell.execute-cell-and-insert-below', - label: nls.localizeByDefault('Execute Notebook Cell and Insert Below'), + label: 'Execute Notebook Cell and Insert Below', category: 'Notebook', }); diff --git a/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts b/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts index 98e59e637cf26..606f26ee3e08e 100644 --- a/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts +++ b/packages/plugin-ext/src/hosted/node/hosted-plugin-localization-service.ts @@ -290,9 +290,9 @@ function buildLocalizations(packageUri: string, localizations: PluginLocalizatio languageName: localization.languageName, localizedLanguageName: localization.localizedLanguageName, languagePack: true, - async getTranslations(): Promise> { + async getTranslations(): Promise> { cachedLocalization ??= loadTranslations(packagePath, localization.translations); - return cachedLocalization; + return cachedLocalization.then(translations => new Map(Object.entries(translations))); }, }; theiaLocalizations.push(theiaLocalization);