mirror of
https://github.com/zadam/trilium.git
synced 2025-03-01 14:22:32 +01:00
Merge pull request #330 from TriliumNext/feature/i18n_language_switcher
Implement a language switcher
This commit is contained in:
commit
a9b094bf27
43
integration-tests/i18n.spec.ts
Normal file
43
integration-tests/i18n.spec.ts
Normal 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");
|
||||||
|
});
|
@ -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": {
|
||||||
|
@ -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;
|
||||||
|
@ -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) => {
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
@ -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() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user