diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-03-30 01:36:22 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-05-16 00:54:57 +0200 |
commit | 7af01713180066b6dc1061dae930840e48c60fec (patch) | |
tree | 604a52eb8f167caf3400dad7589aaa5be2d06c8d /packages | |
parent | feat: Add custom menubar (diff) | |
download | sophie-7af01713180066b6dc1061dae930840e48c60fec.tar.gz sophie-7af01713180066b6dc1061dae930840e48c60fec.tar.zst sophie-7af01713180066b6dc1061dae930840e48c60fec.zip |
feat(main): Add localization support
Add i18next with a custom backend to the main process to load
localization from file.
Missing localizations are written to a missing localizations file in
debug mode, but silently fall back in production mode.
We will also need to add a custom backend for the renderer process that
communicates with the main process.
(i18next-fs-electron-backend is not applicable here, because we need
localizations both in the main and renderer processes.)
Signed-off-by: Kristóf Marussy <kristof@marussy.com>
Diffstat (limited to 'packages')
-rw-r--r-- | packages/main/package.json | 1 | ||||
-rw-r--r-- | packages/main/src/i18n/I18nStore.ts | 104 | ||||
-rw-r--r-- | packages/main/src/i18n/LocalizationRepository.ts | 39 | ||||
-rw-r--r-- | packages/main/src/i18n/RepositoryBasedI18nBackend.ts | 224 | ||||
-rw-r--r-- | packages/main/src/i18n/i18nLog.ts | 41 | ||||
-rw-r--r-- | packages/main/src/i18n/impl/LocaltizationFiles.ts | 93 | ||||
-rw-r--r-- | packages/main/src/i18n/loadLocalization.ts | 50 | ||||
-rw-r--r-- | packages/main/src/infrastructure/config/impl/ConfigFile.ts | 5 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts | 29 | ||||
-rw-r--r-- | packages/main/src/infrastructure/resources/Resources.ts | 2 | ||||
-rw-r--r-- | packages/main/src/infrastructure/resources/impl/getDistResources.ts | 4 | ||||
-rw-r--r-- | packages/main/src/initReactions.ts | 8 | ||||
-rw-r--r-- | packages/main/src/stores/MainStore.ts | 9 | ||||
-rw-r--r-- | packages/main/src/utils/isErrno.ts | 28 |
14 files changed, 624 insertions, 13 deletions
diff --git a/packages/main/package.json b/packages/main/package.json index 4ba6d06..85f9b12 100644 --- a/packages/main/package.json +++ b/packages/main/package.json | |||
@@ -14,6 +14,7 @@ | |||
14 | "deep-equal": "^2.0.5", | 14 | "deep-equal": "^2.0.5", |
15 | "electron": "17.1.0", | 15 | "electron": "17.1.0", |
16 | "fs-extra": "^10.0.1", | 16 | "fs-extra": "^10.0.1", |
17 | "i18next": "^21.6.14", | ||
17 | "json5": "^2.2.0", | 18 | "json5": "^2.2.0", |
18 | "lodash-es": "^4.17.21", | 19 | "lodash-es": "^4.17.21", |
19 | "loglevel": "^1.8.0", | 20 | "loglevel": "^1.8.0", |
diff --git a/packages/main/src/i18n/I18nStore.ts b/packages/main/src/i18n/I18nStore.ts new file mode 100644 index 0000000..d833e8a --- /dev/null +++ b/packages/main/src/i18n/I18nStore.ts | |||
@@ -0,0 +1,104 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { i18n, TFunction } from 'i18next'; | ||
22 | import { IAtom, createAtom } from 'mobx'; | ||
23 | |||
24 | import { getLogger } from '../utils/log'; | ||
25 | |||
26 | const log = getLogger('I18nStore'); | ||
27 | |||
28 | export type UseTranslationResult = | ||
29 | | { ready: true; i18n: i18n; t: TFunction } | ||
30 | | { ready: false }; | ||
31 | |||
32 | export default class I18nStore { | ||
33 | private readonly languageChangedAtom: IAtom; | ||
34 | |||
35 | private readonly namespaceLoadedAtoms: Map<string, IAtom> = new Map(); | ||
36 | |||
37 | private readonly notifyLanguageChange = () => | ||
38 | this.languageChangedAtom.reportObserved(); | ||
39 | |||
40 | constructor(private readonly i18next: i18n) { | ||
41 | this.languageChangedAtom = createAtom( | ||
42 | 'i18next', | ||
43 | () => i18next.on('languageChanged', this.notifyLanguageChange), | ||
44 | () => i18next.off('languageChanged', this.notifyLanguageChange), | ||
45 | ); | ||
46 | } | ||
47 | |||
48 | useTranslation(ns?: string): UseTranslationResult { | ||
49 | const observed = this.languageChangedAtom.reportObserved(); | ||
50 | const namespaceToLoad = | ||
51 | ns ?? this.i18next.options.defaultNS ?? 'translation'; | ||
52 | if ( | ||
53 | this.i18next.isInitialized && | ||
54 | this.i18next.hasLoadedNamespace(namespaceToLoad) | ||
55 | ) { | ||
56 | return { | ||
57 | ready: true, | ||
58 | i18n: this.i18next, | ||
59 | // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`. | ||
60 | t: this.i18next.getFixedT(null, namespaceToLoad), | ||
61 | }; | ||
62 | } | ||
63 | if (observed) { | ||
64 | this.loadNamespace(namespaceToLoad); | ||
65 | } | ||
66 | return { ready: false }; | ||
67 | } | ||
68 | |||
69 | private loadNamespace(ns: string): void { | ||
70 | const existingAtom = this.namespaceLoadedAtoms.get(ns); | ||
71 | if (existingAtom !== undefined) { | ||
72 | existingAtom.reportObserved(); | ||
73 | return; | ||
74 | } | ||
75 | const atom = createAtom(`i18next-${ns}`); | ||
76 | this.namespaceLoadedAtoms.set(ns, atom); | ||
77 | atom.reportObserved(); | ||
78 | |||
79 | const loaded = () => { | ||
80 | this.namespaceLoadedAtoms.delete(ns); | ||
81 | atom.reportChanged(); | ||
82 | }; | ||
83 | |||
84 | const loadAsync = async () => { | ||
85 | await this.i18next.loadNamespaces([ns]); | ||
86 | if (this.i18next.isInitialized) { | ||
87 | setImmediate(loaded); | ||
88 | return; | ||
89 | } | ||
90 | const initialized = () => { | ||
91 | setImmediate(() => { | ||
92 | this.i18next.off('initialized', initialized); | ||
93 | loaded(); | ||
94 | }); | ||
95 | }; | ||
96 | this.i18next.on('initialized', initialized); | ||
97 | }; | ||
98 | |||
99 | loadAsync().catch((error) => { | ||
100 | log.error('Failed to load translations for namespace', ns, error); | ||
101 | setImmediate(loaded); | ||
102 | }); | ||
103 | } | ||
104 | } | ||
diff --git a/packages/main/src/i18n/LocalizationRepository.ts b/packages/main/src/i18n/LocalizationRepository.ts new file mode 100644 index 0000000..041449d --- /dev/null +++ b/packages/main/src/i18n/LocalizationRepository.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { ResourceKey } from 'i18next'; | ||
22 | |||
23 | export default interface LocatlizationRepository { | ||
24 | getResourceContents( | ||
25 | language: string, | ||
26 | namespace: string, | ||
27 | ): Promise<ResourceKey>; | ||
28 | |||
29 | getMissingResourceContents( | ||
30 | language: string, | ||
31 | namespace: string, | ||
32 | ): Promise<ResourceKey>; | ||
33 | |||
34 | setMissingResourceContents( | ||
35 | language: string, | ||
36 | namespace: string, | ||
37 | data: ResourceKey, | ||
38 | ): Promise<void>; | ||
39 | } | ||
diff --git a/packages/main/src/i18n/RepositoryBasedI18nBackend.ts b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts new file mode 100644 index 0000000..4d87ce9 --- /dev/null +++ b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts | |||
@@ -0,0 +1,224 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { | ||
22 | BackendModule, | ||
23 | InitOptions, | ||
24 | ReadCallback, | ||
25 | Services, | ||
26 | } from 'i18next'; | ||
27 | import { debounce } from 'lodash-es'; | ||
28 | import ms from 'ms'; | ||
29 | |||
30 | import { getLogger } from '../utils/log'; | ||
31 | |||
32 | import type LocatlizationRepository from './LocalizationRepository'; | ||
33 | |||
34 | const MISSING_ENTRIES_DEBOUNCE_TIME = ms('1s'); | ||
35 | |||
36 | const log = getLogger('RepositoryBasedI18nBackend'); | ||
37 | |||
38 | /** | ||
39 | * I18next backend for loading translations from a file. | ||
40 | * | ||
41 | * Loosely based on [i18next-fs-backend](https://github.com/i18next/i18next-fs-backend), | ||
42 | * but we shrink bundle size by omitting JSON5 or YAML translation handling. | ||
43 | * Direct file IO is replaced by the `LocalizationRepository`, | ||
44 | * which handles platform-specific concerns. | ||
45 | */ | ||
46 | export default class RepositoryBasedI18nBackend | ||
47 | implements BackendModule<unknown> | ||
48 | { | ||
49 | type = 'backend' as const; | ||
50 | |||
51 | private writeQueue: Map<string, Map<string, Map<string, string>>> = new Map(); | ||
52 | |||
53 | private keySeparator: string | false = '.'; | ||
54 | |||
55 | private readonly flushWriteQueueImmediately = () => { | ||
56 | const savedWriteQueue = this.writeQueue; | ||
57 | this.writeQueue = new Map(); | ||
58 | savedWriteQueue.forEach((languagesMap, namespace) => { | ||
59 | languagesMap.forEach((keysMap, language) => { | ||
60 | this.flushChanges(language, namespace, keysMap).catch((error) => { | ||
61 | log.error( | ||
62 | 'Cannot write missing', | ||
63 | language, | ||
64 | 'translations for namespace', | ||
65 | namespace, | ||
66 | error, | ||
67 | ); | ||
68 | }); | ||
69 | }); | ||
70 | }); | ||
71 | }; | ||
72 | |||
73 | private readonly flushWriteQueue = debounce( | ||
74 | this.flushWriteQueueImmediately, | ||
75 | MISSING_ENTRIES_DEBOUNCE_TIME, | ||
76 | ); | ||
77 | |||
78 | constructor( | ||
79 | private readonly repository: LocatlizationRepository, | ||
80 | private readonly devMode = false, | ||
81 | ) {} | ||
82 | |||
83 | init( | ||
84 | _services: Services, | ||
85 | _backendOptions: unknown, | ||
86 | { keySeparator }: InitOptions, | ||
87 | ) { | ||
88 | if (keySeparator !== undefined) { | ||
89 | this.keySeparator = keySeparator; | ||
90 | } | ||
91 | } | ||
92 | |||
93 | read(language: string, namespace: string, callback: ReadCallback): void { | ||
94 | const readAsync = async () => { | ||
95 | const translations = await this.repository.getResourceContents( | ||
96 | language, | ||
97 | namespace, | ||
98 | ); | ||
99 | // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`. | ||
100 | setImmediate(() => callback(null, translations)); | ||
101 | }; | ||
102 | |||
103 | readAsync().catch((error) => { | ||
104 | log.error( | ||
105 | 'Error while loading', | ||
106 | language, | ||
107 | 'translations for namespace', | ||
108 | namespace, | ||
109 | error, | ||
110 | ); | ||
111 | const callbackError = | ||
112 | error instanceof Error | ||
113 | ? error | ||
114 | : new Error(`Unknown error: ${JSON.stringify(error)}`); | ||
115 | /* | ||
116 | eslint-disable-next-line promise/no-callback-in-promise, unicorn/no-null -- | ||
117 | Converting from promise based API to a callback. `i18next` API requires `null`. | ||
118 | */ | ||
119 | setImmediate(() => callback(callbackError, null)); | ||
120 | }); | ||
121 | } | ||
122 | |||
123 | create( | ||
124 | languages: string[], | ||
125 | namespace: string, | ||
126 | key: string, | ||
127 | fallbackValue: string, | ||
128 | ): void { | ||
129 | if (!this.devMode) { | ||
130 | throw new Error( | ||
131 | 'Refusing to write missing translations in production mode', | ||
132 | ); | ||
133 | } | ||
134 | let languagesMapOrUndefined = this.writeQueue.get(namespace); | ||
135 | if (languagesMapOrUndefined === undefined) { | ||
136 | languagesMapOrUndefined = new Map(); | ||
137 | this.writeQueue.set(namespace, languagesMapOrUndefined); | ||
138 | } | ||
139 | const languagesMap = languagesMapOrUndefined; | ||
140 | languages.forEach((language) => { | ||
141 | let keysMap = languagesMap.get(language); | ||
142 | if (keysMap === undefined) { | ||
143 | keysMap = new Map(); | ||
144 | languagesMap.set(language, keysMap); | ||
145 | } | ||
146 | keysMap.set(key, fallbackValue); | ||
147 | }); | ||
148 | this.flushWriteQueue(); | ||
149 | } | ||
150 | |||
151 | /** | ||
152 | * Flushes new translations to the missing translations file. | ||
153 | * | ||
154 | * Will always fails if `devMode` is `false`. | ||
155 | * If no changes are made to the translations, the file won't be rewritten. | ||
156 | * | ||
157 | * @param language The language to add translations for. | ||
158 | * @param namespace The namespace to add translations for. | ||
159 | * @param entries The translations to add. | ||
160 | * @returns A promise that resolves when changes were flushed successfully. | ||
161 | */ | ||
162 | private async flushChanges( | ||
163 | language: string, | ||
164 | namespace: string, | ||
165 | entries: Map<string, string>, | ||
166 | ): Promise<void> { | ||
167 | if (!this.devMode) { | ||
168 | throw new Error( | ||
169 | 'Refusing to write missing translations in production mode', | ||
170 | ); | ||
171 | } | ||
172 | const resourceContents = await this.repository.getMissingResourceContents( | ||
173 | language, | ||
174 | namespace, | ||
175 | ); | ||
176 | const translations = | ||
177 | typeof resourceContents === 'object' ? resourceContents : {}; | ||
178 | let changed = false; | ||
179 | entries.forEach((value, key) => { | ||
180 | const splitKey = this.keySeparator ? key.split(this.keySeparator) : [key]; | ||
181 | let obj = translations; | ||
182 | /* | ||
183 | eslint-disable @typescript-eslint/no-unsafe-assignment, | ||
184 | security/detect-object-injection -- | ||
185 | We need to modify raw objects, because this is the storage format for i18next. | ||
186 | To mitigate the prototype injection security risk, | ||
187 | we make sure to only ever run this in development mode. | ||
188 | */ | ||
189 | for (let i = 0; i < splitKey.length - 1; i += 1) { | ||
190 | let nextObj = obj[splitKey[i]]; | ||
191 | if (typeof nextObj !== 'object') { | ||
192 | nextObj = {}; | ||
193 | obj[splitKey[i]] = nextObj; | ||
194 | } | ||
195 | obj = nextObj; | ||
196 | } | ||
197 | const lastKey = splitKey[splitKey.length - 1]; | ||
198 | if (obj[lastKey] !== value) { | ||
199 | obj[lastKey] = value; | ||
200 | changed = true; | ||
201 | } | ||
202 | /* | ||
203 | eslint-enable @typescript-eslint/no-unsafe-assignment, | ||
204 | security/detect-object-injection | ||
205 | */ | ||
206 | }); | ||
207 | if (!changed) { | ||
208 | return; | ||
209 | } | ||
210 | await this.repository.setMissingResourceContents( | ||
211 | language, | ||
212 | namespace, | ||
213 | translations, | ||
214 | ); | ||
215 | log.debug( | ||
216 | 'Wrote', | ||
217 | entries.size, | ||
218 | 'missing', | ||
219 | language, | ||
220 | 'translations for namespace', | ||
221 | namespace, | ||
222 | ); | ||
223 | } | ||
224 | } | ||
diff --git a/packages/main/src/i18n/i18nLog.ts b/packages/main/src/i18n/i18nLog.ts new file mode 100644 index 0000000..33a8c4e --- /dev/null +++ b/packages/main/src/i18n/i18nLog.ts | |||
@@ -0,0 +1,41 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { LoggerModule } from 'i18next'; | ||
22 | |||
23 | import { getLogger } from '../utils/log'; | ||
24 | |||
25 | const log = getLogger('i18nLog'); | ||
26 | |||
27 | /* | ||
28 | eslint-disable-next-line @typescript-eslint/unbound-method -- | ||
29 | loglevel log methods don't rely on `this`, so this is safe, | ||
30 | and also keeps stacktraces smaller. | ||
31 | */ | ||
32 | const { debug, warn, error } = log; | ||
33 | |||
34 | const i18nLog: LoggerModule = { | ||
35 | type: 'logger', | ||
36 | log: debug, | ||
37 | warn, | ||
38 | error, | ||
39 | }; | ||
40 | |||
41 | export default i18nLog; | ||
diff --git a/packages/main/src/i18n/impl/LocaltizationFiles.ts b/packages/main/src/i18n/impl/LocaltizationFiles.ts new file mode 100644 index 0000000..73a769e --- /dev/null +++ b/packages/main/src/i18n/impl/LocaltizationFiles.ts | |||
@@ -0,0 +1,93 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { readFile, writeFile } from 'node:fs/promises'; | ||
22 | |||
23 | import type { ResourceKey } from 'i18next'; | ||
24 | |||
25 | import type Resources from '../../infrastructure/resources/Resources'; | ||
26 | import isErrno from '../../utils/isErrno'; | ||
27 | import { getLogger } from '../../utils/log'; | ||
28 | import type LocatlizationRepository from '../LocalizationRepository'; | ||
29 | |||
30 | const log = getLogger('LocalizationFiles'); | ||
31 | |||
32 | export default class LocalizationFiles implements LocatlizationRepository { | ||
33 | constructor(private readonly resources: Resources) {} | ||
34 | |||
35 | async getResourceContents( | ||
36 | language: string, | ||
37 | namespace: string, | ||
38 | ): Promise<ResourceKey> { | ||
39 | const fileName = this.resources.getLocalizationPath( | ||
40 | language, | ||
41 | `${namespace}.json`, | ||
42 | ); | ||
43 | const contents = await readFile(fileName, 'utf8'); | ||
44 | log.info('Read localization file', fileName); | ||
45 | // The contents of the file should come from a signed archive during production, | ||
46 | // so we don't need to (and can't) validate them. | ||
47 | return JSON.parse(contents) as ResourceKey; | ||
48 | } | ||
49 | |||
50 | private getMissingLocalizationPath( | ||
51 | language: string, | ||
52 | namespace: string, | ||
53 | ): string { | ||
54 | return this.resources.getLocalizationPath( | ||
55 | language, | ||
56 | `${namespace}.missing.json`, | ||
57 | ); | ||
58 | } | ||
59 | |||
60 | async getMissingResourceContents( | ||
61 | language: string, | ||
62 | namespace: string, | ||
63 | ): Promise<ResourceKey> { | ||
64 | const fileName = this.getMissingLocalizationPath(language, namespace); | ||
65 | try { | ||
66 | const contents = await readFile(fileName, 'utf8'); | ||
67 | // The contents of the file are only used during development, | ||
68 | // so we don't need to (and can't) validate them. | ||
69 | return JSON.parse(contents) as ResourceKey; | ||
70 | } catch (error) { | ||
71 | if (isErrno(error, 'ENOENT')) { | ||
72 | log.debug( | ||
73 | 'No missing translations file', | ||
74 | language, | ||
75 | 'for namespace', | ||
76 | namespace, | ||
77 | ); | ||
78 | return {}; | ||
79 | } | ||
80 | throw error; | ||
81 | } | ||
82 | } | ||
83 | |||
84 | setMissingResourceContents( | ||
85 | language: string, | ||
86 | namespace: string, | ||
87 | data: ResourceKey, | ||
88 | ): Promise<void> { | ||
89 | const fileName = this.getMissingLocalizationPath(language, namespace); | ||
90 | const contents = JSON.stringify(data, undefined, 2); | ||
91 | return writeFile(fileName, contents, 'utf8'); | ||
92 | } | ||
93 | } | ||
diff --git a/packages/main/src/i18n/loadLocalization.ts b/packages/main/src/i18n/loadLocalization.ts new file mode 100644 index 0000000..1408a30 --- /dev/null +++ b/packages/main/src/i18n/loadLocalization.ts | |||
@@ -0,0 +1,50 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import i18next from 'i18next'; | ||
22 | |||
23 | import type Resources from '../infrastructure/resources/Resources'; | ||
24 | import type MainStore from '../stores/MainStore'; | ||
25 | |||
26 | import I18nStore from './I18nStore'; | ||
27 | import RepositoryBasedI18nBackend from './RepositoryBasedI18nBackend'; | ||
28 | import i18nLog from './i18nLog'; | ||
29 | import LocalizationFiles from './impl/LocaltizationFiles'; | ||
30 | |||
31 | export default async function loadLocalization( | ||
32 | store: MainStore, | ||
33 | resources: Resources, | ||
34 | devMode: boolean, | ||
35 | ): Promise<void> { | ||
36 | const repository = new LocalizationFiles(resources); | ||
37 | const backend = new RepositoryBasedI18nBackend(repository, devMode); | ||
38 | const i18n = i18next | ||
39 | .createInstance({ | ||
40 | lng: 'en', | ||
41 | fallbackLng: ['en'], | ||
42 | debug: devMode, | ||
43 | saveMissing: devMode, | ||
44 | }) | ||
45 | .use(backend) | ||
46 | .use(i18nLog); | ||
47 | await i18n.init(); | ||
48 | const i18nStore = new I18nStore(i18n); | ||
49 | store.setI18n(i18nStore); | ||
50 | } | ||
diff --git a/packages/main/src/infrastructure/config/impl/ConfigFile.ts b/packages/main/src/infrastructure/config/impl/ConfigFile.ts index e8237b4..4ad0fcc 100644 --- a/packages/main/src/infrastructure/config/impl/ConfigFile.ts +++ b/packages/main/src/infrastructure/config/impl/ConfigFile.ts | |||
@@ -27,6 +27,7 @@ import { throttle } from 'lodash-es'; | |||
27 | 27 | ||
28 | import type Config from '../../../stores/config/Config'; | 28 | import type Config from '../../../stores/config/Config'; |
29 | import type Disposer from '../../../utils/Disposer'; | 29 | import type Disposer from '../../../utils/Disposer'; |
30 | import isErrno from '../../../utils/isErrno'; | ||
30 | import { getLogger } from '../../../utils/log'; | 31 | import { getLogger } from '../../../utils/log'; |
31 | import type ConfigRepository from '../ConfigRepository'; | 32 | import type ConfigRepository from '../ConfigRepository'; |
32 | import type { ReadConfigResult } from '../ConfigRepository'; | 33 | import type { ReadConfigResult } from '../ConfigRepository'; |
@@ -55,7 +56,7 @@ export default class ConfigFile implements ConfigRepository { | |||
55 | try { | 56 | try { |
56 | configStr = await readFile(this.#configFilePath, 'utf8'); | 57 | configStr = await readFile(this.#configFilePath, 'utf8'); |
57 | } catch (error) { | 58 | } catch (error) { |
58 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { | 59 | if (isErrno(error, 'ENOENT')) { |
59 | log.debug('Config file', this.#configFilePath, 'was not found'); | 60 | log.debug('Config file', this.#configFilePath, 'was not found'); |
60 | return { found: false }; | 61 | return { found: false }; |
61 | } | 62 | } |
@@ -94,7 +95,7 @@ export default class ConfigFile implements ConfigRepository { | |||
94 | mtime = stats.mtime; | 95 | mtime = stats.mtime; |
95 | log.trace('Config file last modified at', mtime); | 96 | log.trace('Config file last modified at', mtime); |
96 | } catch (error) { | 97 | } catch (error) { |
97 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { | 98 | if (isErrno(error, 'ENOENT')) { |
98 | log.debug( | 99 | log.debug( |
99 | 'Config file', | 100 | 'Config file', |
100 | this.#configFilePath, | 101 | this.#configFilePath, |
diff --git a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts index 5166719..49bfbfd 100644 --- a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts +++ b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts | |||
@@ -30,6 +30,12 @@ export default function setApplicationMenu( | |||
30 | isMac: boolean, | 30 | isMac: boolean, |
31 | ): void { | 31 | ): void { |
32 | const dispose = autorun(() => { | 32 | const dispose = autorun(() => { |
33 | const translation = store.useTranslation(); | ||
34 | if (!translation.ready) { | ||
35 | return; | ||
36 | } | ||
37 | const { t } = translation; | ||
38 | |||
33 | const { settings, shared, visibleService } = store; | 39 | const { settings, shared, visibleService } = store; |
34 | const { showLocationBar, selectedService } = settings; | 40 | const { showLocationBar, selectedService } = settings; |
35 | const { canSwitchServices, services } = shared; | 41 | const { canSwitchServices, services } = shared; |
@@ -42,7 +48,7 @@ export default function setApplicationMenu( | |||
42 | role: 'viewMenu', | 48 | role: 'viewMenu', |
43 | submenu: [ | 49 | submenu: [ |
44 | { | 50 | { |
45 | label: 'Show Location Bar', | 51 | label: t<string>('menu.view.showLocationBar'), |
46 | accelerator: 'CommandOrControl+Shift+L', | 52 | accelerator: 'CommandOrControl+Shift+L', |
47 | type: 'checkbox', | 53 | type: 'checkbox', |
48 | checked: showLocationBar, | 54 | checked: showLocationBar, |
@@ -52,7 +58,7 @@ export default function setApplicationMenu( | |||
52 | }, | 58 | }, |
53 | { type: 'separator' }, | 59 | { type: 'separator' }, |
54 | { | 60 | { |
55 | label: 'Reload', | 61 | label: t<string>('menu.view.reload'), |
56 | accelerator: 'CommandOrControl+R', | 62 | accelerator: 'CommandOrControl+R', |
57 | enabled: selectedService !== undefined, | 63 | enabled: selectedService !== undefined, |
58 | click() { | 64 | click() { |
@@ -60,7 +66,7 @@ export default function setApplicationMenu( | |||
60 | }, | 66 | }, |
61 | }, | 67 | }, |
62 | { | 68 | { |
63 | label: 'Force Reload', | 69 | label: t<string>('menu.view.forceReload'), |
64 | accelerator: 'CommandOrControl+Shift+R', | 70 | accelerator: 'CommandOrControl+Shift+R', |
65 | enabled: selectedService !== undefined, | 71 | enabled: selectedService !== undefined, |
66 | click() { | 72 | click() { |
@@ -68,7 +74,7 @@ export default function setApplicationMenu( | |||
68 | }, | 74 | }, |
69 | }, | 75 | }, |
70 | { | 76 | { |
71 | label: 'Toggle Developer Tools', | 77 | label: t<string>('menu.view.toggleDeveloperTools'), |
72 | accelerator: 'CommandOrControl+Shift+I', | 78 | accelerator: 'CommandOrControl+Shift+I', |
73 | enabled: visibleService !== undefined, | 79 | enabled: visibleService !== undefined, |
74 | click() { | 80 | click() { |
@@ -80,12 +86,15 @@ export default function setApplicationMenu( | |||
80 | ? ([ | 86 | ? ([ |
81 | { | 87 | { |
82 | role: 'forceReload', | 88 | role: 'forceReload', |
83 | label: 'Reload Sophie', | 89 | label: t<string>('menu.view.reloadSophie'), |
84 | accelerator: 'CommandOrControl+Shift+Alt+R', | 90 | accelerator: 'CommandOrControl+Shift+Alt+R', |
85 | }, | 91 | }, |
86 | { | 92 | { |
87 | role: 'toggleDevTools', | 93 | role: 'toggleDevTools', |
88 | label: 'Toggle Sophie Developer Tools', | 94 | label: t<string>( |
95 | 'menu.view.toggleSophieDeveloperTools', | ||
96 | 'Toggle Sophie Developer Tools', | ||
97 | ), | ||
89 | accelerator: 'CommandOrControl+Shift+Alt+I', | 98 | accelerator: 'CommandOrControl+Shift+Alt+I', |
90 | }, | 99 | }, |
91 | { type: 'separator' }, | 100 | { type: 'separator' }, |
@@ -95,10 +104,10 @@ export default function setApplicationMenu( | |||
95 | ], | 104 | ], |
96 | }, | 105 | }, |
97 | { | 106 | { |
98 | label: 'Services', | 107 | label: t<string>('menu.servicesMenu'), |
99 | submenu: [ | 108 | submenu: [ |
100 | { | 109 | { |
101 | label: 'Next Service', | 110 | label: t<string>('menu.services.nextService'), |
102 | accelerator: 'CommandOrControl+Tab', | 111 | accelerator: 'CommandOrControl+Tab', |
103 | enabled: canSwitchServices, | 112 | enabled: canSwitchServices, |
104 | click() { | 113 | click() { |
@@ -106,7 +115,7 @@ export default function setApplicationMenu( | |||
106 | }, | 115 | }, |
107 | }, | 116 | }, |
108 | { | 117 | { |
109 | label: 'Previous Service', | 118 | label: t<string>('menu.services.previousService'), |
110 | accelerator: 'CommandOrControl+Shift+Tab', | 119 | accelerator: 'CommandOrControl+Shift+Tab', |
111 | enabled: canSwitchServices, | 120 | enabled: canSwitchServices, |
112 | click() { | 121 | click() { |
@@ -140,7 +149,7 @@ export default function setApplicationMenu( | |||
140 | role: 'help', | 149 | role: 'help', |
141 | submenu: [ | 150 | submenu: [ |
142 | { | 151 | { |
143 | label: 'Gitlab', | 152 | label: t<string>('menu.help.gitlab'), |
144 | click() { | 153 | click() { |
145 | store.openWebpageInBrowser(); | 154 | store.openWebpageInBrowser(); |
146 | }, | 155 | }, |
diff --git a/packages/main/src/infrastructure/resources/Resources.ts b/packages/main/src/infrastructure/resources/Resources.ts index 269c838..0e90c6b 100644 --- a/packages/main/src/infrastructure/resources/Resources.ts +++ b/packages/main/src/infrastructure/resources/Resources.ts | |||
@@ -21,6 +21,8 @@ | |||
21 | export default interface Resources { | 21 | export default interface Resources { |
22 | getPath(packageName: string, relativePathInPackage: string): string; | 22 | getPath(packageName: string, relativePathInPackage: string): string; |
23 | 23 | ||
24 | getLocalizationPath(language: string, fileName: string): string; | ||
25 | |||
24 | getFileURL(packageName: string, relativePathInPackage: string): string; | 26 | getFileURL(packageName: string, relativePathInPackage: string): string; |
25 | 27 | ||
26 | getRendererURL(relativePathInRendererPackage: string): string; | 28 | getRendererURL(relativePathInRendererPackage: string): string; |
diff --git a/packages/main/src/infrastructure/resources/impl/getDistResources.ts b/packages/main/src/infrastructure/resources/impl/getDistResources.ts index f3c3f7b..3c1ffb3 100644 --- a/packages/main/src/infrastructure/resources/impl/getDistResources.ts +++ b/packages/main/src/infrastructure/resources/impl/getDistResources.ts | |||
@@ -33,6 +33,7 @@ export default function getDistResources( | |||
33 | devServerURL = import.meta.env?.VITE_DEV_SERVER_URL, | 33 | devServerURL = import.meta.env?.VITE_DEV_SERVER_URL, |
34 | ): Resources { | 34 | ): Resources { |
35 | const packagesRoot = path.join(thisDir, '..', '..'); | 35 | const packagesRoot = path.join(thisDir, '..', '..'); |
36 | const localizationRoot = path.join(packagesRoot, '..', 'locales'); | ||
36 | 37 | ||
37 | function getPath(packageName: string, relativePathInPackage: string): string { | 38 | function getPath(packageName: string, relativePathInPackage: string): string { |
38 | return path.join(packagesRoot, packageName, 'dist', relativePathInPackage); | 39 | return path.join(packagesRoot, packageName, 'dist', relativePathInPackage); |
@@ -48,6 +49,9 @@ export default function getDistResources( | |||
48 | 49 | ||
49 | return { | 50 | return { |
50 | getPath, | 51 | getPath, |
52 | getLocalizationPath(language, fileName) { | ||
53 | return path.join(localizationRoot, language, fileName); | ||
54 | }, | ||
51 | getFileURL, | 55 | getFileURL, |
52 | getRendererURL: | 56 | getRendererURL: |
53 | devMode && devServerURL !== undefined | 57 | devMode && devServerURL !== undefined |
diff --git a/packages/main/src/initReactions.ts b/packages/main/src/initReactions.ts index 9c49fc5..cdff551 100644 --- a/packages/main/src/initReactions.ts +++ b/packages/main/src/initReactions.ts | |||
@@ -20,6 +20,7 @@ | |||
20 | 20 | ||
21 | import { app, session } from 'electron'; | 21 | import { app, session } from 'electron'; |
22 | 22 | ||
23 | import loadLocalization from './i18n/loadLocalization'; | ||
23 | import ConfigFile from './infrastructure/config/impl/ConfigFile'; | 24 | import ConfigFile from './infrastructure/config/impl/ConfigFile'; |
24 | import UserAgents from './infrastructure/electron/UserAgents'; | 25 | import UserAgents from './infrastructure/electron/UserAgents'; |
25 | import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; | 26 | import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; |
@@ -52,7 +53,11 @@ export default async function initReactions( | |||
52 | } | 53 | } |
53 | const userAgents = new UserAgents(app.userAgentFallback); | 54 | const userAgents = new UserAgents(app.userAgentFallback); |
54 | app.userAgentFallback = userAgents.fallbackUserAgent(devMode); | 55 | app.userAgentFallback = userAgents.fallbackUserAgent(devMode); |
55 | setApplicationMenu(store, devMode, isMac); | 56 | const localizeInterface = async () => { |
57 | await loadLocalization(store, resources, devMode); | ||
58 | setApplicationMenu(store, devMode, isMac); | ||
59 | }; | ||
60 | const localization = localizeInterface(); | ||
56 | const viewFactory = new ElectronViewFactory(userAgents, resources, devMode); | 61 | const viewFactory = new ElectronViewFactory(userAgents, resources, devMode); |
57 | const [mainWindow] = await Promise.all([ | 62 | const [mainWindow] = await Promise.all([ |
58 | viewFactory.createMainWindow(store), | 63 | viewFactory.createMainWindow(store), |
@@ -60,6 +65,7 @@ export default async function initReactions( | |||
60 | ]); | 65 | ]); |
61 | store.setMainWindow(mainWindow); | 66 | store.setMainWindow(mainWindow); |
62 | loadServices(store, viewFactory); | 67 | loadServices(store, viewFactory); |
68 | await localization; | ||
63 | return () => { | 69 | return () => { |
64 | disposeNativeThemeController(); | 70 | disposeNativeThemeController(); |
65 | disposeConfigController(); | 71 | disposeConfigController(); |
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts index 86fadcb..873f998 100644 --- a/packages/main/src/stores/MainStore.ts +++ b/packages/main/src/stores/MainStore.ts | |||
@@ -21,6 +21,8 @@ | |||
21 | import type { Action, BrowserViewBounds } from '@sophie/shared'; | 21 | import type { Action, BrowserViewBounds } from '@sophie/shared'; |
22 | import { applySnapshot, Instance, types } from 'mobx-state-tree'; | 22 | import { applySnapshot, Instance, types } from 'mobx-state-tree'; |
23 | 23 | ||
24 | import type I18nStore from '../i18n/I18nStore'; | ||
25 | import type { UseTranslationResult } from '../i18n/I18nStore'; | ||
24 | import type { MainWindow } from '../infrastructure/electron/types'; | 26 | import type { MainWindow } from '../infrastructure/electron/types'; |
25 | import { getLogger } from '../utils/log'; | 27 | import { getLogger } from '../utils/log'; |
26 | 28 | ||
@@ -44,6 +46,7 @@ const MainStore = types | |||
44 | {}, | 46 | {}, |
45 | ), | 47 | ), |
46 | shared: types.optional(SharedStore, {}), | 48 | shared: types.optional(SharedStore, {}), |
49 | i18n: types.frozen<I18nStore | undefined>(), | ||
47 | }) | 50 | }) |
48 | .views((self) => ({ | 51 | .views((self) => ({ |
49 | get settings(): GlobalSettings { | 52 | get settings(): GlobalSettings { |
@@ -61,6 +64,9 @@ const MainStore = types | |||
61 | ? selectedService | 64 | ? selectedService |
62 | : undefined; | 65 | : undefined; |
63 | }, | 66 | }, |
67 | useTranslation(ns?: string): UseTranslationResult { | ||
68 | return self.i18n?.useTranslation(ns) ?? { ready: false }; | ||
69 | }, | ||
64 | })) | 70 | })) |
65 | .volatile( | 71 | .volatile( |
66 | (): { | 72 | (): { |
@@ -124,6 +130,9 @@ const MainStore = types | |||
124 | beforeDestroy(): void { | 130 | beforeDestroy(): void { |
125 | self.mainWindow?.dispose(); | 131 | self.mainWindow?.dispose(); |
126 | }, | 132 | }, |
133 | setI18n(i18n: I18nStore): void { | ||
134 | self.i18n = i18n; | ||
135 | }, | ||
127 | })); | 136 | })); |
128 | 137 | ||
129 | /* | 138 | /* |
diff --git a/packages/main/src/utils/isErrno.ts b/packages/main/src/utils/isErrno.ts new file mode 100644 index 0000000..8adbc33 --- /dev/null +++ b/packages/main/src/utils/isErrno.ts | |||
@@ -0,0 +1,28 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2021-2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | export default function isErrno(error: unknown, code: string): boolean { | ||
22 | return ( | ||
23 | typeof error === 'object' && | ||
24 | error !== null && | ||
25 | 'code' in error && | ||
26 | (error as NodeJS.ErrnoException).code === code | ||
27 | ); | ||
28 | } | ||