diff options
Diffstat (limited to 'packages/main/src/i18n/RepositoryBasedI18nBackend.ts')
-rw-r--r-- | packages/main/src/i18n/RepositoryBasedI18nBackend.ts | 223 |
1 files changed, 223 insertions, 0 deletions
diff --git a/packages/main/src/i18n/RepositoryBasedI18nBackend.ts b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts new file mode 100644 index 0000000..5b667d5 --- /dev/null +++ b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts | |||
@@ -0,0 +1,223 @@ | |||
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 | |||
29 | import getLogger from '../utils/getLogger.js'; | ||
30 | |||
31 | import type LocatlizationRepository from './LocalizationRepository.js'; | ||
32 | |||
33 | const MISSING_ENTRIES_DEBOUNCE_TIME_MS = 1000; | ||
34 | |||
35 | const log = getLogger('RepositoryBasedI18nBackend'); | ||
36 | |||
37 | /** | ||
38 | * I18next backend for loading translations from a file. | ||
39 | * | ||
40 | * Loosely based on [i18next-fs-backend](https://github.com/i18next/i18next-fs-backend), | ||
41 | * but we shrink bundle size by omitting JSON5 or YAML translation handling. | ||
42 | * Direct file IO is replaced by the `LocalizationRepository`, | ||
43 | * which handles platform-specific concerns. | ||
44 | */ | ||
45 | export default class RepositoryBasedI18nBackend | ||
46 | implements BackendModule<unknown> | ||
47 | { | ||
48 | type = 'backend' as const; | ||
49 | |||
50 | private writeQueue: Map<string, Map<string, Map<string, string>>> = new Map(); | ||
51 | |||
52 | private keySeparator: string | false = '.'; | ||
53 | |||
54 | private readonly flushWriteQueueImmediately = () => { | ||
55 | const savedWriteQueue = this.writeQueue; | ||
56 | this.writeQueue = new Map(); | ||
57 | savedWriteQueue.forEach((languagesMap, namespace) => { | ||
58 | languagesMap.forEach((keysMap, language) => { | ||
59 | this.flushChanges(language, namespace, keysMap).catch((error) => { | ||
60 | log.error( | ||
61 | 'Cannot write missing', | ||
62 | language, | ||
63 | 'translations for namespace', | ||
64 | namespace, | ||
65 | error, | ||
66 | ); | ||
67 | }); | ||
68 | }); | ||
69 | }); | ||
70 | }; | ||
71 | |||
72 | private readonly flushWriteQueue = debounce( | ||
73 | this.flushWriteQueueImmediately, | ||
74 | MISSING_ENTRIES_DEBOUNCE_TIME_MS, | ||
75 | ); | ||
76 | |||
77 | constructor( | ||
78 | private readonly repository: LocatlizationRepository, | ||
79 | private readonly devMode = false, | ||
80 | ) {} | ||
81 | |||
82 | init( | ||
83 | _services: Services, | ||
84 | _backendOptions: unknown, | ||
85 | { keySeparator }: InitOptions, | ||
86 | ) { | ||
87 | if (keySeparator !== undefined) { | ||
88 | this.keySeparator = keySeparator; | ||
89 | } | ||
90 | } | ||
91 | |||
92 | read(language: string, namespace: string, callback: ReadCallback): void { | ||
93 | const readAsync = async () => { | ||
94 | const translations = await this.repository.getResourceContents( | ||
95 | language, | ||
96 | namespace, | ||
97 | ); | ||
98 | // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`. | ||
99 | setImmediate(() => callback(null, translations)); | ||
100 | }; | ||
101 | |||
102 | readAsync().catch((error) => { | ||
103 | log.error( | ||
104 | 'Error while loading', | ||
105 | language, | ||
106 | 'translations for namespace', | ||
107 | namespace, | ||
108 | error, | ||
109 | ); | ||
110 | const callbackError = | ||
111 | error instanceof Error | ||
112 | ? error | ||
113 | : new Error(`Unknown error: ${JSON.stringify(error)}`); | ||
114 | /* | ||
115 | eslint-disable-next-line promise/no-callback-in-promise, unicorn/no-null -- | ||
116 | Converting from promise based API to a callback. `i18next` API requires `null`. | ||
117 | */ | ||
118 | setImmediate(() => callback(callbackError, null)); | ||
119 | }); | ||
120 | } | ||
121 | |||
122 | create( | ||
123 | languages: string[], | ||
124 | namespace: string, | ||
125 | key: string, | ||
126 | fallbackValue: string, | ||
127 | ): void { | ||
128 | if (!this.devMode) { | ||
129 | throw new Error( | ||
130 | 'Refusing to write missing translations in production mode', | ||
131 | ); | ||
132 | } | ||
133 | let languagesMapOrUndefined = this.writeQueue.get(namespace); | ||
134 | if (languagesMapOrUndefined === undefined) { | ||
135 | languagesMapOrUndefined = new Map(); | ||
136 | this.writeQueue.set(namespace, languagesMapOrUndefined); | ||
137 | } | ||
138 | const languagesMap = languagesMapOrUndefined; | ||
139 | languages.forEach((language) => { | ||
140 | let keysMap = languagesMap.get(language); | ||
141 | if (keysMap === undefined) { | ||
142 | keysMap = new Map(); | ||
143 | languagesMap.set(language, keysMap); | ||
144 | } | ||
145 | keysMap.set(key, fallbackValue); | ||
146 | }); | ||
147 | this.flushWriteQueue(); | ||
148 | } | ||
149 | |||
150 | /** | ||
151 | * Flushes new translations to the missing translations file. | ||
152 | * | ||
153 | * Will always fails if `devMode` is `false`. | ||
154 | * If no changes are made to the translations, the file won't be rewritten. | ||
155 | * | ||
156 | * @param language The language to add translations for. | ||
157 | * @param namespace The namespace to add translations for. | ||
158 | * @param entries The translations to add. | ||
159 | * @returns A promise that resolves when changes were flushed successfully. | ||
160 | */ | ||
161 | private async flushChanges( | ||
162 | language: string, | ||
163 | namespace: string, | ||
164 | entries: Map<string, string>, | ||
165 | ): Promise<void> { | ||
166 | if (!this.devMode) { | ||
167 | throw new Error( | ||
168 | 'Refusing to write missing translations in production mode', | ||
169 | ); | ||
170 | } | ||
171 | const resourceContents = await this.repository.getMissingResourceContents( | ||
172 | language, | ||
173 | namespace, | ||
174 | ); | ||
175 | const translations = | ||
176 | typeof resourceContents === 'object' ? resourceContents : {}; | ||
177 | let changed = false; | ||
178 | entries.forEach((value, key) => { | ||
179 | const splitKey = this.keySeparator ? key.split(this.keySeparator) : [key]; | ||
180 | let obj = translations; | ||
181 | /* | ||
182 | eslint-disable @typescript-eslint/no-unsafe-assignment, | ||
183 | security/detect-object-injection -- | ||
184 | We need to modify raw objects, because this is the storage format for i18next. | ||
185 | To mitigate the prototype injection security risk, | ||
186 | we make sure to only ever run this in development mode. | ||
187 | */ | ||
188 | for (let i = 0; i < splitKey.length - 1; i += 1) { | ||
189 | let nextObj = obj[splitKey[i]]; | ||
190 | if (typeof nextObj !== 'object') { | ||
191 | nextObj = {}; | ||
192 | obj[splitKey[i]] = nextObj; | ||
193 | } | ||
194 | obj = nextObj; | ||
195 | } | ||
196 | const lastKey = splitKey[splitKey.length - 1]; | ||
197 | if (obj[lastKey] !== value) { | ||
198 | obj[lastKey] = value; | ||
199 | changed = true; | ||
200 | } | ||
201 | /* | ||
202 | eslint-enable @typescript-eslint/no-unsafe-assignment, | ||
203 | security/detect-object-injection | ||
204 | */ | ||
205 | }); | ||
206 | if (!changed) { | ||
207 | return; | ||
208 | } | ||
209 | await this.repository.setMissingResourceContents( | ||
210 | language, | ||
211 | namespace, | ||
212 | translations, | ||
213 | ); | ||
214 | log.debug( | ||
215 | 'Wrote', | ||
216 | entries.size, | ||
217 | 'missing', | ||
218 | language, | ||
219 | 'translations for namespace', | ||
220 | namespace, | ||
221 | ); | ||
222 | } | ||
223 | } | ||