@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/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
+ }