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