From 301b4b0205193eaa1cdce765a7e54837248501a0 Mon Sep 17 00:00:00 2001 From: tiddlygit-test Date: Thu, 11 Mar 2021 00:21:26 +0800 Subject: [PATCH] fix: i18n not loaded --- src/helpers/i18next-electron-fs-backend.ts | 345 ------------------ src/i18n.ts | 2 +- src/main.ts | 9 +- src/services/libs/i18n/bindI18nListener.ts | 16 - src/services/libs/i18n/i18nMainBindings.ts | 57 +++ .../libs/i18n/i18next-electron-fs-backend.ts | 341 ++++++++++++++--- src/services/libs/i18n/index.ts | 6 +- src/services/windows/index.ts | 7 +- 8 files changed, 363 insertions(+), 420 deletions(-) delete mode 100644 src/helpers/i18next-electron-fs-backend.ts delete mode 100644 src/services/libs/i18n/bindI18nListener.ts create mode 100644 src/services/libs/i18n/i18nMainBindings.ts diff --git a/src/helpers/i18next-electron-fs-backend.ts b/src/helpers/i18next-electron-fs-backend.ts deleted file mode 100644 index 714fbf17..00000000 --- a/src/helpers/i18next-electron-fs-backend.ts +++ /dev/null @@ -1,345 +0,0 @@ -/* eslint-disable unicorn/prevent-abbreviations */ -import { BackendModule } from 'i18next'; -import { cloneDeep, merge } from 'lodash'; -// CONFIGS -const defaultOptions = { - debug: false, - loadPath: '/locales/{{lng}}/{{ns}}.json', - addPath: '/locales/{{lng}}/{{ns}}.missing.json', -}; -const readFileRequest = 'ReadFile-Request'; -const writeFileRequest = 'WriteFile-Request'; -const readFileResponse = 'ReadFile-Response'; -const writeFileResponse = 'WriteFile-Response'; -const changeLanguageRequest = 'ChangeLanguage-Request'; -/** - * Fast UUID generator, RFC4122 version 4 compliant. - * @author Jeff Ward (jcward.com). - * @license MIT license - * @link http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/21963136#21963136 - **/ -const UUID = (function () { - const self = {}; - const lut: any = []; - for (let index = 0; index < 256; index++) { - lut[index] = (index < 16 ? '0' : '') + index.toString(16); - } - (self as any).generate = function () { - const d0 = Math.trunc(Math.random() * 0xffffffff); - const d1 = Math.trunc(Math.random() * 0xffffffff); - const d2 = Math.trunc(Math.random() * 0xffffffff); - const d3 = Math.trunc(Math.random() * 0xffffffff); - return `${lut[d0 & 0xff] + lut[(d0 >> 8) & 0xff] + lut[(d0 >> 16) & 0xff] + lut[(d0 >> 24) & 0xff]}-${lut[d1 & 0xff]}${lut[(d1 >> 8) & 0xff]}-${ - lut[((d1 >> 16) & 0x0f) | 0x40] - }${lut[(d1 >> 24) & 0xff]}-${lut[(d2 & 0x3f) | 0x80]}${lut[(d2 >> 8) & 0xff]}-${lut[(d2 >> 16) & 0xff]}${lut[(d2 >> 24) & 0xff]}${lut[d3 & 0xff]}${ - lut[(d3 >> 8) & 0xff] - }${lut[(d3 >> 16) & 0xff]}${lut[(d3 >> 24) & 0xff]}`; - }; - return self; -})(); -// Merges objects together -const mergeNested = function (object: any, path: any, split: any, value: any) { - const tokens = path.split(split); - let temporary = {}; - let temporary2; - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - temporary[`${tokens[tokens.length - 1]}`] = value; - for (let index = tokens.length - 2; index >= 0; index--) { - temporary2 = {}; - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - temporary2[`${tokens[index]}`] = temporary; - temporary = temporary2; - } - return merge(object, temporary); -}; -// https://stackoverflow.com/a/34890276/1837080 -const groupByArray = function (xs: any, key: any) { - return xs.reduce(function (rv: any, x: any) { - const v = key instanceof Function ? key(x) : x[key]; - const element = rv.find((r: any) => r && r.key === v); - if (element) { - element.values.push(x); - } else { - rv.push({ - key: v, - values: [x], - }); - } - return rv; - }, []); -}; -// Template is found at: https://www.i18next.com/misc/creating-own-plugins#backend; -// also took code from: https://github.com/i18next/i18next-node-fs-backend -export class Backend implements BackendModule { - static type = 'backend'; - type = 'backend' as const; - - backendOptions: any; - i18nextOptions: any; - mainLog: any; - readCallbacks: any; - rendererLog: any; - services: any; - useOverflow: any; - writeCallbacks: any; - writeQueue: any; - writeQueueOverflow: any; - writeTimeout: any; - constructor(services: any, backendOptions = {}, i18nextOptions = {}) { - this.init(services, backendOptions, i18nextOptions); - this.readCallbacks = {}; // Callbacks after reading a translation - this.writeCallbacks = {}; // Callbacks after writing a missing translation - this.writeTimeout; // A timer that will initate writing missing translations to files - this.writeQueue = []; // An array to hold missing translations before the writeTimeout occurs - this.writeQueueOverflow = []; // An array to hold missing translations while the writeTimeout's items are being written to file - this.useOverflow = false; // If true, we should insert missing translations into the writeQueueOverflow - } - - init(services: any, backendOptions: any, i18nextOptions: any) { - if (typeof window !== 'undefined' && typeof (window as any).i18n.i18nextElectronBackend === 'undefined') { - throw new TypeError("'window.i18n.i18nextElectronBackend' is not defined! Be sure you are setting up your BrowserWindow's preload script properly!"); - } - this.services = services; - this.backendOptions = { - ...defaultOptions, - ...backendOptions, - i18nextElectronBackend: typeof window !== 'undefined' ? (window as any).i18n.i18nextElectronBackend : undefined, - }; - this.i18nextOptions = i18nextOptions; - // log-related - const logPrepend = '[i18next-electron-fs-backend:'; - this.mainLog = `${logPrepend}main]=>`; - this.rendererLog = `${logPrepend}renderer]=>`; - this.setupIpcBindings(); - } - - // Sets up Ipc bindings so that we can keep any node-specific - // modules; (ie. 'fs') out of the Electron renderer process - setupIpcBindings() { - const { i18nextElectronBackend } = this.backendOptions; - i18nextElectronBackend.onReceive(readFileResponse, (arguments_: any) => { - // args: - // { - // key - // error - // data - // } - // Don't know why we need this line; - // upon initialization, the i18next library - // ends up in this .on([channel], args) method twice - if (typeof this.readCallbacks[arguments_.key] === 'undefined') { - return; - } - let callback; - if (arguments_.error) { - // Failed to read translation file; - // we pass back a fake "success" response - // so that we create a translation file - callback = this.readCallbacks[arguments_.key].callback; - delete this.readCallbacks[arguments_.key]; - if (callback !== null && typeof callback === 'function') { - callback(null, {}); - } - } else { - let result; - arguments_.data = arguments_.data.replace(/^\uFEFF/, ''); - try { - result = JSON.parse(arguments_.data); - } catch (parseError) { - parseError.message = `Error parsing '${arguments_.filename}'. Message: '${parseError}'.`; - callback = this.readCallbacks[arguments_.key].callback; - delete this.readCallbacks[arguments_.key]; - if (callback !== null && typeof callback === 'function') { - callback(parseError); - } - return; - } - callback = this.readCallbacks[arguments_.key].callback; - delete this.readCallbacks[arguments_.key]; - if (callback !== null && typeof callback === 'function') { - callback(null, result); - } - } - }); - i18nextElectronBackend.onReceive(writeFileResponse, (arguments_: any) => { - // args: - // { - // keys - // error - // } - const { keys } = arguments_; - for (const key of keys) { - let callback; - // Write methods don't have any callbacks from what I've seen, - // so this is called more than I thought; but necessary! - if (typeof this.writeCallbacks[key] === 'undefined') { - return; - } - if (arguments_.error) { - callback = this.writeCallbacks[key].callback; - delete this.writeCallbacks[key]; - callback(arguments_.error); - } else { - callback = this.writeCallbacks[key].callback; - delete this.writeCallbacks[key]; - callback(null, true); - } - } - }); - } - - // Writes a given translation to file - write(writeQueue: any) { - const { debug, i18nextElectronBackend } = this.backendOptions; - // Group by filename so we can make one request - // for all changes within a given file - const toWork = groupByArray(writeQueue, 'filename'); - for (const element of toWork) { - const anonymous = function (error: any, data: any) { - if (error) { - console.error( - `${ - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - this.rendererLog - } encountered error when trying to read file '{filename}' before writing missing translation ('{key}'/'{fallbackValue}') to file. Please resolve this error so missing translation values can be written to file. Error: '${error}'.`, - ); - return; - } - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - const keySeparator = !!this.i18nextOptions.keySeparator; // Do we have a key separator or not? - const writeKeys = []; - for (let index = 0; index < element.values.length; index++) { - // If we have no key separator set, simply update the translation value - if (!keySeparator) { - data[element.values[index].key] = element.values[index].fallbackValue; - } else { - // Created the nested object structure based on the key separator, and merge that - // into the existing translation data - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - data = mergeNested(data, element.values[index].key, this.i18nextOptions.keySeparator, element.values[index].fallbackValue); - } - const writeKey = `${(UUID as any).generate()}`; - if (element.values[index].callback) { - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - this.writeCallbacks[writeKey] = { - callback: element.values[index].callback, - }; - writeKeys.push(writeKey); - } - } - // Send out the message to the ipcMain process - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - debug ? console.log(`${this.rendererLog} requesting the missing key '${key}' be written to file '${filename}'.`) : null; - i18nextElectronBackend.send(writeFileRequest, { - keys: writeKeys, - filename: element.key, - data, - }); - }.bind(this); - this.requestFileRead(element.key, anonymous); - } - } - - // Reads a given translation file - requestFileRead(filename: any, callback: any) { - const { i18nextElectronBackend } = this.backendOptions; - // Save the callback for this request so we - // can execute once the ipcRender process returns - // with a value from the ipcMain process - const key = `${(UUID as any).generate()}`; - this.readCallbacks[key] = { - callback, - }; - // Send out the message to the ipcMain process - i18nextElectronBackend.send(readFileRequest, { - key, - filename, - }); - } - - // Reads a given translation file - read(language: any, namespace: any, callback: any) { - const { loadPath } = this.backendOptions; - const filename = this.services.interpolator.interpolate(loadPath, { - lng: language, - ns: namespace, - }); - this.requestFileRead(filename, (error: any, data: any) => { - if (error) { - return callback(error, false); - } // no retry - callback(null, data); - }); - } - - // Not implementing at this time - readMulti(languages: any, namespaces: any, callback: any) { - throw 'Not implemented exception.'; - } - - // Writes a missing translation to file - create(languages: any, namespace: any, key: any, fallbackValue: any, callback: any) { - const { addPath } = this.backendOptions; - let filename; - languages = typeof languages === 'string' ? [languages] : languages; - // Create the missing translation for all languages - for (const language of languages) { - filename = this.services.interpolator.interpolate(addPath, { - lng: language, - ns: namespace, - }); - // If we are currently writing missing translations from writeQueue, - // temporarily store the requests in writeQueueOverflow until we are - // done writing to file - if (this.useOverflow) { - this.writeQueueOverflow.push({ - filename, - key, - fallbackValue, - callback, - }); - } else { - this.writeQueue.push({ - filename, - key, - fallbackValue, - callback, - }); - } - } - // Fire up the timeout to process items to write - if (this.writeQueue.length > 0 && !this.useOverflow) { - // Clear out any existing timeout if we are still getting translations to write - if (typeof this.writeTimeout !== 'undefined') { - clearInterval(this.writeTimeout); - } - this.writeTimeout = setInterval( - function () { - // Write writeQueue entries, then after, - // fill in any from the writeQueueOverflow - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - if (this.writeQueue.length > 0) { - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - this.write(cloneDeep(this.writeQueue)); - } - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - this.writeQueue = cloneDeep(this.writeQueueOverflow); - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - this.writeQueueOverflow = []; - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - if (this.writeQueue.length === 0) { - // Clear timer - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - clearInterval(this.writeTimeout); - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - delete this.writeTimeout; - // @ts-expect-error ts-migrate(2683) FIXME: 'this' implicitly has type 'any' because it does n... Remove this comment to see the full error message - this.useOverflow = false; - } - }.bind(this), - 1000, - ); - this.useOverflow = true; - } - } -} diff --git a/src/i18n.ts b/src/i18n.ts index e794057d..2fd84915 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,6 +1,6 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import { Backend as ElectronFsBackend } from './helpers/i18next-electron-fs-backend'; +import { Backend as ElectronFsBackend } from './services/libs/i18n/i18next-electron-fs-backend'; export async function initI18N(): Promise { const isDevelopment = (await window.service.context.get('isDevelopment')) as boolean; diff --git a/src/main.ts b/src/main.ts index e09480a2..7cb550dc 100755 --- a/src/main.ts +++ b/src/main.ts @@ -8,14 +8,14 @@ import unhandled from 'electron-unhandled'; import { openNewGitHubIssue, debugInfo } from 'electron-util'; import i18n from 'i18next'; -import { clearMainBindings } from '@services/libs/i18n/i18next-electron-fs-backend'; +import { clearMainBindings } from '@services/libs/i18n/i18nMainBindings'; import { buildLanguageMenu } from '@services/libs/i18n/buildLanguageMenu'; import { MainChannel } from '@/constants/channels'; import { container } from '@services/container'; import { logger } from '@services/libs/log'; import extractHostname from '@services/libs/extract-hostname'; import MAILTO_URLS from '@services/constants/mailto-urls'; -import { initI18NAfterServiceReady } from '@services/libs/i18n'; +import { initRendererI18NHandler } from '@services/libs/i18n'; import serviceIdentifier from '@services/serviceIdentifier'; import { WindowNames } from '@services/windows/WindowProperties'; @@ -56,6 +56,7 @@ if (!gotTheLock) { mainWindow.focus(); } }); + void initRendererI18NHandler(); // make sure "Settings" file exists // if not, ignore this chunk of code // as using electron-settings before app.on('ready') and "Settings" is created @@ -107,9 +108,8 @@ if (!gotTheLock) { }); } await windowService.open(WindowNames.main); - // set language async + // set language async on main process void i18n.changeLanguage(preferenceService.get('language')); - await workspaceViewService.initializeAllWorkspaceView(); ipcMain.emit('request-update-pause-notifications-info'); @@ -141,7 +141,6 @@ if (!gotTheLock) { } // trigger whenTrulyReady ipcMain.emit(MainChannel.commonInitFinished); - await initI18NAfterServiceReady(); // build menu at last, this is not noticeable to user, so do it last buildLanguageMenu(); menuService.buildMenu(); diff --git a/src/services/libs/i18n/bindI18nListener.ts b/src/services/libs/i18n/bindI18nListener.ts deleted file mode 100644 index e02f501d..00000000 --- a/src/services/libs/i18n/bindI18nListener.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ipcMain } from 'electron'; - -import { container } from '@services/container'; -import type { IWindowService } from '@services/windows/interface'; -import { WindowNames } from '@services/windows/WindowProperties'; -import serviceIdentifier from '@services/serviceIdentifier'; - -export default async function bindI18nListener(): Promise { - const windows = container.get(serviceIdentifier.Window); - const mainWindow = windows.get(WindowNames.main); - if (mainWindow === undefined) { - throw new Error('Window is undefined in bindI18nListener()'); - } - const { mainBindings } = await import('./i18next-electron-fs-backend'); - mainBindings(ipcMain, mainWindow); -} diff --git a/src/services/libs/i18n/i18nMainBindings.ts b/src/services/libs/i18n/i18nMainBindings.ts new file mode 100644 index 00000000..f110c164 --- /dev/null +++ b/src/services/libs/i18n/i18nMainBindings.ts @@ -0,0 +1,57 @@ +import { ipcMain } from 'electron'; +import fs from 'fs-extra'; +import path from 'path'; +import { IpcMain, IpcMainInvokeEvent } from 'electron'; + +import type { IWindowService } from '@services/windows/interface'; +import serviceIdentifier from '@services/serviceIdentifier'; +import { container } from '@services/container'; +import { LOCALIZATION_FOLDER } from '@services/constants/paths'; +import { I18NChannels } from '@/constants/channels'; +import { IReadFileRequest, IWriteFileRequest } from './types'; + +/** + * This is the code that will go into the main.js file + * in order to set up the ipc main bindings + */ +export function mainBindings(): void { + ipcMain.handle(I18NChannels.readFileRequest, (_event: IpcMainInvokeEvent, readFileArgs: IReadFileRequest) => { + const localeFilePath = path.join(LOCALIZATION_FOLDER, readFileArgs.filename); + const windowService = container.get(serviceIdentifier.Window); + fs.readFile(localeFilePath, 'utf8', (error, data) => { + windowService.sendToAllWindows(I18NChannels.readFileResponse, { + key: readFileArgs.key, + error, + data: typeof data !== 'undefined' && data !== null ? data.toString() : '', + }); + }); + }); + + ipcMain.handle(I18NChannels.writeFileRequest, (_event: IpcMainInvokeEvent, writeFileArgs: IWriteFileRequest) => { + const localeFilePath = path.join(LOCALIZATION_FOLDER, writeFileArgs.filename); + const localeFileFolderPath = path.dirname(localeFilePath); + const windowService = container.get(serviceIdentifier.Window); + fs.ensureDir(localeFileFolderPath, (directoryCreationError?: Error) => { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (directoryCreationError) { + console.error(directoryCreationError); + return; + } + fs.writeFile(localeFilePath, JSON.stringify(writeFileArgs.data), (error: Error) => { + windowService.sendToAllWindows(I18NChannels.writeFileResponse, { + keys: writeFileArgs.keys, + error, + }); + }); + }); + }); +} + +/** + * Clears the bindings from ipcMain; + * in case app is closed/reopened (only on macos) + */ +export function clearMainBindings(ipcMain: IpcMain): void { + ipcMain.removeAllListeners(I18NChannels.readFileRequest); + ipcMain.removeAllListeners(I18NChannels.writeFileRequest); +} diff --git a/src/services/libs/i18n/i18next-electron-fs-backend.ts b/src/services/libs/i18n/i18next-electron-fs-backend.ts index fd79eea0..356cab7f 100644 --- a/src/services/libs/i18n/i18next-electron-fs-backend.ts +++ b/src/services/libs/i18n/i18next-electron-fs-backend.ts @@ -1,57 +1,302 @@ /* eslint-disable unicorn/prevent-abbreviations */ -import fs from 'fs-extra'; -import path from 'path'; -import { IpcMain, BrowserWindow, IpcMainInvokeEvent } from 'electron'; - -import type { IWindowService } from '@services/windows/interface'; -import serviceIdentifier from '@services/serviceIdentifier'; -import { container } from '@services/container'; -import { LOCALIZATION_FOLDER } from '@services/constants/paths'; +import { BackendModule } from 'i18next'; +import { cloneDeep, merge } from 'lodash'; +import { v4 as uuid } from 'uuid' import { I18NChannels } from '@/constants/channels'; -import { IReadFileRequest, IWriteFileRequest } from './types'; -/** - * This is the code that will go into the main.js file - * in order to set up the ipc main bindings - */ -export const mainBindings = function (ipcMain: IpcMain, browserWindow: BrowserWindow): void { - ipcMain.handle(I18NChannels.readFileRequest, (_event: IpcMainInvokeEvent, readFileArgs: IReadFileRequest) => { - const localeFilePath = path.join(LOCALIZATION_FOLDER, readFileArgs.filename); - const windowService = container.get(serviceIdentifier.Window); - fs.readFile(localeFilePath, 'utf8', (error, data) => { - windowService.sendToAllWindows(I18NChannels.readFileResponse, { - key: readFileArgs.key, - error, - data: typeof data !== 'undefined' && data !== null ? data.toString() : '', +// CONFIGS +const defaultOptions = { + debug: false, + loadPath: '/locales/{{lng}}/{{ns}}.json', + addPath: '/locales/{{lng}}/{{ns}}.missing.json', +}; + +// Merges objects together +const mergeNested = function (object: any, path: string, split: string, value: any) { + const tokens = path.split(split); + let temporary = {}; + let temporary2; + temporary[`${tokens[tokens.length - 1]}`] = value; + for (let index = tokens.length - 2; index >= 0; index--) { + temporary2 = {}; + temporary2[`${tokens[index]}`] = temporary; + temporary = temporary2; + } + return merge(object, temporary); +}; +// https://stackoverflow.com/a/34890276/1837080 +const groupByArray = function (xs: any, key: any) { + return xs.reduce(function (rv: any, x: any) { + const v = key instanceof Function ? key(x) : x[key]; + const element = rv.find((r: any) => r && r.key === v); + if (element) { + element.values.push(x); + } else { + rv.push({ + key: v, + values: [x], }); - }); - }); + } + return rv; + }, []); +}; +// Template is found at: https://www.i18next.com/misc/creating-own-plugins#backend; +// also took code from: https://github.com/i18next/i18next-node-fs-backend +export class Backend implements BackendModule { + static type = 'backend'; + type = 'backend' as const; - ipcMain.handle(I18NChannels.writeFileRequest, (_event: IpcMainInvokeEvent, writeFileArgs: IWriteFileRequest) => { - const localeFilePath = path.join(LOCALIZATION_FOLDER, writeFileArgs.filename); - const localeFileFolderPath = path.dirname(localeFilePath); - const windowService = container.get(serviceIdentifier.Window); - fs.ensureDir(localeFileFolderPath, (directoryCreationError?: Error) => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (directoryCreationError) { - console.error(directoryCreationError); + backendOptions: any; + i18nextOptions: any; + mainLog: any; + readCallbacks: any; + rendererLog: any; + services: any; + useOverflow: any; + writeCallbacks: any; + writeQueue: any; + writeQueueOverflow: any; + writeTimeout: any; + constructor(services: any, backendOptions = {}, i18nextOptions = {}) { + this.init(services, backendOptions, i18nextOptions); + this.readCallbacks = {}; // Callbacks after reading a translation + this.writeCallbacks = {}; // Callbacks after writing a missing translation + this.writeTimeout; // A timer that will initate writing missing translations to files + this.writeQueue = []; // An array to hold missing translations before the writeTimeout occurs + this.writeQueueOverflow = []; // An array to hold missing translations while the writeTimeout's items are being written to file + this.useOverflow = false; // If true, we should insert missing translations into the writeQueueOverflow + } + + init(services: any, backendOptions: any, i18nextOptions: any) { + if (typeof window !== 'undefined' && typeof window.i18n.i18nextElectronBackend === 'undefined') { + throw new TypeError("'window.i18n.i18nextElectronBackend' is not defined! Be sure you are setting up your BrowserWindow's preload script properly!"); + } + this.services = services; + this.backendOptions = { + ...defaultOptions, + ...backendOptions, + i18nextElectronBackend: typeof window !== 'undefined' ? window.i18n.i18nextElectronBackend : undefined, + }; + this.i18nextOptions = i18nextOptions; + // log-related + const logPrepend = '[i18next-electron-fs-backend:'; + this.mainLog = `${logPrepend}main]=>`; + this.rendererLog = `${logPrepend}renderer]=>`; + this.setupIpcBindings(); + } + + // Sets up Ipc bindings so that we can keep any node-specific + // modules; (ie. 'fs') out of the Electron renderer process + setupIpcBindings() { + const { i18nextElectronBackend } = this.backendOptions; + i18nextElectronBackend.onReceive(I18NChannels.readFileResponse, (arguments_: any) => { + // args: + // { + // key + // error + // data + // } + // Don't know why we need this line; + // upon initialization, the i18next library + // ends up in this .on([channel], args) method twice + if (typeof this.readCallbacks[arguments_.key] === 'undefined') { return; } - fs.writeFile(localeFilePath, JSON.stringify(writeFileArgs.data), (error: Error) => { - windowService.sendToAllWindows(I18NChannels.writeFileResponse, { - keys: writeFileArgs.keys, - error, - }); - }); + let callback; + if (arguments_.error) { + // Failed to read translation file; + // we pass back a fake "success" response + // so that we create a translation file + callback = this.readCallbacks[arguments_.key].callback; + delete this.readCallbacks[arguments_.key]; + if (callback !== null && typeof callback === 'function') { + callback(null, {}); + } + } else { + let result; + arguments_.data = arguments_.data.replace(/^\uFEFF/, ''); + try { + result = JSON.parse(arguments_.data); + } catch (parseError) { + parseError.message = `Error parsing '${arguments_.filename}'. Message: '${parseError}'.`; + callback = this.readCallbacks[arguments_.key].callback; + delete this.readCallbacks[arguments_.key]; + if (callback !== null && typeof callback === 'function') { + callback(parseError); + } + return; + } + callback = this.readCallbacks[arguments_.key].callback; + delete this.readCallbacks[arguments_.key]; + if (callback !== null && typeof callback === 'function') { + callback(null, result); + } + } }); - }); -}; + i18nextElectronBackend.onReceive(I18NChannels.writeFileResponse, (arguments_: any) => { + // args: + // { + // keys + // error + // } + const { keys } = arguments_; + for (const key of keys) { + let callback; + // Write methods don't have any callbacks from what I've seen, + // so this is called more than I thought; but necessary! + if (typeof this.writeCallbacks[key] === 'undefined') { + return; + } + if (arguments_.error) { + callback = this.writeCallbacks[key].callback; + delete this.writeCallbacks[key]; + callback(arguments_.error); + } else { + callback = this.writeCallbacks[key].callback; + delete this.writeCallbacks[key]; + callback(null, true); + } + } + }); + } -/** - * Clears the bindings from ipcMain; - * in case app is closed/reopened (only on macos) - */ -export const clearMainBindings = function (ipcMain: IpcMain): void { - ipcMain.removeAllListeners(I18NChannels.readFileRequest); - ipcMain.removeAllListeners(I18NChannels.writeFileRequest); -}; + // Writes a given translation to file + write(writeQueue: any) { + const { debug, i18nextElectronBackend } = this.backendOptions; + // Group by filename so we can make one request + // for all changes within a given file + const toWork = groupByArray(writeQueue, 'filename'); + for (const element of toWork) { + const anonymous = function (error: any, data: any) { + if (error) { + console.error( + `${this.rendererLog} encountered error when trying to read file '{filename}' before writing missing translation ('{key}'/'{fallbackValue}') to file. Please resolve this error so missing translation values can be written to file. Error: '${error}'.`, + ); + return; + } + const keySeparator = !!this.i18nextOptions.keySeparator; // Do we have a key separator or not? + const writeKeys = []; + for (let index = 0; index < element.values.length; index++) { + // If we have no key separator set, simply update the translation value + if (!keySeparator) { + data[element.values[index].key] = element.values[index].fallbackValue; + } else { + // Created the nested object structure based on the key separator, and merge that + // into the existing translation data + data = mergeNested(data, element.values[index].key, this.i18nextOptions.keySeparator, element.values[index].fallbackValue); + } + const writeKey = uuid(); + if (element.values[index].callback) { + this.writeCallbacks[writeKey] = { + callback: element.values[index].callback, + }; + writeKeys.push(writeKey); + } + } + // Send out the message to the ipcMain process + debug ? console.log(`${this.rendererLog} requesting the missing key '${key}' be written to file '${filename}'.`) : null; + i18nextElectronBackend.send(I18NChannels.writeFileRequest, { + keys: writeKeys, + filename: element.key, + data, + }); + }.bind(this); + this.requestFileRead(element.key, anonymous); + } + } + + // Reads a given translation file + requestFileRead(filename: any, callback: any) { + const { i18nextElectronBackend } = this.backendOptions; + // Save the callback for this request so we + // can execute once the ipcRender process returns + // with a value from the ipcMain process + const key = uuid(); + this.readCallbacks[key] = { + callback, + }; + // Send out the message to the ipcMain process + i18nextElectronBackend.send(I18NChannels.readFileRequest, { + key, + filename, + }); + } + + // Reads a given translation file + read(language: string, namespace: string, callback: any) { + const { loadPath } = this.backendOptions; + const filename = this.services.interpolator.interpolate(loadPath, { + lng: language, + ns: namespace, + }); + this.requestFileRead(filename, (error: any, data: any) => { + if (error) { + return callback(error, false); + } // no retry + callback(null, data); + }); + } + + // Not implementing at this time + readMulti(languages:string, namespaces: any, callback: any) { + throw 'Not implemented exception.'; + } + + // Writes a missing translation to file + create(languages:string, namespace: any, key: any, fallbackValue: any, callback: any) { + const { addPath } = this.backendOptions; + let filename; + languages = typeof languages === 'string' ? [languages] : languages; + // Create the missing translation for all languages + for (const language of languages) { + filename = this.services.interpolator.interpolate(addPath, { + lng: language, + ns: namespace, + }); + // If we are currently writing missing translations from writeQueue, + // temporarily store the requests in writeQueueOverflow until we are + // done writing to file + if (this.useOverflow) { + this.writeQueueOverflow.push({ + filename, + key, + fallbackValue, + callback, + }); + } else { + this.writeQueue.push({ + filename, + key, + fallbackValue, + callback, + }); + } + } + // Fire up the timeout to process items to write + if (this.writeQueue.length > 0 && !this.useOverflow) { + // Clear out any existing timeout if we are still getting translations to write + if (typeof this.writeTimeout !== 'undefined') { + clearInterval(this.writeTimeout); + } + this.writeTimeout = setInterval( + function () { + // Write writeQueue entries, then after, + // fill in any from the writeQueueOverflow + if (this.writeQueue.length > 0) { + this.write(cloneDeep(this.writeQueue)); + } + this.writeQueue = cloneDeep(this.writeQueueOverflow); + this.writeQueueOverflow = []; + if (this.writeQueue.length === 0) { + // Clear timer + clearInterval(this.writeTimeout); + delete this.writeTimeout; + this.useOverflow = false; + } + }.bind(this), + 1000, + ); + this.useOverflow = true; + } + } +} diff --git a/src/services/libs/i18n/index.ts b/src/services/libs/i18n/index.ts index 0ea3c97d..007c56e9 100644 --- a/src/services/libs/i18n/index.ts +++ b/src/services/libs/i18n/index.ts @@ -4,8 +4,8 @@ import Backend from 'i18next-fs-backend'; import isDevelopment from 'electron-is-dev'; import { LOCALIZATION_FOLDER } from '@services/constants/paths'; -import bindI18nListener from './bindI18nListener'; import changeToDefaultLanguage from './useDefaultLanguage'; +import { mainBindings } from './i18nMainBindings'; // init i18n is async, but our usage is basically await the electron app to start, so this is basically ok void i18next.use(Backend).init({ @@ -23,8 +23,8 @@ void i18next.use(Backend).init({ fallbackLng: isDevelopment ? false : 'en', // set to false when generating translation files locally }); -export async function initI18NAfterServiceReady(): Promise { - await bindI18nListener(); +export async function initRendererI18NHandler(): Promise { + mainBindings(); await changeToDefaultLanguage(i18next); } diff --git a/src/services/windows/index.ts b/src/services/windows/index.ts index efec39c2..718ddb1d 100644 --- a/src/services/windows/index.ts +++ b/src/services/windows/index.ts @@ -178,10 +178,10 @@ export class Window implements IWindowService { newWindow.on('closed', () => { this.windows[windowName] = undefined; }); - await newWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); + let webContentLoadingPromise: Promise | undefined; if (isMainWindow) { // handle window show and Webview/browserView show - return await new Promise((resolve) => { + webContentLoadingPromise = new Promise((resolve) => { newWindow.once('ready-to-show', () => { const mainWindow = this.get(WindowNames.main); if (mainWindow === undefined) return; @@ -202,6 +202,9 @@ export class Window implements IWindowService { }); }); } + // This loading will wait for a while + await newWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); + await webContentLoadingPromise; } private registerMainWindowListeners(newWindow: BrowserWindow): void {