aboutsummaryrefslogtreecommitdiffstats
path: root/packages/main/src/i18n/RepositoryBasedI18nBackend.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/main/src/i18n/RepositoryBasedI18nBackend.ts')
-rw-r--r--packages/main/src/i18n/RepositoryBasedI18nBackend.ts223
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
21import type {
22 BackendModule,
23 InitOptions,
24 ReadCallback,
25 Services,
26} from 'i18next';
27import { debounce } from 'lodash-es';
28
29import getLogger from '../utils/getLogger.js';
30
31import type LocatlizationRepository from './LocalizationRepository.js';
32
33const MISSING_ENTRIES_DEBOUNCE_TIME_MS = 1000;
34
35const 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 */
45export 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}