@adminforth/i18n 1.8.1 → 1.9.0

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/build.log CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  sending incremental file list
6
6
  custom/
7
+ custom/BulkActionButton.vue
7
8
  custom/LanguageEveryPageLoader.vue
8
9
  custom/LanguageInUserMenu.vue
9
10
  custom/LanguageUnderLogin.vue
@@ -17,5 +18,5 @@ custom/package-lock.json
17
18
  custom/package.json
18
19
  custom/tsconfig.json
19
20
 
20
- sent 33,163 bytes received 248 bytes 66,822.00 bytes/sec
21
- total size is 32,230 speedup is 0.96
21
+ sent 38,848 bytes received 267 bytes 78,230.00 bytes/sec
22
+ total size is 37,840 speedup is 0.97
@@ -0,0 +1,132 @@
1
+ <template>
2
+ <Dialog
3
+ class="w-[500px]"
4
+ :buttons="[
5
+ {
6
+ label: 'Translate',
7
+ onclick: (dialog) => { runTranslation(); dialog.hide(); } ,
8
+ options: {
9
+ disabled: noneChecked
10
+ }
11
+ },
12
+ {
13
+ label: 'Close',
14
+ options: {
15
+ class: 'bg-white hover:!bg-gray-100 !text-gray-900 hover:!text-gray-800 dark:!bg-gray-800 dark:!text-gray-100 dark:hover:!bg-gray-700 !border-gray-200 dark:!border-gray-600'
16
+ },
17
+ onclick: (dialog) => dialog.hide()
18
+ },
19
+ ]"
20
+ >
21
+ <template #trigger>
22
+ <button
23
+ v-if="checkboxes.length > 0"
24
+ class="flex gap-1 items-center py-1 px-3 text-sm font-medium text-lightListViewButtonText focus:outline-none bg-lightListViewButtonBackground rounded-default border border-lightListViewButtonBorder hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover focus:z-10 focus:ring-4 focus:ring-lightListViewButtonFocusRing dark:focus:ring-darkListViewButtonFocusRing dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:border-darkListViewButtonBorder dark:hover:text-darkListViewButtonTextHover dark:hover:bg-darkListViewButtonBackgroundHover"
25
+ >
26
+ <IconLanguageOutline class="w-5 h-5" />
27
+ {{ t('Translate Selected') }} {{ `(${checkboxes.length})` }}
28
+ <div class="text-white bg-gradient-to-r from-purple-500 via-purple-600 to-purple-700 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-purple-300 dark:focus:ring-purple-800
29
+ font-medium rounded-sm text-xs px-1 ml-1 text-center ">
30
+ AI
31
+ </div>
32
+ </button>
33
+ </template>
34
+
35
+ <div class="grid grid-cols-2 gap-4 w-full">
36
+ <Button @click="selectAll" :disabled="allChecked">{{ t('Select All') }}</Button>
37
+ <Button @click="uncheckAll" :disabled="noneChecked">{{ t('Uncheck All') }}</Button>
38
+ <div class="col-span-2 grid grid-cols-3 gap-4 ">
39
+ <div class="group flex items-center justify-between cursor-pointer" v-for="(index, lang) in checkedLanguages" :key="index" @click="toggleLanguage(lang)">
40
+ <div class="flex items-center gap-2">
41
+ <Checkbox v-model="checkedLanguages[lang]" />
42
+ <span class="flag-icon"
43
+ :class="`flag-icon-${getCountryCodeFromLangCode(lang)}`"
44
+ ></span>
45
+ <span class="group-hover:underline">{{ getName(getCountryCodeFromLangCode(lang)) }}</span>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </Dialog>
51
+
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ import { IconLanguageOutline } from '@iconify-prerendered/vue-flowbite';
56
+ import { useI18n } from 'vue-i18n';
57
+ import { Dialog, Button, Checkbox } from '@/afcl';
58
+ import { computed, onMounted, ref, watch } from 'vue';
59
+ import { callAdminForthApi } from '@/utils';
60
+ import { useAdminforth } from '@/adminforth';
61
+ import { getCountryCodeFromLangCode } from './langCommon';
62
+ import { getName, overwrite } from 'country-list';
63
+
64
+ const { t } = useI18n();
65
+ const adminforth = useAdminforth();
66
+
67
+ overwrite([{
68
+ code: 'US',
69
+ name: 'USA'
70
+ }]);
71
+ const props = defineProps<{
72
+ resource: Record<string, any>;
73
+ checkboxes: string[];
74
+ adminUser: Record<string, any>;
75
+ meta: {
76
+ supportedLanguages: string[];
77
+ pluginInstanceId: string;
78
+ };
79
+ clearCheckboxes: () => void;
80
+ }>();
81
+
82
+ const checkedLanguages = ref<Record<string, boolean>>({});
83
+ const allChecked = computed(() => Object.values(checkedLanguages.value).every(Boolean));
84
+ const noneChecked = computed(() => Object.values(checkedLanguages.value).every(value => !value));
85
+
86
+ onMounted(() => {
87
+ for (const lang of props.meta.supportedLanguages) {
88
+ checkedLanguages.value[lang] = true;
89
+ }
90
+ });
91
+
92
+ function selectAll() {
93
+ for (const lang of props.meta.supportedLanguages) {
94
+ checkedLanguages.value[lang] = true;
95
+ }
96
+ }
97
+
98
+ function uncheckAll() {
99
+ for (const lang of props.meta.supportedLanguages) {
100
+ checkedLanguages.value[lang] = false;
101
+ }
102
+ }
103
+
104
+ function toggleLanguage(lang: string) {
105
+ checkedLanguages.value[lang] = !checkedLanguages.value[lang];
106
+ }
107
+
108
+ async function runTranslation() {
109
+ try {
110
+ const res = await callAdminForthApi({
111
+ path: `/plugin/${props.meta.pluginInstanceId}/translate-selected-to-languages`,
112
+ method: 'POST',
113
+ body: {
114
+ selectedIds: props.checkboxes,
115
+ selectedLanguages: Object.keys(checkedLanguages.value).filter(lang => checkedLanguages.value[lang]),
116
+ },
117
+ silentError: true,
118
+ });
119
+ adminforth.list.refresh();
120
+ props.clearCheckboxes();
121
+ if (res.ok) {
122
+ adminforth.alert({ message: res.successMessage, variant: 'success' });
123
+ } else {
124
+ adminforth.alert({ message: res.errorMessage || t('Failed to translate selected items. Please, try again.'), variant: 'danger' });
125
+ }
126
+ } catch (e) {
127
+ console.error('Failed to translate selected items:', e);
128
+ adminforth.alert({ message: t('Failed to translate selected items. Please, try again.'), variant: 'danger' });
129
+ }
130
+ }
131
+
132
+ </script>
@@ -76,6 +76,8 @@ const countryISO31661ByLangISO6391 = {
76
76
  ja: 'jp', // Japanese → Japan
77
77
  uk: 'ua', // Ukrainian → Ukraine
78
78
  ur: 'pk', // Urdu → Pakistan
79
+ sr: 'rs', // Serbian → Serbia
80
+ da: 'dk' // Danish → Denmark
79
81
  };
80
82
 
81
83
  export function getCountryCodeFromLangCode(langCode) {
@@ -8,10 +8,19 @@
8
8
  "name": "custom",
9
9
  "version": "1.0.0",
10
10
  "license": "ISC",
11
+ "dependencies": {
12
+ "country-list": "^2.4.1"
13
+ },
11
14
  "devDependencies": {
12
15
  "flag-icon-css": "^4.1.7"
13
16
  }
14
17
  },
18
+ "node_modules/country-list": {
19
+ "version": "2.4.1",
20
+ "resolved": "https://registry.npmjs.org/country-list/-/country-list-2.4.1.tgz",
21
+ "integrity": "sha512-KhVV/UfUV3dSNpsWIqHTQxLpYDKPKz1UwkRjadt+YbX2PRhyCEihEoS5XgB7J7AMXpkicvl+tRHvkNI5wbji/g==",
22
+ "license": "MIT"
23
+ },
15
24
  "node_modules/flag-icon-css": {
16
25
  "version": "4.1.7",
17
26
  "resolved": "https://registry.npmjs.org/flag-icon-css/-/flag-icon-css-4.1.7.tgz",
@@ -11,5 +11,8 @@
11
11
  "description": "",
12
12
  "devDependencies": {
13
13
  "flag-icon-css": "^4.1.7"
14
+ },
15
+ "dependencies": {
16
+ "country-list": "^2.4.1"
14
17
  }
15
18
  }
@@ -0,0 +1,132 @@
1
+ <template>
2
+ <Dialog
3
+ class="w-[500px]"
4
+ :buttons="[
5
+ {
6
+ label: 'Translate',
7
+ onclick: (dialog) => { runTranslation(); dialog.hide(); } ,
8
+ options: {
9
+ disabled: noneChecked
10
+ }
11
+ },
12
+ {
13
+ label: 'Close',
14
+ options: {
15
+ class: 'bg-white hover:!bg-gray-100 !text-gray-900 hover:!text-gray-800 dark:!bg-gray-800 dark:!text-gray-100 dark:hover:!bg-gray-700 !border-gray-200 dark:!border-gray-600'
16
+ },
17
+ onclick: (dialog) => dialog.hide()
18
+ },
19
+ ]"
20
+ >
21
+ <template #trigger>
22
+ <button
23
+ v-if="checkboxes.length > 0"
24
+ class="flex gap-1 items-center py-1 px-3 text-sm font-medium text-lightListViewButtonText focus:outline-none bg-lightListViewButtonBackground rounded-default border border-lightListViewButtonBorder hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover focus:z-10 focus:ring-4 focus:ring-lightListViewButtonFocusRing dark:focus:ring-darkListViewButtonFocusRing dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:border-darkListViewButtonBorder dark:hover:text-darkListViewButtonTextHover dark:hover:bg-darkListViewButtonBackgroundHover"
25
+ >
26
+ <IconLanguageOutline class="w-5 h-5" />
27
+ {{ t('Translate Selected') }} {{ `(${checkboxes.length})` }}
28
+ <div class="text-white bg-gradient-to-r from-purple-500 via-purple-600 to-purple-700 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-purple-300 dark:focus:ring-purple-800
29
+ font-medium rounded-sm text-xs px-1 ml-1 text-center ">
30
+ AI
31
+ </div>
32
+ </button>
33
+ </template>
34
+
35
+ <div class="grid grid-cols-2 gap-4 w-full">
36
+ <Button @click="selectAll" :disabled="allChecked">{{ t('Select All') }}</Button>
37
+ <Button @click="uncheckAll" :disabled="noneChecked">{{ t('Uncheck All') }}</Button>
38
+ <div class="col-span-2 grid grid-cols-3 gap-4 ">
39
+ <div class="group flex items-center justify-between cursor-pointer" v-for="(index, lang) in checkedLanguages" :key="index" @click="toggleLanguage(lang)">
40
+ <div class="flex items-center gap-2">
41
+ <Checkbox v-model="checkedLanguages[lang]" />
42
+ <span class="flag-icon"
43
+ :class="`flag-icon-${getCountryCodeFromLangCode(lang)}`"
44
+ ></span>
45
+ <span class="group-hover:underline">{{ getName(getCountryCodeFromLangCode(lang)) }}</span>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </Dialog>
51
+
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ import { IconLanguageOutline } from '@iconify-prerendered/vue-flowbite';
56
+ import { useI18n } from 'vue-i18n';
57
+ import { Dialog, Button, Checkbox } from '@/afcl';
58
+ import { computed, onMounted, ref, watch } from 'vue';
59
+ import { callAdminForthApi } from '@/utils';
60
+ import { useAdminforth } from '@/adminforth';
61
+ import { getCountryCodeFromLangCode } from './langCommon';
62
+ import { getName, overwrite } from 'country-list';
63
+
64
+ const { t } = useI18n();
65
+ const adminforth = useAdminforth();
66
+
67
+ overwrite([{
68
+ code: 'US',
69
+ name: 'USA'
70
+ }]);
71
+ const props = defineProps<{
72
+ resource: Record<string, any>;
73
+ checkboxes: string[];
74
+ adminUser: Record<string, any>;
75
+ meta: {
76
+ supportedLanguages: string[];
77
+ pluginInstanceId: string;
78
+ };
79
+ clearCheckboxes: () => void;
80
+ }>();
81
+
82
+ const checkedLanguages = ref<Record<string, boolean>>({});
83
+ const allChecked = computed(() => Object.values(checkedLanguages.value).every(Boolean));
84
+ const noneChecked = computed(() => Object.values(checkedLanguages.value).every(value => !value));
85
+
86
+ onMounted(() => {
87
+ for (const lang of props.meta.supportedLanguages) {
88
+ checkedLanguages.value[lang] = true;
89
+ }
90
+ });
91
+
92
+ function selectAll() {
93
+ for (const lang of props.meta.supportedLanguages) {
94
+ checkedLanguages.value[lang] = true;
95
+ }
96
+ }
97
+
98
+ function uncheckAll() {
99
+ for (const lang of props.meta.supportedLanguages) {
100
+ checkedLanguages.value[lang] = false;
101
+ }
102
+ }
103
+
104
+ function toggleLanguage(lang: string) {
105
+ checkedLanguages.value[lang] = !checkedLanguages.value[lang];
106
+ }
107
+
108
+ async function runTranslation() {
109
+ try {
110
+ const res = await callAdminForthApi({
111
+ path: `/plugin/${props.meta.pluginInstanceId}/translate-selected-to-languages`,
112
+ method: 'POST',
113
+ body: {
114
+ selectedIds: props.checkboxes,
115
+ selectedLanguages: Object.keys(checkedLanguages.value).filter(lang => checkedLanguages.value[lang]),
116
+ },
117
+ silentError: true,
118
+ });
119
+ adminforth.list.refresh();
120
+ props.clearCheckboxes();
121
+ if (res.ok) {
122
+ adminforth.alert({ message: res.successMessage, variant: 'success' });
123
+ } else {
124
+ adminforth.alert({ message: res.errorMessage || t('Failed to translate selected items. Please, try again.'), variant: 'danger' });
125
+ }
126
+ } catch (e) {
127
+ console.error('Failed to translate selected items:', e);
128
+ adminforth.alert({ message: t('Failed to translate selected items. Please, try again.'), variant: 'danger' });
129
+ }
130
+ }
131
+
132
+ </script>
@@ -76,6 +76,8 @@ const countryISO31661ByLangISO6391 = {
76
76
  ja: 'jp', // Japanese → Japan
77
77
  uk: 'ua', // Ukrainian → Ukraine
78
78
  ur: 'pk', // Urdu → Pakistan
79
+ sr: 'rs', // Serbian → Serbia
80
+ da: 'dk' // Danish → Denmark
79
81
  };
80
82
 
81
83
  export function getCountryCodeFromLangCode(langCode) {
@@ -8,10 +8,19 @@
8
8
  "name": "custom",
9
9
  "version": "1.0.0",
10
10
  "license": "ISC",
11
+ "dependencies": {
12
+ "country-list": "^2.4.1"
13
+ },
11
14
  "devDependencies": {
12
15
  "flag-icon-css": "^4.1.7"
13
16
  }
14
17
  },
18
+ "node_modules/country-list": {
19
+ "version": "2.4.1",
20
+ "resolved": "https://registry.npmjs.org/country-list/-/country-list-2.4.1.tgz",
21
+ "integrity": "sha512-KhVV/UfUV3dSNpsWIqHTQxLpYDKPKz1UwkRjadt+YbX2PRhyCEihEoS5XgB7J7AMXpkicvl+tRHvkNI5wbji/g==",
22
+ "license": "MIT"
23
+ },
15
24
  "node_modules/flag-icon-css": {
16
25
  "version": "4.1.7",
17
26
  "resolved": "https://registry.npmjs.org/flag-icon-css/-/flag-icon-css-4.1.7.tgz",
@@ -11,5 +11,8 @@
11
11
  "description": "",
12
12
  "devDependencies": {
13
13
  "flag-icon-css": "^4.1.7"
14
+ },
15
+ "dependencies": {
16
+ "country-list": "^2.4.1"
14
17
  }
15
18
  }
package/dist/index.js CHANGED
@@ -152,6 +152,13 @@ export default class I18nPlugin extends AdminForthPlugin {
152
152
  throw new Error(`Invalid language code ${lang}. Use ISO 639-1 (e.g., 'en') or BCP-47 with region (e.g., 'en-GB').`);
153
153
  }
154
154
  });
155
+ if (this.options.translateLangAsBCP47Code) {
156
+ for (const [lang, bcp47] of Object.entries(this.options.translateLangAsBCP47Code)) {
157
+ if (!this.options.supportedLanguages.includes(lang)) {
158
+ throw new Error(`Invalid language code ${lang} in translateLangAsBCP47Code. It must be one of the supportedLanguages.`);
159
+ }
160
+ }
161
+ }
155
162
  this.externalAppOnly = this.options.externalAppOnly === true;
156
163
  // find primary key field
157
164
  this.primaryKeyFieldName = (_a = resourceConfig.columns.find(c => c.primaryKey)) === null || _a === void 0 ? void 0 : _a.name;
@@ -388,45 +395,23 @@ export default class I18nPlugin extends AdminForthPlugin {
388
395
  }));
389
396
  }
390
397
  // add bulk action
391
- if (!resourceConfig.options.bulkActions) {
392
- resourceConfig.options.bulkActions = [];
393
- }
394
- if (this.options.completeAdapter) {
395
- resourceConfig.options.bulkActions.push({
396
- id: 'translate_all',
397
- label: 'Translate selected',
398
- icon: 'flowbite:language-outline',
399
- badge: 'AI',
400
- // if optional `confirm` is provided, user will be asked to confirm action
401
- confirm: 'Are you sure you want to translate selected items? Only empty strings will be translated',
402
- allowed: (_a) => __awaiter(this, [_a], void 0, function* ({ resource, adminUser, selectedIds, allowedActions }) {
403
- process.env.HEAVY_DEBUG && console.log('allowedActions', JSON.stringify(allowedActions));
404
- return allowedActions.edit;
405
- }),
406
- action: (_a) => __awaiter(this, [_a], void 0, function* ({ selectedIds, tr }) {
407
- let translatedCount = 0;
408
- try {
409
- translatedCount = yield this.bulkTranslate({ selectedIds });
410
- }
411
- catch (e) {
412
- process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
413
- if (e instanceof AiTranslateError) {
414
- return { ok: false, error: e.message };
415
- }
416
- throw e;
417
- }
418
- this.updateUntranslatedMenuBadge();
419
- return {
420
- ok: true,
421
- error: undefined,
422
- successMessage: yield tr(`Translated {count} items`, 'backend', {
423
- count: translatedCount,
424
- }),
425
- };
426
- })
427
- });
398
+ const pageInjection = {
399
+ file: this.componentPath('BulkActionButton.vue'),
400
+ meta: {
401
+ supportedLanguages: this.options.supportedLanguages,
402
+ pluginInstanceId: this.pluginInstanceId,
403
+ }
404
+ };
405
+ if (!resourceConfig.options.pageInjections) {
406
+ resourceConfig.options.pageInjections = {};
428
407
  }
429
- ;
408
+ if (!resourceConfig.options.pageInjections.list) {
409
+ resourceConfig.options.pageInjections.list = {};
410
+ }
411
+ if (!resourceConfig.options.pageInjections.list.beforeActionButtons) {
412
+ resourceConfig.options.pageInjections.list.beforeActionButtons = [];
413
+ }
414
+ resourceConfig.options.pageInjections.list.beforeActionButtons.push(pageInjection);
430
415
  // if there is menu item with resourceId, add .badge function showing number of untranslated strings
431
416
  const addBadgeCountToMenuItem = (menuItem) => {
432
417
  this.menuItemWithBadgeId = menuItem.itemId;
@@ -458,6 +443,7 @@ export default class I18nPlugin extends AdminForthPlugin {
458
443
  if (strings.length === 0) {
459
444
  return [];
460
445
  }
446
+ const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode] : null;
461
447
  if (strings.length > maxKeysInOneReq) {
462
448
  let totalTranslated = [];
463
449
  for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
@@ -468,14 +454,15 @@ export default class I18nPlugin extends AdminForthPlugin {
468
454
  }
469
455
  return totalTranslated;
470
456
  }
457
+ const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
471
458
  const lang = langIsoCode;
472
459
  const primaryLang = getPrimaryLanguageCode(lang);
473
460
  const langName = iso6391.getName(primaryLang);
474
461
  const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
475
462
  const region = ((_a = String(lang).split('-')[1]) === null || _a === void 0 ? void 0 : _a.toUpperCase()) || '';
476
463
  const prompt = `
477
- I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
478
- ${region ? `Use the regional conventions for ${lang} (region ${region}), including spelling, punctuation, and formatting.` : ''}
464
+ I need to translate strings in JSON to ${langName} language ${replacedLanguageCodeForTranslations || lang.length > 2 ? `BCP-47 code ${langCode}` : `ISO 639-1 code ${langIsoCode}`} from English for my web app.
465
+ ${region ? `Use the regional conventions for ${langCode} (region ${region}), including spelling, punctuation, and formatting.` : ''}
479
466
  ${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]}"` : ''}
480
467
  Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:
481
468
 
@@ -486,8 +473,25 @@ export default class I18nPlugin extends AdminForthPlugin {
486
473
  }, {}), null, 2)}
487
474
  \`\`\`
488
475
  `;
476
+ const jsonSchemaProperties = {};
477
+ strings.forEach(s => {
478
+ jsonSchemaProperties[s.en_string] = {
479
+ type: 'string',
480
+ minLength: 1,
481
+ };
482
+ });
483
+ const jsonSchemaRequired = strings.map(s => s.en_string);
489
484
  // call OpenAI
490
- const resp = yield this.options.completeAdapter.complete(prompt, [], prompt.length * 2);
485
+ const resp = yield this.options.completeAdapter.complete(prompt, [], prompt.length * 2, {
486
+ json_schema: {
487
+ name: "translation_response",
488
+ schema: {
489
+ type: "object",
490
+ properties: jsonSchemaProperties,
491
+ required: jsonSchemaRequired,
492
+ },
493
+ },
494
+ });
491
495
  process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM resp >> ${prompt.length}, <<${resp.content.length} :\n\n`, JSON.stringify(resp));
492
496
  if (resp.error) {
493
497
  throw new AiTranslateError(resp.error);
@@ -499,7 +503,7 @@ export default class I18nPlugin extends AdminForthPlugin {
499
503
  // ```
500
504
  let res;
501
505
  try {
502
- res = resp.content.split("```json")[1].split("```")[0];
506
+ res = resp.content; //.split("```json")[1].split("```")[0];
503
507
  }
504
508
  catch (e) {
505
509
  console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`);
@@ -540,10 +544,11 @@ export default class I18nPlugin extends AdminForthPlugin {
540
544
  }
541
545
  // returns translated count
542
546
  bulkTranslate(_a) {
543
- return __awaiter(this, arguments, void 0, function* ({ selectedIds }) {
547
+ return __awaiter(this, arguments, void 0, function* ({ selectedIds, selectedLanguages }) {
544
548
  const needToTranslateByLang = {};
545
549
  const translations = yield this.adminforth.resource(this.resourceConfig.resourceId).list(Filters.IN(this.primaryKeyFieldName, selectedIds));
546
- for (const lang of this.options.supportedLanguages) {
550
+ const languagesToProcess = selectedLanguages || this.options.supportedLanguages;
551
+ for (const lang of languagesToProcess) {
547
552
  if (lang === 'en') {
548
553
  // all strings are in English, no need to translate
549
554
  continue;
@@ -897,5 +902,34 @@ export default class I18nPlugin extends AdminForthPlugin {
897
902
  return { record: updatedRecord };
898
903
  })
899
904
  });
905
+ server.endpoint({
906
+ method: 'POST',
907
+ path: `/plugin/${this.pluginInstanceId}/translate-selected-to-languages`,
908
+ noAuth: false,
909
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, tr }) {
910
+ const selectedLanguages = body.selectedLanguages;
911
+ const selectedIds = body.selectedIds;
912
+ let translatedCount = 0;
913
+ try {
914
+ console.log('🪲translate-selected-to-languages', { selectedLanguages, selectedIds });
915
+ translatedCount = yield this.bulkTranslate({ selectedIds, selectedLanguages });
916
+ }
917
+ catch (e) {
918
+ process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
919
+ if (e instanceof AiTranslateError) {
920
+ return { ok: false, error: e.message };
921
+ }
922
+ throw e;
923
+ }
924
+ this.updateUntranslatedMenuBadge();
925
+ return {
926
+ ok: true,
927
+ error: undefined,
928
+ successMessage: yield tr(`Translated {count} items`, 'backend', {
929
+ count: translatedCount,
930
+ }),
931
+ };
932
+ })
933
+ });
900
934
  }
901
935
  }
package/index.ts CHANGED
@@ -8,7 +8,7 @@ import fs from 'fs-extra';
8
8
  import chokidar from 'chokidar';
9
9
  import { AsyncQueue } from '@sapphire/async-queue';
10
10
  import getFlagEmoji from 'country-flag-svg';
11
- import { parse } from 'bcp-47'
11
+ import { parse } from 'bcp-47';
12
12
 
13
13
  const processFrontendMessagesQueue = new AsyncQueue();
14
14
 
@@ -172,6 +172,14 @@ export default class I18nPlugin extends AdminForthPlugin {
172
172
  }
173
173
  });
174
174
 
175
+ if (this.options.translateLangAsBCP47Code) {
176
+ for (const [lang, bcp47] of Object.entries(this.options.translateLangAsBCP47Code)) {
177
+ if (!this.options.supportedLanguages.includes(lang as SupportedLanguage)) {
178
+ throw new Error(`Invalid language code ${lang} in translateLangAsBCP47Code. It must be one of the supportedLanguages.`);
179
+ }
180
+ }
181
+ }
182
+
175
183
  this.externalAppOnly = this.options.externalAppOnly === true;
176
184
 
177
185
  // find primary key field
@@ -440,46 +448,27 @@ export default class I18nPlugin extends AdminForthPlugin {
440
448
  }
441
449
 
442
450
  // add bulk action
443
- if (!resourceConfig.options.bulkActions) {
444
- resourceConfig.options.bulkActions = [];
451
+
452
+ const pageInjection = {
453
+ file: this.componentPath('BulkActionButton.vue'),
454
+ meta: {
455
+ supportedLanguages: this.options.supportedLanguages,
456
+ pluginInstanceId: this.pluginInstanceId,
457
+ }
445
458
  }
446
-
447
- if (this.options.completeAdapter) {
448
- resourceConfig.options.bulkActions.push(
449
- {
450
- id: 'translate_all',
451
- label: 'Translate selected',
452
- icon: 'flowbite:language-outline',
453
- badge: 'AI',
454
- // if optional `confirm` is provided, user will be asked to confirm action
455
- confirm: 'Are you sure you want to translate selected items? Only empty strings will be translated',
456
- allowed: async ({ resource, adminUser, selectedIds, allowedActions }) => {
457
- process.env.HEAVY_DEBUG && console.log('allowedActions', JSON.stringify(allowedActions));
458
- return allowedActions.edit;
459
- },
460
- action: async ({ selectedIds, tr }) => {
461
- let translatedCount = 0;
462
- try {
463
- translatedCount = await this.bulkTranslate({ selectedIds });
464
- } catch (e) {
465
- process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
466
- if (e instanceof AiTranslateError) {
467
- return { ok: false, error: e.message };
468
- }
469
- throw e;
470
- }
471
- this.updateUntranslatedMenuBadge();
472
- return {
473
- ok: true,
474
- error: undefined,
475
- successMessage: await tr(`Translated {count} items`, 'backend', {
476
- count: translatedCount,
477
- }),
478
- };
479
- }
480
- }
481
- );
482
- };
459
+
460
+ if (!resourceConfig.options.pageInjections) {
461
+ resourceConfig.options.pageInjections = {};
462
+ }
463
+ if (!resourceConfig.options.pageInjections.list) {
464
+ resourceConfig.options.pageInjections.list = {};
465
+ }
466
+ if (!resourceConfig.options.pageInjections.list.beforeActionButtons) {
467
+ resourceConfig.options.pageInjections.list.beforeActionButtons = [];
468
+ }
469
+
470
+ (resourceConfig.options.pageInjections.list.beforeActionButtons as AdminForthComponentDeclaration[]).push(pageInjection);
471
+
483
472
 
484
473
  // if there is menu item with resourceId, add .badge function showing number of untranslated strings
485
474
  const addBadgeCountToMenuItem = (menuItem: AdminForthConfigMenuItem) => {
@@ -517,6 +506,7 @@ export default class I18nPlugin extends AdminForthPlugin {
517
506
  return [];
518
507
  }
519
508
 
509
+ const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode as any] : null;
520
510
  if (strings.length > maxKeysInOneReq) {
521
511
  let totalTranslated = [];
522
512
  for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
@@ -527,14 +517,15 @@ export default class I18nPlugin extends AdminForthPlugin {
527
517
  }
528
518
  return totalTranslated;
529
519
  }
520
+ const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
530
521
  const lang = langIsoCode;
531
522
  const primaryLang = getPrimaryLanguageCode(lang);
532
523
  const langName = iso6391.getName(primaryLang);
533
524
  const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
534
525
  const region = String(lang).split('-')[1]?.toUpperCase() || '';
535
526
  const prompt = `
536
- I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
537
- ${region ? `Use the regional conventions for ${lang} (region ${region}), including spelling, punctuation, and formatting.` : ''}
527
+ I need to translate strings in JSON to ${langName} language ${replacedLanguageCodeForTranslations || lang.length > 2 ? `BCP-47 code ${langCode}` : `ISO 639-1 code ${langIsoCode}`} from English for my web app.
528
+ ${region ? `Use the regional conventions for ${langCode} (region ${region}), including spelling, punctuation, and formatting.` : ''}
538
529
  ${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]}"` : ''}
539
530
  Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:
540
531
 
@@ -548,11 +539,31 @@ export default class I18nPlugin extends AdminForthPlugin {
548
539
  \`\`\`
549
540
  `;
550
541
 
542
+ const jsonSchemaProperties = {};
543
+ strings.forEach(s => {
544
+ jsonSchemaProperties[s.en_string] = {
545
+ type: 'string',
546
+ minLength: 1,
547
+ };
548
+ });
549
+
550
+ const jsonSchemaRequired = strings.map(s => s.en_string);
551
+
551
552
  // call OpenAI
552
553
  const resp = await this.options.completeAdapter.complete(
553
554
  prompt,
554
555
  [],
555
556
  prompt.length * 2,
557
+ {
558
+ json_schema: {
559
+ name: "translation_response",
560
+ schema: {
561
+ type: "object",
562
+ properties: jsonSchemaProperties,
563
+ required: jsonSchemaRequired,
564
+ },
565
+ },
566
+ }
556
567
  );
557
568
 
558
569
  process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM resp >> ${prompt.length}, <<${resp.content.length} :\n\n`, JSON.stringify(resp));
@@ -568,7 +579,7 @@ export default class I18nPlugin extends AdminForthPlugin {
568
579
  // ```
569
580
  let res;
570
581
  try {
571
- res = resp.content.split("```json")[1].split("```")[0];
582
+ res = resp.content//.split("```json")[1].split("```")[0];
572
583
  } catch (e) {
573
584
  console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
574
585
  return [];
@@ -613,7 +624,7 @@ export default class I18nPlugin extends AdminForthPlugin {
613
624
  }
614
625
 
615
626
  // returns translated count
616
- async bulkTranslate({ selectedIds }: { selectedIds: string[] }): Promise<number> {
627
+ async bulkTranslate({ selectedIds, selectedLanguages }: { selectedIds: string[], selectedLanguages?: SupportedLanguage[] }): Promise<number> {
617
628
 
618
629
  const needToTranslateByLang : Partial<
619
630
  Record<
@@ -626,8 +637,8 @@ export default class I18nPlugin extends AdminForthPlugin {
626
637
  > = {};
627
638
 
628
639
  const translations = await this.adminforth.resource(this.resourceConfig.resourceId).list(Filters.IN(this.primaryKeyFieldName, selectedIds));
629
-
630
- for (const lang of this.options.supportedLanguages) {
640
+ const languagesToProcess = selectedLanguages || this.options.supportedLanguages;
641
+ for (const lang of languagesToProcess) {
631
642
  if (lang === 'en') {
632
643
  // all strings are in English, no need to translate
633
644
  continue;
@@ -1057,6 +1068,36 @@ export default class I18nPlugin extends AdminForthPlugin {
1057
1068
  }
1058
1069
  });
1059
1070
 
1071
+ server.endpoint({
1072
+ method: 'POST',
1073
+ path: `/plugin/${this.pluginInstanceId}/translate-selected-to-languages`,
1074
+ noAuth: false,
1075
+ handler: async ({ body, tr }) => {
1076
+ const selectedLanguages = body.selectedLanguages;
1077
+ const selectedIds = body.selectedIds;
1078
+
1079
+ let translatedCount = 0;
1080
+ try {
1081
+ console.log('🪲translate-selected-to-languages', { selectedLanguages, selectedIds });
1082
+ translatedCount = await this.bulkTranslate({ selectedIds, selectedLanguages });
1083
+ } catch (e) {
1084
+ process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
1085
+ if (e instanceof AiTranslateError) {
1086
+ return { ok: false, error: e.message };
1087
+ }
1088
+ throw e;
1089
+ }
1090
+ this.updateUntranslatedMenuBadge();
1091
+ return {
1092
+ ok: true,
1093
+ error: undefined,
1094
+ successMessage: await tr(`Translated {count} items`, 'backend', {
1095
+ count: translatedCount,
1096
+ }),
1097
+ };
1098
+ }
1099
+ });
1100
+
1060
1101
  }
1061
1102
 
1062
1103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/i18n",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
package/types.ts CHANGED
@@ -10,7 +10,7 @@ export type SupportedLanguage = LanguageCode | Bcp47LanguageTag;
10
10
 
11
11
  export interface PluginOptions {
12
12
 
13
- /* List of ISO 639-1 language codes which you want to tsupport*/
13
+ /* List of language codes which you want to support. Can be either short ISO 639-1 language codes or/and BCP47 tags */
14
14
  supportedLanguages: SupportedLanguage[];
15
15
 
16
16
  /**
@@ -68,4 +68,10 @@ export interface PluginOptions {
68
68
  * Defaults to 'en' if not specified.
69
69
  */
70
70
  primaryLanguage?: SupportedLanguage;
71
+
72
+ /**
73
+ * Ask translator to treat some code from supportedLanguages as exact BCP47 tag. Read docs for details.
74
+ * key - one of the values form supportedLanguages, value -BCP47 tag
75
+ */
76
+ translateLangAsBCP47Code?: Partial<Record<LanguageCode, Bcp47LanguageTag>>;
71
77
  }