@adminforth/i18n 1.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.woodpecker/buildRelease.sh +13 -0
- package/.woodpecker/buildSlackNotify.sh +42 -0
- package/.woodpecker/release.yml +45 -0
- package/Changelog.md +71 -0
- package/LICENSE +21 -0
- package/build.log +15 -0
- package/custom/LanguageInUserMenu.vue +102 -0
- package/custom/LanguageUnderLogin.vue +76 -0
- package/custom/langCommon.ts +89 -0
- package/custom/package-lock.json +24 -0
- package/custom/package.json +15 -0
- package/custom/tsconfig.json +19 -0
- package/dist/custom/LanguageInUserMenu.vue +102 -0
- package/dist/custom/LanguageUnderLogin.vue +76 -0
- package/dist/custom/langCommon.ts +89 -0
- package/dist/custom/package-lock.json +24 -0
- package/dist/custom/package.json +15 -0
- package/dist/custom/tsconfig.json +19 -0
- package/dist/index.js +749 -0
- package/dist/types.js +1 -0
- package/index.ts +889 -0
- package/package.json +54 -0
- package/tsconfig.json +12 -0
- package/types.ts +39 -0
package/index.ts
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes } from "adminforth";
|
|
2
|
+
import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction, AdminForthConfigMenuItem } from "adminforth";
|
|
3
|
+
import type { PluginOptions } from './types.js';
|
|
4
|
+
import iso6391, { LanguageCode } from 'iso-639-1';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import fs from 'fs-extra';
|
|
7
|
+
import chokidar from 'chokidar';
|
|
8
|
+
import { AsyncQueue } from '@sapphire/async-queue';
|
|
9
|
+
console.log = (...args) => {
|
|
10
|
+
process.stdout.write(args.join(" ") + "\n");
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const processFrontendMessagesQueue = new AsyncQueue();
|
|
14
|
+
|
|
15
|
+
const SLAVIC_PLURAL_EXAMPLES = {
|
|
16
|
+
uk: 'яблук | Яблуко | Яблука | Яблук', // zero | singular | 2-4 | 5+
|
|
17
|
+
bg: 'ябълки | ябълка | ябълки | ябълки', // zero | singular | 2-4 | 5+
|
|
18
|
+
cs: 'jablek | jablko | jablka | jablek', // zero | singular | 2-4 | 5+
|
|
19
|
+
hr: 'jabuka | jabuka | jabuke | jabuka', // zero | singular | 2-4 | 5+
|
|
20
|
+
mk: 'јаболка | јаболко | јаболка | јаболка', // zero | singular | 2-4 | 5+
|
|
21
|
+
pl: 'jabłek | jabłko | jabłka | jabłek', // zero | singular | 2-4 | 5+
|
|
22
|
+
sk: 'jabĺk | jablko | jablká | jabĺk', // zero | singular | 2-4 | 5+
|
|
23
|
+
sl: 'jabolk | jabolko | jabolka | jabolk', // zero | singular | 2-4 | 5+
|
|
24
|
+
sr: 'јабука | јабука | јабуке | јабука', // zero | singular | 2-4 | 5+
|
|
25
|
+
be: 'яблыкаў | яблык | яблыкі | яблыкаў', // zero | singular | 2-4 | 5+
|
|
26
|
+
ru: 'яблок | яблоко | яблока | яблок', // zero | singular | 2-4 | 5+
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const countryISO31661ByLangISO6391 = {
|
|
30
|
+
en: 'us', // English → United States
|
|
31
|
+
zh: 'cn', // Chinese → China
|
|
32
|
+
hi: 'in', // Hindi → India
|
|
33
|
+
ar: 'sa', // Arabic → Saudi Arabia
|
|
34
|
+
ko: 'kr', // Korean → South Korea
|
|
35
|
+
ja: 'jp', // Japanese → Japan
|
|
36
|
+
uk: 'ua', // Ukrainian → Ukraine
|
|
37
|
+
ur: 'pk', // Urdu → Pakistan
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function getCountryCodeFromLangCode(langCode) {
|
|
41
|
+
return countryISO31661ByLangISO6391[langCode] || langCode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
interface ICachingAdapter {
|
|
46
|
+
get(key: string): Promise<any>;
|
|
47
|
+
set(key: string, value: any): Promise<void>;
|
|
48
|
+
clear(key: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class CachingAdapterMemory implements ICachingAdapter {
|
|
52
|
+
cache: Record<string, any> = {};
|
|
53
|
+
async get(key: string) {
|
|
54
|
+
return this.cache[key];
|
|
55
|
+
}
|
|
56
|
+
async set(key: string, value: any) {
|
|
57
|
+
this.cache[key] = value;
|
|
58
|
+
}
|
|
59
|
+
async clear(key: string) {
|
|
60
|
+
if (this.cache[key]) {
|
|
61
|
+
delete this.cache[key];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ensureTemplateHasAllParams(template, newTemplate) {
|
|
67
|
+
// string ensureTemplateHasAllParams("a {b} c {d}", "я {b} і {d} в") // true
|
|
68
|
+
// string ensureTemplateHasAllParams("a {b} c {d}", "я і {d} в") // false
|
|
69
|
+
// string ensureTemplateHasAllParams("a {b} c {d}", "я {bb} і {d} в") // false
|
|
70
|
+
const existingParams = template.match(/{[^}]+}/g);
|
|
71
|
+
const newParams = newTemplate.match(/{[^}]+}/g);
|
|
72
|
+
const existingParamsSet = new Set(existingParams);
|
|
73
|
+
const newParamsSet = new Set(newParams);
|
|
74
|
+
return existingParamsSet.size === newParamsSet.size && [...existingParamsSet].every(p => newParamsSet.has(p));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class AiTranslateError extends Error {
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = 'AiTranslateError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export default class I18nPlugin extends AdminForthPlugin {
|
|
84
|
+
options: PluginOptions;
|
|
85
|
+
emailField: AdminForthResourceColumn;
|
|
86
|
+
passwordField: AdminForthResourceColumn;
|
|
87
|
+
authResource: AdminForthResource;
|
|
88
|
+
emailConfirmedField?: AdminForthResourceColumn;
|
|
89
|
+
trFieldNames: Partial<Record<LanguageCode, string>>;
|
|
90
|
+
enFieldName: string;
|
|
91
|
+
cache: ICachingAdapter;
|
|
92
|
+
primaryKeyFieldName: string;
|
|
93
|
+
menuItemWithBadgeId: string;
|
|
94
|
+
|
|
95
|
+
adminforth: IAdminForth;
|
|
96
|
+
|
|
97
|
+
// sorted by name list of all supported languages, without en e.g. 'al|ro|uk'
|
|
98
|
+
fullCompleatedFieldValue: string;
|
|
99
|
+
|
|
100
|
+
constructor(options: PluginOptions) {
|
|
101
|
+
super(options, import.meta.url);
|
|
102
|
+
this.options = options;
|
|
103
|
+
this.cache = new CachingAdapterMemory();
|
|
104
|
+
this.trFieldNames = {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async computeCompletedFieldValue(record: any) {
|
|
108
|
+
return this.options.supportedLanguages.reduce((acc: string, lang: LanguageCode): string => {
|
|
109
|
+
if (lang === 'en') {
|
|
110
|
+
return acc;
|
|
111
|
+
}
|
|
112
|
+
if (record[this.trFieldNames[lang]]) {
|
|
113
|
+
if (acc !== '') {
|
|
114
|
+
acc += '|';
|
|
115
|
+
}
|
|
116
|
+
acc += (lang as string);
|
|
117
|
+
}
|
|
118
|
+
return acc;
|
|
119
|
+
}, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async updateUntranslatedMenuBadge() {
|
|
123
|
+
if (this.menuItemWithBadgeId) {
|
|
124
|
+
const resource = this.adminforth.resource(this.resourceConfig.resourceId);
|
|
125
|
+
const count = await resource.count([Filters.NEQ(this.options.completedFieldName, this.fullCompleatedFieldValue)]);
|
|
126
|
+
|
|
127
|
+
this.adminforth.websocket.publish(`/opentopic/update-menu-badge/${this.menuItemWithBadgeId}`, {
|
|
128
|
+
badge: count || null
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
135
|
+
super.modifyResourceConfig(adminforth, resourceConfig);
|
|
136
|
+
|
|
137
|
+
// check each supported language is valid ISO 639-1 code
|
|
138
|
+
this.options.supportedLanguages.forEach((lang) => {
|
|
139
|
+
if (!iso6391.validate(lang)) {
|
|
140
|
+
throw new Error(`Invalid language code ${lang}, please define valid ISO 639-1 language code (2 lowercase letters)`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
// find primary key field
|
|
146
|
+
this.primaryKeyFieldName = resourceConfig.columns.find(c => c.primaryKey)?.name;
|
|
147
|
+
|
|
148
|
+
if (!this.primaryKeyFieldName) {
|
|
149
|
+
throw new Error(`Primary key field not found in resource ${resourceConfig.resourceId}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// parse trFieldNames
|
|
153
|
+
for (const lang of this.options.supportedLanguages) {
|
|
154
|
+
if (lang === 'en') {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (this.options.translationFieldNames?.[lang]) {
|
|
158
|
+
this.trFieldNames[lang] = this.options.translationFieldNames[lang];
|
|
159
|
+
} else {
|
|
160
|
+
this.trFieldNames[lang] = lang + '_string';
|
|
161
|
+
}
|
|
162
|
+
// find column by name
|
|
163
|
+
const column = resourceConfig.columns.find(c => c.name === this.trFieldNames[lang]);
|
|
164
|
+
if (!column) {
|
|
165
|
+
throw new Error(`Field ${this.trFieldNames[lang]} not found for storing translation for language ${lang}
|
|
166
|
+
in resource ${resourceConfig.resourceId}, consider adding it to columns or change trFieldNames option to remap it to existing column`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.enFieldName = this.trFieldNames['en'] || 'en_string';
|
|
171
|
+
|
|
172
|
+
this.fullCompleatedFieldValue = this.options.supportedLanguages.reduce((acc: string, lang: LanguageCode) => {
|
|
173
|
+
if (lang === 'en') {
|
|
174
|
+
return acc;
|
|
175
|
+
}
|
|
176
|
+
if (acc !== '') {
|
|
177
|
+
acc += '|';
|
|
178
|
+
}
|
|
179
|
+
acc += lang as string;
|
|
180
|
+
return acc;
|
|
181
|
+
}, '');
|
|
182
|
+
|
|
183
|
+
// if not enFieldName column is not found, throw error
|
|
184
|
+
const enColumn = resourceConfig.columns.find(c => c.name === this.enFieldName);
|
|
185
|
+
if (!enColumn) {
|
|
186
|
+
throw new Error(`Field ${this.enFieldName} not found column to store english original string in resource ${resourceConfig.resourceId}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
enColumn.editReadonly = true;
|
|
190
|
+
|
|
191
|
+
// if sourceFieldName defined, check it exists
|
|
192
|
+
if (this.options.sourceFieldName) {
|
|
193
|
+
if (!resourceConfig.columns.find(c => c.name === this.options.sourceFieldName)) {
|
|
194
|
+
throw new Error(`Field ${this.options.sourceFieldName} not found in resource ${resourceConfig.resourceId}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// if completedFieldName defined, check it exists and should be string
|
|
199
|
+
if (this.options.completedFieldName) {
|
|
200
|
+
const column = resourceConfig.columns.find(c => c.name === this.options.completedFieldName);
|
|
201
|
+
if (!column) {
|
|
202
|
+
const similar = suggestIfTypo(resourceConfig.columns.map((col) => col.name), this.options.completedFieldName);
|
|
203
|
+
throw new Error(`Field ${this.options.completedFieldName} not found in resource ${resourceConfig.resourceId}${similar ? `Did you mean '${similar}'?` : ''}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// if showIn is not defined, add it as empty
|
|
207
|
+
column.showIn = [];
|
|
208
|
+
|
|
209
|
+
// add virtual field for incomplete
|
|
210
|
+
resourceConfig.columns.unshift({
|
|
211
|
+
name: 'fully_translated',
|
|
212
|
+
label: 'Fully translated',
|
|
213
|
+
virtual: true,
|
|
214
|
+
showIn: ['list', 'show', 'filter'],
|
|
215
|
+
type: AdminForthDataTypes.BOOLEAN,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const compMeta = {
|
|
220
|
+
brandSlug: adminforth.config.customization.brandNameSlug,
|
|
221
|
+
pluginInstanceId: this.pluginInstanceId,
|
|
222
|
+
supportedLanguages: this.options.supportedLanguages.map(lang => (
|
|
223
|
+
{
|
|
224
|
+
code: lang,
|
|
225
|
+
// lang name on on language native name
|
|
226
|
+
name: iso6391.getNativeName(lang),
|
|
227
|
+
}
|
|
228
|
+
))
|
|
229
|
+
};
|
|
230
|
+
// add underLogin component
|
|
231
|
+
(adminforth.config.customization.loginPageInjections.underInputs).push({
|
|
232
|
+
file: this.componentPath('LanguageUnderLogin.vue'),
|
|
233
|
+
meta: compMeta
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
(adminforth.config.customization.globalInjections.userMenu).push({
|
|
237
|
+
file: this.componentPath('LanguageInUserMenu.vue'),
|
|
238
|
+
meta: compMeta
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// disable create allowedActions for translations
|
|
242
|
+
resourceConfig.options.allowedActions.create = false;
|
|
243
|
+
|
|
244
|
+
// add hook to validate user did not screw up with template params
|
|
245
|
+
resourceConfig.hooks.edit.beforeSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord?: any }): Promise<{ ok: boolean, error?: string }> => {
|
|
246
|
+
for (const lang of this.options.supportedLanguages) {
|
|
247
|
+
if (lang === 'en') {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (updates[this.trFieldNames[lang]]) { // if user set '', it will have '' in updates, then it is fine, we shoudl nto check it
|
|
251
|
+
if (!ensureTemplateHasAllParams(oldRecord[this.enFieldName], updates[this.trFieldNames[lang]])) {
|
|
252
|
+
return { ok: false, error: `Template params mismatch for ${updates[this.enFieldName]}. Template param names should be the same as in original string. E. g. 'Hello {name}', should be 'Hola {name}' and not 'Hola {nombre}'!` };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { ok: true };
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// add hook on edit of any translation
|
|
260
|
+
resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord?: any }): Promise<{ ok: boolean, error?: string }> => {
|
|
261
|
+
if (oldRecord) {
|
|
262
|
+
// find lang which changed
|
|
263
|
+
let langsChanged: LanguageCode[] = [];
|
|
264
|
+
for (const lang of this.options.supportedLanguages) {
|
|
265
|
+
if (lang === 'en') {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (updates[this.trFieldNames[lang]] !== undefined) {
|
|
269
|
+
langsChanged.push(lang);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// clear frontend cache for all langsChanged
|
|
274
|
+
for (const lang of langsChanged) {
|
|
275
|
+
this.cache.clear(`${this.resourceConfig.resourceId}:${oldRecord[this.options.categoryFieldName]}:${lang}`);
|
|
276
|
+
this.cache.clear(`${this.resourceConfig.resourceId}:${oldRecord[this.options.categoryFieldName]}:${lang}:${oldRecord[this.enFieldName]}`);
|
|
277
|
+
}
|
|
278
|
+
this.updateUntranslatedMenuBadge();
|
|
279
|
+
}
|
|
280
|
+
// clear frontend cache for all lan
|
|
281
|
+
return { ok: true };
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// add hook on delete of any translation to reset cache
|
|
285
|
+
resourceConfig.hooks.delete.afterSave.push(async ({ record }: { record: any }): Promise<{ ok: boolean, error?: string }> => {
|
|
286
|
+
for (const lang of this.options.supportedLanguages) {
|
|
287
|
+
// if frontend, clear frontend cache
|
|
288
|
+
this.cache.clear(`${this.resourceConfig.resourceId}:${record[this.options.categoryFieldName]}:${lang}`);
|
|
289
|
+
this.cache.clear(`${this.resourceConfig.resourceId}:${record[this.options.categoryFieldName]}:${lang}:${record[this.enFieldName]}`);
|
|
290
|
+
}
|
|
291
|
+
this.updateUntranslatedMenuBadge();
|
|
292
|
+
return { ok: true };
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (this.options.completedFieldName) {
|
|
296
|
+
// on show and list add a list hook which will add incomplete field to record if translation is missing for at least one language
|
|
297
|
+
const addIncompleteField = (record: any) => {
|
|
298
|
+
// form list of all langs, sorted by alphabet, without en, to get 'al|ro|uk'
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
record.fully_translated = this.fullCompleatedFieldValue === record[this.options.completedFieldName];
|
|
302
|
+
}
|
|
303
|
+
resourceConfig.hooks.list.afterDatasourceResponse.push(async ({ response }: { response: any[] }): Promise<{ ok: boolean, error?: string }> => {
|
|
304
|
+
response.forEach(addIncompleteField);
|
|
305
|
+
return { ok: true }
|
|
306
|
+
});
|
|
307
|
+
resourceConfig.hooks.show.afterDatasourceResponse.push(async ({ response }: { response: any }): Promise<{ ok: boolean, error?: string }> => {
|
|
308
|
+
addIncompleteField(response.length && response[0]);
|
|
309
|
+
return { ok: true }
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// also add edit hook beforeSave to update completedFieldName
|
|
313
|
+
resourceConfig.hooks.edit.beforeSave.push(async ({ record, oldRecord }: { record: any, oldRecord: any }): Promise<{ ok: boolean, error?: string }> => {
|
|
314
|
+
const futureRecord = { ...oldRecord, ...record };
|
|
315
|
+
|
|
316
|
+
const futureCompletedFieldValue = await this.computeCompletedFieldValue(futureRecord);
|
|
317
|
+
|
|
318
|
+
record[this.options.completedFieldName] = futureCompletedFieldValue;
|
|
319
|
+
return { ok: true };
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// add list hook to support filtering by fully_translated virtual field
|
|
323
|
+
resourceConfig.hooks.list.beforeDatasourceRequest.push(async ({ query }: { query: any }): Promise<{ ok: boolean, error?: string }> => {
|
|
324
|
+
if (!query.filters || query.filters.length === 0) {
|
|
325
|
+
query.filters = [];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// get fully_translated field from filter if it is there
|
|
329
|
+
const fullyTranslatedFilter = query.filters.find((f: any) => f.field === 'fully_translated');
|
|
330
|
+
if (fullyTranslatedFilter) {
|
|
331
|
+
// remove it from filters because it is virtual field
|
|
332
|
+
query.filters = query.filters.filter((f: any) => f.field !== 'fully_translated');
|
|
333
|
+
if (fullyTranslatedFilter.value[0]) {
|
|
334
|
+
query.filters.push({
|
|
335
|
+
field: this.options.completedFieldName,
|
|
336
|
+
value: this.fullCompleatedFieldValue,
|
|
337
|
+
operator: 'eq',
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
query.filters.push({
|
|
341
|
+
field: this.options.completedFieldName,
|
|
342
|
+
value: this.fullCompleatedFieldValue,
|
|
343
|
+
operator: 'ne',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { ok: true };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// add bulk action
|
|
353
|
+
if (!resourceConfig.options.bulkActions) {
|
|
354
|
+
resourceConfig.options.bulkActions = [];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (this.options.completeAdapter) {
|
|
358
|
+
resourceConfig.options.bulkActions.push(
|
|
359
|
+
{
|
|
360
|
+
id: 'translate_all',
|
|
361
|
+
label: 'Translate selected',
|
|
362
|
+
icon: 'flowbite:language-outline',
|
|
363
|
+
// if optional `confirm` is provided, user will be asked to confirm action
|
|
364
|
+
confirm: 'Are you sure you want to translate selected items?',
|
|
365
|
+
state: 'selected',
|
|
366
|
+
allowed: async ({ resource, adminUser, selectedIds, allowedActions }) => {
|
|
367
|
+
console.log('allowedActions', JSON.stringify(allowedActions));
|
|
368
|
+
return allowedActions.edit;
|
|
369
|
+
},
|
|
370
|
+
action: async ({ selectedIds, tr }) => {
|
|
371
|
+
let translatedCount = 0;
|
|
372
|
+
try {
|
|
373
|
+
translatedCount = await this.bulkTranslate({ selectedIds });
|
|
374
|
+
} catch (e) {
|
|
375
|
+
process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
|
|
376
|
+
if (e instanceof AiTranslateError) {
|
|
377
|
+
return { ok: false, error: e.message };
|
|
378
|
+
}
|
|
379
|
+
throw e;
|
|
380
|
+
}
|
|
381
|
+
this.updateUntranslatedMenuBadge();
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
error: undefined,
|
|
385
|
+
successMessage: await tr(`Translated {count} items`, 'backend', {
|
|
386
|
+
count: translatedCount,
|
|
387
|
+
}),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// if there is menu item with resourceId, add .badge function showing number of untranslated strings
|
|
395
|
+
const addBadgeCountToMenuItem = (menuItem: AdminForthConfigMenuItem) => {
|
|
396
|
+
this.menuItemWithBadgeId = menuItem.itemId;
|
|
397
|
+
menuItem.badge = async () => {
|
|
398
|
+
const resource = adminforth.resource(menuItem.resourceId);
|
|
399
|
+
const count = await resource.count([Filters.NEQ(this.options.completedFieldName, this.fullCompleatedFieldValue)]);
|
|
400
|
+
return count ? `${count}` : null;
|
|
401
|
+
};
|
|
402
|
+
menuItem.badgeTooltip = 'Untranslated count';
|
|
403
|
+
}
|
|
404
|
+
adminforth.config.menu.forEach((menuItem) => {
|
|
405
|
+
if (menuItem.resourceId === resourceConfig.resourceId) {
|
|
406
|
+
addBadgeCountToMenuItem(menuItem);
|
|
407
|
+
}
|
|
408
|
+
if (menuItem.children) {
|
|
409
|
+
menuItem.children.forEach((child) => {
|
|
410
|
+
if (child.resourceId === resourceConfig.resourceId) {
|
|
411
|
+
addBadgeCountToMenuItem(child);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async translateToLang (
|
|
419
|
+
langIsoCode: LanguageCode,
|
|
420
|
+
strings: { en_string: string, category: string }[],
|
|
421
|
+
plurals=false,
|
|
422
|
+
translations: any[],
|
|
423
|
+
updateStrings: Record<string, { updates: any, category: string, strId: string, enStr: string, translatedStr: string }> = {}
|
|
424
|
+
): Promise<string[]> {
|
|
425
|
+
const maxKeysInOneReq = 10;
|
|
426
|
+
if (strings.length === 0) {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (strings.length > maxKeysInOneReq) {
|
|
431
|
+
let totalTranslated = [];
|
|
432
|
+
for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
|
|
433
|
+
const slicedStrings = strings.slice(i, i + maxKeysInOneReq);
|
|
434
|
+
process.env.HEAVY_DEBUG && console.log('🪲🔪slicedStrings len', slicedStrings.length);
|
|
435
|
+
const madeKeys = await this.translateToLang(langIsoCode, slicedStrings, plurals, translations, updateStrings);
|
|
436
|
+
totalTranslated = totalTranslated.concat(madeKeys);
|
|
437
|
+
}
|
|
438
|
+
return totalTranslated;
|
|
439
|
+
}
|
|
440
|
+
const lang = langIsoCode;
|
|
441
|
+
const langName = iso6391.getName(lang);
|
|
442
|
+
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(lang) && plurals;
|
|
443
|
+
|
|
444
|
+
const prompt = `
|
|
445
|
+
I need to translate strings in JSON to ${lang} (${langName}) language from English for my web app.
|
|
446
|
+
${requestSlavicPlurals ? `You should provide 4 slavic forms (in format "zero count | singular count | 2-4 | 5+") e.g. "apple | apples" should become "${SLAVIC_PLURAL_EXAMPLES[lang]}"` : ''}
|
|
447
|
+
Keep keys, as is, write translation into values! Here are the strings:
|
|
448
|
+
|
|
449
|
+
\`\`\`json
|
|
450
|
+
${
|
|
451
|
+
JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object => {
|
|
452
|
+
acc[s.en_string] = '';
|
|
453
|
+
return acc;
|
|
454
|
+
}, {}), null, 2)
|
|
455
|
+
}
|
|
456
|
+
\`\`\`
|
|
457
|
+
`;
|
|
458
|
+
|
|
459
|
+
// call OpenAI
|
|
460
|
+
const resp = await this.options.completeAdapter.complete(
|
|
461
|
+
prompt,
|
|
462
|
+
[],
|
|
463
|
+
300,
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
if (resp.error) {
|
|
468
|
+
throw new AiTranslateError(resp.error);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// parse response like
|
|
472
|
+
// Here are the translations for the strings you provided:
|
|
473
|
+
// ```json
|
|
474
|
+
// [{"live": "canlı"}, {"Table Games": "Masa Oyunları"}]
|
|
475
|
+
// ```
|
|
476
|
+
let res;
|
|
477
|
+
try {
|
|
478
|
+
res = resp.content.split("```json")[1].split("```")[0];
|
|
479
|
+
} catch (e) {
|
|
480
|
+
console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
res = JSON.parse(res);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
console.error(`Error in parsing LLM resp json: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
for (const [enStr, translatedStr] of Object.entries(res) as [string, string][]) {
|
|
493
|
+
const translationsTargeted = translations.filter(t => t[this.enFieldName] === enStr);
|
|
494
|
+
// might be several with same en_string
|
|
495
|
+
for (const translation of translationsTargeted) {
|
|
496
|
+
//translation[this.trFieldNames[lang]] = translatedStr;
|
|
497
|
+
// process.env.HEAVY_DEBUG && console.log(`🪲translated to ${lang} ${translation.en_string}, ${translatedStr}`)
|
|
498
|
+
if (!updateStrings[translation[this.primaryKeyFieldName]]) {
|
|
499
|
+
|
|
500
|
+
updateStrings[translation[this.primaryKeyFieldName]] = {
|
|
501
|
+
updates: {},
|
|
502
|
+
translatedStr,
|
|
503
|
+
enStr,
|
|
504
|
+
category: translation[this.options.categoryFieldName],
|
|
505
|
+
strId: translation[this.primaryKeyFieldName],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
// make sure LLM did not screw up with template params
|
|
509
|
+
if (translation[this.enFieldName].includes('{') && !ensureTemplateHasAllParams(translation[this.enFieldName], translatedStr)) {
|
|
510
|
+
console.warn(`LLM Screwed up with template params mismatch for "${translation[this.enFieldName]}"on language ${lang}, it returned "${translatedStr}"`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
updateStrings[
|
|
514
|
+
translation[this.primaryKeyFieldName]
|
|
515
|
+
].updates[this.trFieldNames[lang]] = translatedStr;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return Object.keys(updateStrings);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// returns translated count
|
|
523
|
+
async bulkTranslate({ selectedIds }: { selectedIds: string[] }): Promise<number> {
|
|
524
|
+
|
|
525
|
+
const needToTranslateByLang : Partial<
|
|
526
|
+
Record<
|
|
527
|
+
LanguageCode,
|
|
528
|
+
{
|
|
529
|
+
en_string: string;
|
|
530
|
+
category: string;
|
|
531
|
+
}[]
|
|
532
|
+
>
|
|
533
|
+
> = {};
|
|
534
|
+
|
|
535
|
+
const translations = await this.adminforth.resource(this.resourceConfig.resourceId).list(Filters.IN(this.primaryKeyFieldName, selectedIds));
|
|
536
|
+
|
|
537
|
+
for (const lang of this.options.supportedLanguages) {
|
|
538
|
+
if (lang === 'en') {
|
|
539
|
+
// all strings are in English, no need to translate
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
for (const translation of translations) {
|
|
543
|
+
if (!translation[this.trFieldNames[lang]]) {
|
|
544
|
+
if (!needToTranslateByLang[lang]) {
|
|
545
|
+
needToTranslateByLang[lang] = [];
|
|
546
|
+
}
|
|
547
|
+
needToTranslateByLang[lang].push({
|
|
548
|
+
'en_string': translation[this.enFieldName],
|
|
549
|
+
category: translation[this.options.categoryFieldName],
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const updateStrings: Record<string, {
|
|
556
|
+
updates: any,
|
|
557
|
+
category: string,
|
|
558
|
+
strId: string,
|
|
559
|
+
enStr: string,
|
|
560
|
+
translatedStr: string
|
|
561
|
+
}> = {};
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
const langsInvolved = new Set(Object.keys(needToTranslateByLang));
|
|
565
|
+
|
|
566
|
+
let totalTranslated = [];
|
|
567
|
+
|
|
568
|
+
await Promise.all(
|
|
569
|
+
Object.entries(needToTranslateByLang).map(
|
|
570
|
+
async ([lang, strings]: [LanguageCode, { en_string: string, category: string }[]]) => {
|
|
571
|
+
// first translate without plurals
|
|
572
|
+
const stringsWithoutPlurals = strings.filter(s => !s.en_string.includes('|'));
|
|
573
|
+
const noPluralKeys = await this.translateToLang(lang, stringsWithoutPlurals, false, translations, updateStrings);
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
const stringsWithPlurals = strings.filter(s => s.en_string.includes('|'));
|
|
577
|
+
|
|
578
|
+
const pluralKeys = await this.translateToLang(lang, stringsWithPlurals, true, translations, updateStrings);
|
|
579
|
+
|
|
580
|
+
totalTranslated = totalTranslated.concat(noPluralKeys, pluralKeys);
|
|
581
|
+
}
|
|
582
|
+
)
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
await Promise.all(
|
|
586
|
+
Object.entries(updateStrings).map(
|
|
587
|
+
async ([_, { updates, strId }]: [string, { updates: any, category: string, strId: string }]) => {
|
|
588
|
+
// get old full record
|
|
589
|
+
const oldRecord = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.primaryKeyFieldName, strId)]);
|
|
590
|
+
|
|
591
|
+
// because this will translate all languages, we can set completedLangs to all languages
|
|
592
|
+
const futureCompletedFieldValue = await this.computeCompletedFieldValue({ ...oldRecord, ...updates });
|
|
593
|
+
|
|
594
|
+
await this.adminforth.resource(this.resourceConfig.resourceId).update(strId, {
|
|
595
|
+
...updates,
|
|
596
|
+
[this.options.completedFieldName]: futureCompletedFieldValue,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
for (const lang of langsInvolved) {
|
|
603
|
+
await this.cache.clear(`${this.resourceConfig.resourceId}:frontend:${lang}`);
|
|
604
|
+
for (const { enStr, category } of Object.values(updateStrings)) {
|
|
605
|
+
await this.cache.clear(`${this.resourceConfig.resourceId}:${category}:${lang}:${enStr}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return new Set(totalTranslated).size;
|
|
610
|
+
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async processExtractedMessages(adminforth: IAdminForth, filePath: string) {
|
|
614
|
+
await processFrontendMessagesQueue.wait();
|
|
615
|
+
// messages file is in i18n-messages.json
|
|
616
|
+
let messages;
|
|
617
|
+
try {
|
|
618
|
+
messages = await fs.readJson(filePath);
|
|
619
|
+
process.env.HEAVY_DEBUG && console.info('🐛 Messages file found');
|
|
620
|
+
|
|
621
|
+
} catch (e) {
|
|
622
|
+
process.env.HEAVY_DEBUG && console.error('🐛 Messages file not yet exists, probably npm run i18n:extract not finished/started yet, might be ok');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// loop over missingKeys[i].path and add them to database if not exists
|
|
626
|
+
const messagesForFeed = messages.missingKeys.map((mk) => {
|
|
627
|
+
return {
|
|
628
|
+
en_string: mk.path,
|
|
629
|
+
source: mk.file,
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
await this.feedCategoryTranslations(messagesForFeed, 'frontend')
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
async tryProcessAndWatch(adminforth: IAdminForth) {
|
|
640
|
+
const serveDir = adminforth.codeInjector.getServeDir();
|
|
641
|
+
// messages file is in i18n-messages.json
|
|
642
|
+
const messagesFile = path.join(serveDir, 'i18n-messages.json');
|
|
643
|
+
process.env.HEAVY_DEBUG && console.log('🪲🔔messagesFile read started', messagesFile);
|
|
644
|
+
this.processExtractedMessages(adminforth, messagesFile);
|
|
645
|
+
// we use watcher because file can't be yet created when we start - bundleNow can be done in build time or can be done now
|
|
646
|
+
// that is why we make attempt to process it now and then watch for changes
|
|
647
|
+
const w = chokidar.watch(messagesFile, {
|
|
648
|
+
persistent: true,
|
|
649
|
+
ignoreInitial: true, // don't trigger 'add' event for existing file on start
|
|
650
|
+
});
|
|
651
|
+
w.on('change', () => {
|
|
652
|
+
process.env.HEAVY_DEBUG && console.log('🪲🔔messagesFile change', messagesFile);
|
|
653
|
+
this.processExtractedMessages(adminforth, messagesFile);
|
|
654
|
+
});
|
|
655
|
+
w.on('add', () => {
|
|
656
|
+
process.env.HEAVY_DEBUG && console.log('🪲🔔messagesFile add', messagesFile);
|
|
657
|
+
this.processExtractedMessages(adminforth, messagesFile);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
663
|
+
// optional method where you can safely check field types after database discovery was performed
|
|
664
|
+
// ensure each trFieldName (apart from enFieldName) is nullable column of type string
|
|
665
|
+
if (this.options.completeAdapter) {
|
|
666
|
+
this.options.completeAdapter.validate();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const lang of this.options.supportedLanguages) {
|
|
670
|
+
if (lang === 'en') {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const column = resourceConfig.columns.find(c => c.name === this.trFieldNames[lang]);
|
|
674
|
+
if (!column) {
|
|
675
|
+
throw new Error(`Field ${this.trFieldNames[lang]} not found for storing translation for language ${lang}
|
|
676
|
+
in resource ${resourceConfig.resourceId}, consider adding it to columns or change trFieldNames option to remap it to existing column`);
|
|
677
|
+
}
|
|
678
|
+
if (column.required.create || column.required.edit) {
|
|
679
|
+
throw new Error(`Field ${this.trFieldNames[lang]} should be not required in resource ${resourceConfig.resourceId}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ensure categoryFieldName defined and is string
|
|
684
|
+
if (!this.options.categoryFieldName) {
|
|
685
|
+
throw new Error(`categoryFieldName option is not defined. It is used to categorize translations and return only specific category e.g. to frontend`);
|
|
686
|
+
}
|
|
687
|
+
const categoryColumn = resourceConfig.columns.find(c => c.name === this.options.categoryFieldName);
|
|
688
|
+
if (!categoryColumn) {
|
|
689
|
+
throw new Error(`Field ${this.options.categoryFieldName} not found in resource ${resourceConfig.resourceId}`);
|
|
690
|
+
}
|
|
691
|
+
if (categoryColumn.type !== AdminForthDataTypes.STRING && categoryColumn.type !== AdminForthDataTypes.TEXT) {
|
|
692
|
+
throw new Error(`Field ${this.options.categoryFieldName} should be of type string in resource ${resourceConfig.resourceId}, but it is ${categoryColumn.type}`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// in this plugin we will use plugin to fill the database with missing language messages
|
|
696
|
+
this.tryProcessAndWatch(adminforth);
|
|
697
|
+
|
|
698
|
+
adminforth.tr = async (msg: string | null | undefined, category: string, lang: string, params, pluralizationNumber: number): Promise<string> => {
|
|
699
|
+
if (!msg) {
|
|
700
|
+
return msg;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (category === 'frontend') {
|
|
704
|
+
throw new Error(`Category 'frontend' is reserved for frontend messages, use any other category for backend messages`);
|
|
705
|
+
}
|
|
706
|
+
// console.log('🪲tr', msg, category, lang);
|
|
707
|
+
|
|
708
|
+
// if lang is not supported , throw
|
|
709
|
+
if (!this.options.supportedLanguages.includes(lang as LanguageCode)) {
|
|
710
|
+
lang = 'en'; // for now simply fallback to english
|
|
711
|
+
|
|
712
|
+
// throwing like line below might be too strict, e.g. for custom apis made with fetch which don't pass accept-language
|
|
713
|
+
// throw new Error(`Language ${lang} is not entered to be supported by requested by browser in request headers accept-language`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
let result;
|
|
717
|
+
// try to get translation from cache
|
|
718
|
+
const cacheKey = `${resourceConfig.resourceId}:${category}:${lang}:${msg}`;
|
|
719
|
+
const cached = await this.cache.get(cacheKey);
|
|
720
|
+
if (cached) {
|
|
721
|
+
result = cached;
|
|
722
|
+
}
|
|
723
|
+
if (!result) {
|
|
724
|
+
const resource = adminforth.resource(resourceConfig.resourceId);
|
|
725
|
+
const translation = await resource.get([Filters.EQ(this.enFieldName, msg), Filters.EQ(this.options.categoryFieldName, category)]);
|
|
726
|
+
if (!translation) {
|
|
727
|
+
await resource.create({
|
|
728
|
+
[this.enFieldName]: msg,
|
|
729
|
+
[this.options.categoryFieldName]: category,
|
|
730
|
+
});
|
|
731
|
+
this.updateUntranslatedMenuBadge();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// do this check here, to faster register missing translations
|
|
735
|
+
// also not cache it - no sense to cache english strings
|
|
736
|
+
if (lang === 'en') {
|
|
737
|
+
// set to cache to return faster next time
|
|
738
|
+
result = msg;
|
|
739
|
+
} else {
|
|
740
|
+
result = translation?.[this.trFieldNames[lang]];
|
|
741
|
+
if (!result) {
|
|
742
|
+
// return english
|
|
743
|
+
result = msg;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// cache so even if key does not exist, we will not hit database
|
|
747
|
+
await this.cache.set(cacheKey, result);
|
|
748
|
+
}
|
|
749
|
+
// if msg has '|' in it, then we need to aplly pluralization
|
|
750
|
+
if (msg.includes('|')) {
|
|
751
|
+
result = this.applyPluralization(result, pluralizationNumber, lang);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (params) {
|
|
755
|
+
for (const [key, value] of Object.entries(params)) {
|
|
756
|
+
result = result.replace(`{${key}}`, value);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
applyPluralization(str: string, number: number, lang: string): string {
|
|
764
|
+
const choices = str.split('|');
|
|
765
|
+
const choicesLength = choices.length;
|
|
766
|
+
|
|
767
|
+
if (choicesLength > 2) {
|
|
768
|
+
// this is slavic pluralization
|
|
769
|
+
return choices[this.slavicPluralRule(number, choicesLength)];
|
|
770
|
+
} else {
|
|
771
|
+
return number === 1 ? choices[0] : choices[1];
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// taken from here https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization
|
|
776
|
+
slavicPluralRule(choice, choicesLength) {
|
|
777
|
+
if (choice === 0) {
|
|
778
|
+
return 0
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const teen = choice > 10 && choice < 20
|
|
782
|
+
const endsWithOne = choice % 10 === 1
|
|
783
|
+
|
|
784
|
+
if (!teen && endsWithOne) {
|
|
785
|
+
return 1
|
|
786
|
+
}
|
|
787
|
+
if (!teen && choice % 10 >= 2 && choice % 10 <= 4) {
|
|
788
|
+
return 2
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return choicesLength < 4 ? 2 : 3
|
|
792
|
+
}
|
|
793
|
+
instanceUniqueRepresentation(pluginOptions: any) : string {
|
|
794
|
+
// optional method to return unique string representation of plugin instance.
|
|
795
|
+
// Needed if plugin can have multiple instances on one resource
|
|
796
|
+
return `single`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async getCategoryTranslations(category: string, lang: string): Promise<Record<string, string>> {
|
|
800
|
+
const resource = this.adminforth.resource(this.resourceConfig.resourceId);
|
|
801
|
+
const cacheKey = `${this.resourceConfig.resourceId}:${category}:${lang}`;
|
|
802
|
+
const cached = await this.cache.get(cacheKey);
|
|
803
|
+
if (cached) {
|
|
804
|
+
return cached;
|
|
805
|
+
}
|
|
806
|
+
const translations = {};
|
|
807
|
+
const allTranslations = await resource.list([Filters.EQ(this.options.categoryFieldName, category)]);
|
|
808
|
+
for (const tr of allTranslations) {
|
|
809
|
+
translations[tr[this.enFieldName]] = tr[this.trFieldNames[lang]];
|
|
810
|
+
}
|
|
811
|
+
await this.cache.set(cacheKey, translations);
|
|
812
|
+
return translations;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async languagesList(): Promise<{
|
|
816
|
+
code: LanguageCode;
|
|
817
|
+
nameOnNative: string;
|
|
818
|
+
nameEnglish: string;
|
|
819
|
+
emojiFlag: string;
|
|
820
|
+
}[]> {
|
|
821
|
+
return this.options.supportedLanguages.map((lang) => {
|
|
822
|
+
return {
|
|
823
|
+
code: lang as LanguageCode,
|
|
824
|
+
nameOnNative: iso6391.getNativeName(lang),
|
|
825
|
+
nameEnglish: iso6391.getName(lang),
|
|
826
|
+
emojiFlag: getCountryCodeFromLangCode(lang).toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)),
|
|
827
|
+
};
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async feedCategoryTranslations(messages: {
|
|
832
|
+
en_string: string;
|
|
833
|
+
source: string;
|
|
834
|
+
}[], category: string): Promise<void> {
|
|
835
|
+
const adminforth = this.adminforth;
|
|
836
|
+
const missingKeysDeduplicated = messages.reduce((acc: any[], missingKey: any) => {
|
|
837
|
+
if (!acc.find((a) => a.en_string === missingKey.en_string)) {
|
|
838
|
+
acc.push(missingKey);
|
|
839
|
+
}
|
|
840
|
+
return acc;
|
|
841
|
+
}, []);
|
|
842
|
+
|
|
843
|
+
await Promise.all(missingKeysDeduplicated.map(async (missingKey: any) => {
|
|
844
|
+
const key = missingKey.en_string;
|
|
845
|
+
const source = missingKey.source;
|
|
846
|
+
const exists = await adminforth.resource(this.resourceConfig.resourceId).count([
|
|
847
|
+
Filters.EQ(this.enFieldName, key), Filters.EQ(this.options.categoryFieldName, category)
|
|
848
|
+
]);
|
|
849
|
+
if (exists) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (!key) {
|
|
853
|
+
console.error(`Faced an empty key in feeding ${category} messages, source ${source}`);
|
|
854
|
+
}
|
|
855
|
+
const record = {
|
|
856
|
+
[this.enFieldName]: key,
|
|
857
|
+
[this.options.categoryFieldName]: category,
|
|
858
|
+
...(this.options.sourceFieldName ? { [this.options.sourceFieldName]: source } : {}),
|
|
859
|
+
};
|
|
860
|
+
try {
|
|
861
|
+
await adminforth.resource(this.resourceConfig.resourceId).create(record);
|
|
862
|
+
} catch (e) {
|
|
863
|
+
console.error('🐛 Error creating record', e);
|
|
864
|
+
}
|
|
865
|
+
}));
|
|
866
|
+
|
|
867
|
+
// updateBadge
|
|
868
|
+
this.updateUntranslatedMenuBadge();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
setupEndpoints(server: IHttpServer) {
|
|
873
|
+
server.endpoint({
|
|
874
|
+
method: 'GET',
|
|
875
|
+
path: `/plugin/${this.pluginInstanceId}/frontend_messages`,
|
|
876
|
+
noAuth: true,
|
|
877
|
+
handler: async ({ query }) => {
|
|
878
|
+
const lang = query.lang;
|
|
879
|
+
|
|
880
|
+
// form map of translations
|
|
881
|
+
const translations = await this.getCategoryTranslations('frontend', lang);
|
|
882
|
+
return translations;
|
|
883
|
+
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
}
|