Merge pull request #330 from TriliumNext/feature/i18n_language_switcher

Implement a language switcher
This commit is contained in:
Elian Doran 2024-08-12 19:48:16 +03:00 committed by GitHub
commit a9b094bf27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 157 additions and 25 deletions

View File

@ -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");
});

View File

@ -45,6 +45,7 @@
"errors": "tsc --watch --noEmit", "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-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": "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" "generate-document": "cross-env nodemon src/tools/generate_document.ts 1000"
}, },
"dependencies": { "dependencies": {

View File

@ -13,6 +13,7 @@ import MobileScreenSwitcherExecutor from "./mobile_screen_switcher.js";
import MainTreeExecutors from "./main_tree_executors.js"; import MainTreeExecutors from "./main_tree_executors.js";
import toast from "../services/toast.js"; import toast from "../services/toast.js";
import ShortcutComponent from "./shortcut_component.js"; import ShortcutComponent from "./shortcut_component.js";
import { initLocale } from "../services/i18n.js";
class AppContext extends Component { class AppContext extends Component {
constructor(isMainWindow) { constructor(isMainWindow) {
@ -24,16 +25,20 @@ class AppContext extends Component {
this.beforeUnloadListeners = []; 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; this.layout = layout;
} }
async start() { async start() {
this.initComponents(); this.initComponents();
// options are often needed for isEnabled()
await options.initializedPromise;
this.renderWidgets(); this.renderWidgets();
await froca.initializedPromise; await froca.initializedPromise;

View File

@ -6,11 +6,15 @@ import toastService from "./services/toast.js";
import noteAutocompleteService from './services/note_autocomplete.js'; import noteAutocompleteService from './services/note_autocomplete.js';
import macInit from './services/mac_init.js'; import macInit from './services/mac_init.js';
import electronContextMenu from "./menus/electron_context_menu.js"; import electronContextMenu from "./menus/electron_context_menu.js";
import DesktopLayout from "./layouts/desktop_layout.js";
import glob from "./services/glob.js"; import glob from "./services/glob.js";
import { t } from "./services/i18n.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.setLayout(new DesktopLayout(widgetBundles));
appContext.start() appContext.start()
.catch((e) => { .catch((e) => {

View File

@ -1,8 +1,12 @@
import appContext from "./components/app_context.js"; import appContext from "./components/app_context.js";
import MobileLayout from "./layouts/mobile_layout.js";
import glob from "./services/glob.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.setLayout(new MobileLayout());
appContext.start(); appContext.start();

View File

@ -1,16 +1,21 @@
import library_loader from "./library_loader.js"; import library_loader from "./library_loader.js";
import options from "./options.js";
await library_loader.requireLibrary(library_loader.I18NEXT); await library_loader.requireLibrary(library_loader.I18NEXT);
await i18next export async function initLocale() {
.use(i18nextHttpBackend) const locale = options.get("locale") || "en";
.init({
lng: "en", await i18next
fallbackLng: "en", .use(i18nextHttpBackend)
debug: true, .init({
backend: { lng: locale,
loadPath: `/${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json` fallbackLng: "en",
} debug: true,
}); backend: {
loadPath: `/${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
}
});
}
export const t = i18next.t; export const t = i18next.t;

View File

@ -32,6 +32,7 @@ import DatabaseAnonymizationOptions from "./options/advanced/database_anonymizat
import BackendLogWidget from "./content/backend_log.js"; import BackendLogWidget from "./content/backend_log.js";
import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js"; import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js";
import RibbonOptions from "./options/appearance/ribbon.js"; import RibbonOptions from "./options/appearance/ribbon.js";
import LocalizationOptions from "./options/appearance/i18n.js";
const TPL = `<div class="note-detail-content-widget note-detail-printable"> const TPL = `<div class="note-detail-content-widget note-detail-printable">
<style> <style>
@ -54,6 +55,7 @@ const TPL = `<div class="note-detail-content-widget note-detail-printable">
const CONTENT_WIDGETS = { const CONTENT_WIDGETS = {
_optionsAppearance: [ _optionsAppearance: [
LocalizationOptions,
ThemeOptions, ThemeOptions,
FontsOptions, FontsOptions,
ZoomFactorOptions, ZoomFactorOptions,

View File

@ -0,0 +1,40 @@
import OptionsWidget from "../options_widget.js";
import server from "../../../../services/server.js";
import utils from "../../../../services/utils.js";
import { t } from "../../../../services/i18n.js";
const TPL = `
<div class="options-section">
<h4>${t("i18n.title")}</h4>
<div class="form-group row">
<div class="col-6">
<label>${t("i18n.language")}</label>
<select class="locale-select form-control"></select>
</div>
</div>
</div>
`;
export default class LocalizationOptions extends OptionsWidget {
doRender() {
this.$widget = $(TPL);
this.$localeSelect = this.$widget.find(".locale-select");
this.$localeSelect.on("change", async() => {
const newLocale = this.$localeSelect.val();
await server.put(`options/locale/${newLocale}`);
utils.reloadFrontendApp("locale change");
});
}
async optionsLoaded(options) {
const availableLocales = await server.get("options/locales");
for (const locale of availableLocales) {
this.$localeSelect.append($("<option>")
.attr("value", locale.id)
.text(locale.name));
}
this.$localeSelect.val(options.locale);
}
}

View File

@ -1115,5 +1115,9 @@
"title": "Automatic Read-Only Size", "title": "Automatic Read-Only Size",
"description": "Automatic read-only note size is the size after which notes will be displayed in a read-only mode (for performance reasons).", "description": "Automatic read-only note size is the size after which notes will be displayed in a read-only mode (for performance reasons).",
"label": "Automatic read-only size (text notes)" "label": "Automatic read-only size (text notes)"
},
"i18n": {
"title": "Localization",
"language": "Language"
} }
} }

View File

@ -58,7 +58,8 @@ const ALLOWED_OPTIONS = new Set([
'customSearchEngineName', 'customSearchEngineName',
'customSearchEngineUrl', 'customSearchEngineUrl',
'promotedAttributesOpenInRibbon', 'promotedAttributesOpenInRibbon',
'editedNotesOpenInRibbon' 'editedNotesOpenInRibbon',
'locale'
]); ]);
function getOptions() { function getOptions() {
@ -129,6 +130,20 @@ function getUserThemes() {
return ret; return ret;
} }
function getSupportedLocales() {
// TODO: Currently hardcoded, needs to read the list of available languages.
return [
{
"id": "en",
"name": "English"
},
{
"id": "cn",
"name": "Chinese"
}
];
}
function isAllowed(name: string) { function isAllowed(name: string) {
return ALLOWED_OPTIONS.has(name) return ALLOWED_OPTIONS.has(name)
|| name.startsWith("keyboardShortcuts") || name.startsWith("keyboardShortcuts")
@ -140,5 +155,6 @@ export default {
getOptions, getOptions,
updateOption, updateOption,
updateOptions, updateOptions,
getUserThemes getUserThemes,
getSupportedLocales
}; };

View File

@ -217,6 +217,7 @@ function register(app: express.Application) {
apiRoute(PUT, '/api/options/:name/:value*', optionsApiRoute.updateOption); apiRoute(PUT, '/api/options/:name/:value*', optionsApiRoute.updateOption);
apiRoute(PUT, '/api/options', optionsApiRoute.updateOptions); apiRoute(PUT, '/api/options', optionsApiRoute.updateOptions);
apiRoute(GET, '/api/options/user-themes', optionsApiRoute.getUserThemes); apiRoute(GET, '/api/options/user-themes', optionsApiRoute.getUserThemes);
apiRoute(GET, '/api/options/locales', optionsApiRoute.getSupportedLocales);
apiRoute(PST, '/api/password/change', passwordApiRoute.changePassword); apiRoute(PST, '/api/password/change', passwordApiRoute.changePassword);
apiRoute(PST, '/api/password/reset', passwordApiRoute.resetPassword); apiRoute(PST, '/api/password/reset', passwordApiRoute.resetPassword);

View File

@ -16,6 +16,12 @@ interface NotSyncedOpts {
syncProxy?: string; syncProxy?: string;
} }
interface DefaultOption {
name: string;
value: string;
isSynced: boolean;
}
async function initNotSyncedOptions(initialized: boolean, theme: string, opts: NotSyncedOpts = {}) { async function initNotSyncedOptions(initialized: boolean, theme: string, opts: NotSyncedOpts = {}) {
optionService.createOption('openNoteContexts', JSON.stringify([ optionService.createOption('openNoteContexts', JSON.stringify([
{ {
@ -35,13 +41,13 @@ async function initNotSyncedOptions(initialized: boolean, theme: string, opts: N
optionService.createOption('lastSyncedPush', '0', false); optionService.createOption('lastSyncedPush', '0', false);
optionService.createOption('theme', theme, false); optionService.createOption('theme', theme, false);
optionService.createOption('syncServerHost', opts.syncServerHost || '', false); optionService.createOption('syncServerHost', opts.syncServerHost || '', false);
optionService.createOption('syncServerTimeout', '120000', false); optionService.createOption('syncServerTimeout', '120000', false);
optionService.createOption('syncProxy', opts.syncProxy || '', false); optionService.createOption('syncProxy', opts.syncProxy || '', false);
} }
const defaultOptions = [ const defaultOptions: DefaultOption[] = [
{ name: 'revisionSnapshotTimeInterval', value: '600', isSynced: true }, { name: 'revisionSnapshotTimeInterval', value: '600', isSynced: true },
{ name: 'protectedSessionTimeout', value: '600', isSynced: true }, { name: 'protectedSessionTimeout', value: '600', isSynced: true },
{ name: 'zoomFactor', value: process.platform === "win32" ? '0.9' : '1.0', isSynced: false }, { name: 'zoomFactor', value: process.platform === "win32" ? '0.9' : '1.0', isSynced: false },
@ -88,7 +94,8 @@ const defaultOptions = [
{ name: 'customSearchEngineName', value: 'DuckDuckGo', isSynced: true }, { name: 'customSearchEngineName', value: 'DuckDuckGo', isSynced: true },
{ name: 'customSearchEngineUrl', value: 'https://duckduckgo.com/?q={keyword}', isSynced: true }, { name: 'customSearchEngineUrl', value: 'https://duckduckgo.com/?q={keyword}', isSynced: true },
{ name: 'promotedAttributesOpenInRibbon', value: 'true', isSynced: true }, { name: 'promotedAttributesOpenInRibbon', value: 'true', isSynced: true },
{ name: 'editedNotesOpenInRibbon', value: 'true', isSynced: true } { name: 'editedNotesOpenInRibbon', value: 'true', isSynced: true },
{ name: 'locale', value: 'en', isSynced: true }
]; ];
function initStartupOptions() { function initStartupOptions() {