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