diff --git a/integration-tests/i18n.spec.ts b/integration-tests/i18n.spec.ts new file mode 100644 index 000000000..0e700ad47 --- /dev/null +++ b/integration-tests/i18n.spec.ts @@ -0,0 +1,43 @@ +import test, { expect } from "@playwright/test"; + +test("User can change language from settings", async ({ page }) => { + await page.goto('http://localhost:8082'); + + // Clear all tabs + await page.locator('.note-tab:first-of-type').locator("div").nth(1).click({ button: 'right' }); + await page.getByText('Close all tabs').click(); + + // Go to options -> Appearance + await page.locator('#launcher-pane div').filter({ hasText: 'Options Open New Window' }).getByRole('button').click(); + await page.locator('#launcher-pane').getByText('Options').click(); + await page.locator('#center-pane').getByText('Appearance').click(); + + // Check that the default value (English) is set. + await expect(page.locator('#center-pane')).toContainText('Theme'); + const languageCombobox = await page.getByRole('combobox').first(); + await expect(languageCombobox).toHaveValue("en"); + + // Select Chinese and ensure the translation is set. + languageCombobox.selectOption("cn"); + await expect(page.locator('#center-pane')).toContainText('主题'); + + // Select English again. + languageCombobox.selectOption("en"); +}); + +test("Restores language on start-up on desktop", async ({ page, context }) => { + await page.goto('http://localhost:8082'); + await expect(page.locator('#launcher-pane').first()).toContainText("Open New Window"); +}); + +test("Restores language on start-up on mobile", async ({ page, context }) => { + await context.addCookies([ + { + url: "http://localhost:8082", + name: "trilium-device", + value: "mobile" + } + ]); + await page.goto('http://localhost:8082'); + await expect(page.locator('#launcher-pane div').first()).toContainText("Open New Window"); +}); \ No newline at end of file diff --git a/package.json b/package.json index 4e394f8ba..3c7e410d3 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "errors": "tsc --watch --noEmit", "integration-edit-db": "cross-env TRILIUM_INTEGRATION_TEST=edit TRILIUM_PORT=8081 TRILIUM_DATA_DIR=./integration-tests/db nodemon src/www.ts", "integration-mem-db": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_DATA_DIR=./integration-tests/db nodemon src/www.ts", + "integration-mem-db-dev": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/www.ts", "generate-document": "cross-env nodemon src/tools/generate_document.ts 1000" }, "dependencies": { diff --git a/src/public/app/components/app_context.js b/src/public/app/components/app_context.js index 2e06493dc..04c11e8b7 100644 --- a/src/public/app/components/app_context.js +++ b/src/public/app/components/app_context.js @@ -13,6 +13,7 @@ import MobileScreenSwitcherExecutor from "./mobile_screen_switcher.js"; import MainTreeExecutors from "./main_tree_executors.js"; import toast from "../services/toast.js"; import ShortcutComponent from "./shortcut_component.js"; +import { initLocale } from "../services/i18n.js"; class AppContext extends Component { constructor(isMainWindow) { @@ -24,16 +25,20 @@ class AppContext extends Component { this.beforeUnloadListeners = []; } - setLayout(layout) { + /** + * Must be called as soon as possible, before the creation of any components since this method is in charge of initializing the locale. Any attempts to read translation before this method is called will result in `undefined`. + */ + async earlyInit() { + await options.initializedPromise; + await initLocale(); + } + + setLayout(layout) { this.layout = layout; } - async start() { + async start() { this.initComponents(); - - // options are often needed for isEnabled() - await options.initializedPromise; - this.renderWidgets(); await froca.initializedPromise; diff --git a/src/public/app/desktop.js b/src/public/app/desktop.js index 669f9d355..febb9a8f9 100644 --- a/src/public/app/desktop.js +++ b/src/public/app/desktop.js @@ -6,11 +6,15 @@ import toastService from "./services/toast.js"; import noteAutocompleteService from './services/note_autocomplete.js'; import macInit from './services/mac_init.js'; import electronContextMenu from "./menus/electron_context_menu.js"; -import DesktopLayout from "./layouts/desktop_layout.js"; import glob from "./services/glob.js"; import { t } from "./services/i18n.js"; -bundleService.getWidgetBundlesByParent().then(widgetBundles => { +bundleService.getWidgetBundlesByParent().then(async widgetBundles => { + await appContext.earlyInit(); + + // A dynamic import is required for layouts since they initialize components which require translations. + const DesktopLayout = (await import("./layouts/desktop_layout.js")).default; + appContext.setLayout(new DesktopLayout(widgetBundles)); appContext.start() .catch((e) => { diff --git a/src/public/app/mobile.js b/src/public/app/mobile.js index 1e2a366fa..1c95324f5 100644 --- a/src/public/app/mobile.js +++ b/src/public/app/mobile.js @@ -1,8 +1,12 @@ import appContext from "./components/app_context.js"; -import MobileLayout from "./layouts/mobile_layout.js"; import glob from "./services/glob.js"; -glob.setupGlobs(); +glob.setupGlobs() + +await appContext.earlyInit(); + +// A dynamic import is required for layouts since they initialize components which require translations. +const MobileLayout = (await import("./layouts/mobile_layout.js")).default; appContext.setLayout(new MobileLayout()); appContext.start(); diff --git a/src/public/app/services/i18n.js b/src/public/app/services/i18n.js index a777db038..8b841c7de 100644 --- a/src/public/app/services/i18n.js +++ b/src/public/app/services/i18n.js @@ -1,16 +1,21 @@ import library_loader from "./library_loader.js"; +import options from "./options.js"; await library_loader.requireLibrary(library_loader.I18NEXT); -await i18next - .use(i18nextHttpBackend) - .init({ - lng: "en", - fallbackLng: "en", - debug: true, - backend: { - loadPath: `/${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json` - } - }); +export async function initLocale() { + const locale = options.get("locale") || "en"; + + await i18next + .use(i18nextHttpBackend) + .init({ + lng: locale, + fallbackLng: "en", + debug: true, + backend: { + loadPath: `/${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json` + } + }); +} export const t = i18next.t; diff --git a/src/public/app/widgets/type_widgets/content_widget.js b/src/public/app/widgets/type_widgets/content_widget.js index fe7f105af..6c2ddc0cb 100644 --- a/src/public/app/widgets/type_widgets/content_widget.js +++ b/src/public/app/widgets/type_widgets/content_widget.js @@ -32,6 +32,7 @@ import DatabaseAnonymizationOptions from "./options/advanced/database_anonymizat import BackendLogWidget from "./content/backend_log.js"; import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js"; import RibbonOptions from "./options/appearance/ribbon.js"; +import LocalizationOptions from "./options/appearance/i18n.js"; const TPL = `