diff --git a/.eslintignore b/.eslintignore index bec05ebf711..40f3001788f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -18,6 +18,7 @@ packages/desktop-client/playwright-report/ packages/desktop-electron/client-build/ packages/desktop-electron/dist/ +packages/desktop-electron/build/ packages/import-ynab4/**/node_modules/* diff --git a/packages/desktop-client/i18next-parser.config.js b/packages/desktop-client/i18next-parser.config.js index eaab75dae07..55acd37d9f8 100644 --- a/packages/desktop-client/i18next-parser.config.js +++ b/packages/desktop-client/i18next-parser.config.js @@ -1,5 +1,9 @@ module.exports = { - input: ['src/**/*.{js,jsx,ts,tsx}', '../loot-core/src/**/*.{js,jsx,ts,tsx}'], + input: [ + 'src/**/*.{js,jsx,ts,tsx}', + '../loot-core/src/**/*.{js,jsx,ts,tsx}', + '../desktop-electron/*.{js,ts}', + ], output: 'src/locale/$LOCALE.json', locales: ['en'], sort: true, diff --git a/packages/desktop-electron/i18n.ts b/packages/desktop-electron/i18n.ts new file mode 100644 index 00000000000..3b591594b58 --- /dev/null +++ b/packages/desktop-electron/i18n.ts @@ -0,0 +1,27 @@ +import i18n from 'i18next'; +import backend from 'i18next-electron-fs-backend'; + +i18n.use(backend).init({ + backend: { + loadPath: '../desktop-client/locale/{{lng}}.json', + addPath: '../desktop-client/locale/{{lng}}.missing.json', + contextBridgeApiKey: 'Actual', + }, + + // While we mark all strings for translations, one can test + // it by setting the language in localStorage to their choice. + // Set this to 'cimode' to see the exact keys without interpolation. + lng: 'en', // FIXME localStorage undefined + // lng: localStorage.getItem('language') || 'en', + + // allow keys to be phrases having `:`, `.` + nsSeparator: false, + keySeparator: false, + // do not load a fallback + fallbackLng: false, + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; diff --git a/packages/desktop-electron/index.ts b/packages/desktop-electron/index.ts index 3a9d4e86089..1b455fd7068 100644 --- a/packages/desktop-electron/index.ts +++ b/packages/desktop-electron/index.ts @@ -21,6 +21,7 @@ import { import { copy, exists, remove } from 'fs-extra'; import promiseRetry from 'promise-retry'; +import i18n from './i18n'; import { getMenu } from './menu'; import { get as getWindowState, @@ -28,6 +29,7 @@ import { } from './window-state'; import './security'; +const backend = require('i18next-electron-fs-backend'); const isDev = !app.isPackaged; // dev mode if not packaged @@ -154,6 +156,8 @@ async function createWindow() { win.setBackgroundColor('#E8ECF0'); + backend.mainBindings(ipcMain, win, fs); + if (isDev) { win.webContents.openDevTools(); } @@ -229,23 +233,25 @@ function isExternalUrl(url: string) { function updateMenu(budgetId?: string) { const isBudgetOpen = !!budgetId; const menu = getMenu(isDev, createWindow, budgetId); - const file = menu.items.filter(item => item.label === 'File')[0]; + const file = menu.items.filter(item => item.label === i18n.t('File'))[0]; const fileItems = file.submenu?.items || []; fileItems - .filter(item => item.label === 'Load Backup...') + .filter(item => item.label === i18n.t('Load Backup...')) .forEach(item => { item.enabled = isBudgetOpen; }); - const tools = menu.items.filter(item => item.label === 'Tools')[0]; + const tools = menu.items.filter(item => item.label === i18n.t('Tools'))[0]; tools.submenu?.items.forEach(item => { item.enabled = isBudgetOpen; }); - const edit = menu.items.filter(item => item.label === 'Edit')[0]; + const edit = menu.items.filter(item => item.label === i18n.t('Edit'))[0]; const editItems = edit.submenu?.items || []; editItems - .filter(item => item.label === 'Undo' || item.label === 'Redo') + .filter( + item => item.label === i18n.t('Undo') || item.label === i18n.t('Redo'), + ) .map(item => (item.enabled = isBudgetOpen)); if (process.platform === 'win32') { @@ -325,6 +331,8 @@ app.on('window-all-closed', () => { // On macOS, closing all windows shouldn't exit the process if (process.platform !== 'darwin') { app.quit(); + } else { + backend.clearMainBindings(ipcMain); } }); diff --git a/packages/desktop-electron/menu.ts b/packages/desktop-electron/menu.ts index 5897cd22d0b..2e6611266d9 100644 --- a/packages/desktop-electron/menu.ts +++ b/packages/desktop-electron/menu.ts @@ -1,5 +1,7 @@ import { MenuItemConstructorOptions, Menu, ipcMain, app } from 'electron'; +import i18n from './i18n'; + export function getMenu( isDev: boolean, createWindow: () => Promise, @@ -7,10 +9,10 @@ export function getMenu( ) { const template: MenuItemConstructorOptions[] = [ { - label: 'File', + label: i18n.t('File'), submenu: [ { - label: 'Load Backup...', + label: i18n.t('Load Backup...'), enabled: false, click(_item, focusedWindow) { if (focusedWindow && budgetId) { @@ -26,7 +28,7 @@ export function getMenu( type: 'separator', }, { - label: 'Manage files...', + label: i18n.t('Manage files...'), accelerator: 'CmdOrCtrl+O', click(_item, focusedWindow) { if (focusedWindow) { @@ -46,10 +48,10 @@ export function getMenu( ], }, { - label: 'Edit', + label: i18n.t('Edit'), submenu: [ { - label: 'Undo', + label: i18n.t('Undo'), enabled: false, accelerator: 'CmdOrCtrl+Z', click: function (_menuItem, focusedWin) { @@ -62,7 +64,7 @@ export function getMenu( }, }, { - label: 'Redo', + label: i18n.t('Redo'), enabled: false, accelerator: 'Shift+CmdOrCtrl+Z', click: function (_menuItem, focusedWin) { @@ -98,10 +100,10 @@ export function getMenu( ], }, { - label: 'View', + label: i18n.t('View'), submenu: [ { - label: 'Reload', + label: i18n.t('Reload'), accelerator: 'CmdOrCtrl+R', click(_item, focusedWindow) { if (focusedWindow) { @@ -110,7 +112,7 @@ export function getMenu( }, }, { - label: 'Toggle Developer Tools', + label: i18n.t('Toggle Developer Tools'), accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I', click(_item, focusedWindow) { @@ -140,10 +142,10 @@ export function getMenu( ], }, { - label: 'Tools', + label: i18n.t('Tools'), submenu: [ { - label: 'Find schedules', + label: i18n.t('Find schedules'), enabled: false, click: function (_menuItem, focusedWin) { if (focusedWin) { @@ -167,7 +169,7 @@ export function getMenu( role: 'help', submenu: [ { - label: 'Documentation', + label: i18n.t('Documentation'), click(_menuItem, focusedWin) { focusedWin?.webContents.executeJavaScript( 'window.__actionsForMenu && window.__actionsForMenu.openDocsForCurrentPage()', @@ -175,7 +177,7 @@ export function getMenu( }, }, { - label: 'Keyboard Shortcuts', + label: i18n.t('Keyboard Shortcuts'), accelerator: '?', enabled: !!budgetId, click: function (_menuItem, focusedWin) { @@ -197,7 +199,7 @@ export function getMenu( submenu: [ isDev ? { - label: 'Screenshot', + label: i18n.t('Screenshot'), click() { ipcMain.emit('screenshot'); }, @@ -231,13 +233,13 @@ export function getMenu( ], }); // Edit menu. - const editIdx = template.findIndex(t => t.label === 'Edit'); + const editIdx = template.findIndex(t => t.label === i18n.t('Edit')); (template[editIdx].submenu as MenuItemConstructorOptions[]).push( { type: 'separator', }, { - label: 'Speech', + label: i18n.t('Speech'), submenu: [ { role: 'startSpeaking', @@ -252,24 +254,24 @@ export function getMenu( const windowIdx = template.findIndex(t => t.role === 'window'); template[windowIdx].submenu = [ { - label: 'Close', + label: i18n.t('Close'), accelerator: 'CmdOrCtrl+W', role: 'close', }, { - label: 'Minimize', + label: i18n.t('Minimize'), accelerator: 'CmdOrCtrl+M', role: 'minimize', }, { - label: 'Zoom', + label: i18n.t('Zoom'), role: 'zoom', }, { type: 'separator', }, { - label: 'Bring All to Front', + label: i18n.t('Bring All to Front'), role: 'front', }, ]; diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index ced422a10fc..bd1b3e7d37c 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -86,6 +86,12 @@ }, "dependencies": { "better-sqlite3": "^9.6.0", + "electron-is-dev": "2.0.0", + "electron-log": "4.4.8", + "i18next": "^23.12.6", + "i18next-electron-fs-backend": "^3.0.2", + "lodash": "^4.17.21", + "loot-core": "*", "fs-extra": "^11.2.0", "promise-retry": "^2.0.1" }, @@ -93,6 +99,7 @@ "@electron/notarize": "2.4.0", "@electron/rebuild": "3.6.0", "@types/copyfiles": "^2", + "@types/lodash": "^4", "@types/fs-extra": "^11", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", diff --git a/packages/desktop-electron/preload.ts b/packages/desktop-electron/preload.ts index 1f98a39096a..fe60b317fa6 100644 --- a/packages/desktop-electron/preload.ts +++ b/packages/desktop-electron/preload.ts @@ -6,6 +6,8 @@ import { SaveFileDialogPayload, } from './index'; +const backend = require('i18next-electron-fs-backend'); + const { version: VERSION, isDev: IS_DEV }: GetBootstrapDataPayload = ipcRenderer.sendSync('get-bootstrap-data'); @@ -14,6 +16,8 @@ contextBridge.exposeInMainWorld('Actual', { ACTUAL_VERSION: VERSION, logToTerminal: console.log, + i18nextElectronBackend: backend.preloadBindings(ipcRenderer, process), + ipcConnect: ( func: (payload: { on: IpcRenderer['on']; diff --git a/upcoming-release-notes/3267.md b/upcoming-release-notes/3267.md new file mode 100644 index 00000000000..e221cb634f2 --- /dev/null +++ b/upcoming-release-notes/3267.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [psybers] +--- + +Support translations in desktop-electron. diff --git a/yarn.lock b/yarn.lock index e39f9406bc8..1409ad90212 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8689,12 +8689,19 @@ __metadata: "@electron/notarize": "npm:2.4.0" "@electron/rebuild": "npm:3.6.0" "@types/copyfiles": "npm:^2" + "@types/lodash": "npm:^4" "@types/fs-extra": "npm:^11" better-sqlite3: "npm:^9.6.0" copyfiles: "npm:^2.4.1" cross-env: "npm:^7.0.3" electron: "npm:30.0.6" electron-builder: "npm:24.13.3" + electron-is-dev: "npm:2.0.0" + electron-log: "npm:4.4.8" + i18next: "npm:^23.12.6" + i18next-electron-fs-backend: "npm:^3.0.2" + lodash: "npm:^4.17.21" + loot-core: "npm:*" fs-extra: "npm:^11.2.0" promise-retry: "npm:^2.0.1" typescript: "npm:^5.5.4" @@ -11298,6 +11305,23 @@ __metadata: languageName: node linkType: hard +"hyphenate-style-name@npm:^1.0.2": + version: 1.0.4 + resolution: "hyphenate-style-name@npm:1.0.4" + checksum: 10/d37883e6b7e1be62e1ddae29cac83fa59fb93c068bc8eb1561585439adbad91dcf7e264ee2a82c4378fc58049f7bd853544a4a81bf00d4aff717f641052323e7 + languageName: node + linkType: hard + +"i18next-electron-fs-backend@npm:^3.0.2": + version: 3.0.2 + resolution: "i18next-electron-fs-backend@npm:3.0.2" + dependencies: + lodash.clonedeep: "npm:^4.5.0" + lodash.merge: "npm:^4.6.2" + checksum: 10/43227421724382ba3cea544e03d0ca3d159c73dad57abc85ba216fd6ae425b7f5cd1937205123e75d405b4db2488f654fbf6214145d2970316cd43572f789cbd + languageName: node + linkType: hard + "i18next-parser@npm:^9.0.0": version: 9.0.1 resolution: "i18next-parser@npm:9.0.1" @@ -11343,6 +11367,15 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^23.12.6": + version: 23.12.6 + resolution: "i18next@npm:23.12.6" + dependencies: + "@babel/runtime": "npm:^7.23.2" + checksum: 10/6bb7faa2f1645dd7fc8d04c485707221edcf14911756d94faa8f5b7ffb5507995e00dca01f3c516ced9e986effb0073b06e9c55c2d762d400841cc557002abb1 + languageName: node + linkType: hard + "iconv-corefoundation@npm:^1.1.7": version: 1.1.7 resolution: "iconv-corefoundation@npm:1.1.7" @@ -13284,6 +13317,13 @@ __metadata: languageName: node linkType: hard +"lodash.clonedeep@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.clonedeep@npm:4.5.0" + checksum: 10/957ed243f84ba6791d4992d5c222ffffca339a3b79dbe81d2eaf0c90504160b500641c5a0f56e27630030b18b8e971ea10b44f928a977d5ced3c8948841b555f + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8"