@adminforth/i18n 1.8.0 → 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,150 bytes received 248 bytes 66,796.00 bytes/sec
21
- total size is 32,230 speedup is 0.97
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
@@ -9,12 +9,12 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock } from "adminforth";
11
11
  import iso6391 from 'iso-639-1';
12
- import { iso31661Alpha2ToAlpha3 } from 'iso-3166';
13
12
  import path from 'path';
14
13
  import fs from 'fs-extra';
15
14
  import chokidar from 'chokidar';
16
15
  import { AsyncQueue } from '@sapphire/async-queue';
17
16
  import getFlagEmoji from 'country-flag-svg';
17
+ import { parse } from 'bcp-47';
18
18
  const processFrontendMessagesQueue = new AsyncQueue();
19
19
  const SLAVIC_PLURAL_EXAMPLES = {
20
20
  uk: 'яблук | Яблуко | Яблука | Яблук', // zero | singular | 2-4 | 5+
@@ -52,15 +52,19 @@ function getPrimaryLanguageCode(langCode) {
52
52
  return String(langCode).split('-')[0];
53
53
  }
54
54
  function isValidSupportedLanguageTag(langCode) {
55
- const [primary, region] = String(langCode).split('-');
56
- if (!iso6391.validate(primary)) {
57
- return false;
55
+ try {
56
+ const schema = parse(String(langCode), {
57
+ normalize: true,
58
+ warning: (reason, code, offset) => {
59
+ console.warn(`Warning in validating language tag ${langCode}: reason=${reason}, code=${code}, offset=${offset}`);
60
+ }
61
+ });
62
+ const isValid = schema && schema.language && schema.language.length === 2;
63
+ return !!isValid;
58
64
  }
59
- if (!region) {
60
- return true;
65
+ catch (e) {
66
+ return false;
61
67
  }
62
- const regionUpper = region.toUpperCase();
63
- return /^[A-Z]{2}$/.test(regionUpper) && (regionUpper in iso31661Alpha2ToAlpha3);
64
68
  }
65
69
  class CachingAdapterMemory {
66
70
  constructor() {
@@ -148,6 +152,13 @@ export default class I18nPlugin extends AdminForthPlugin {
148
152
  throw new Error(`Invalid language code ${lang}. Use ISO 639-1 (e.g., 'en') or BCP-47 with region (e.g., 'en-GB').`);
149
153
  }
150
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
+ }
151
162
  this.externalAppOnly = this.options.externalAppOnly === true;
152
163
  // find primary key field
153
164
  this.primaryKeyFieldName = (_a = resourceConfig.columns.find(c => c.primaryKey)) === null || _a === void 0 ? void 0 : _a.name;
@@ -384,45 +395,23 @@ export default class I18nPlugin extends AdminForthPlugin {
384
395
  }));
385
396
  }
386
397
  // add bulk action
387
- if (!resourceConfig.options.bulkActions) {
388
- resourceConfig.options.bulkActions = [];
389
- }
390
- if (this.options.completeAdapter) {
391
- resourceConfig.options.bulkActions.push({
392
- id: 'translate_all',
393
- label: 'Translate selected',
394
- icon: 'flowbite:language-outline',
395
- badge: 'AI',
396
- // if optional `confirm` is provided, user will be asked to confirm action
397
- confirm: 'Are you sure you want to translate selected items? Only empty strings will be translated',
398
- allowed: (_a) => __awaiter(this, [_a], void 0, function* ({ resource, adminUser, selectedIds, allowedActions }) {
399
- process.env.HEAVY_DEBUG && console.log('allowedActions', JSON.stringify(allowedActions));
400
- return allowedActions.edit;
401
- }),
402
- action: (_a) => __awaiter(this, [_a], void 0, function* ({ selectedIds, tr }) {
403
- let translatedCount = 0;
404
- try {
405
- translatedCount = yield this.bulkTranslate({ selectedIds });
406
- }
407
- catch (e) {
408
- process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
409
- if (e instanceof AiTranslateError) {
410
- return { ok: false, error: e.message };
411
- }
412
- throw e;
413
- }
414
- this.updateUntranslatedMenuBadge();
415
- return {
416
- ok: true,
417
- error: undefined,
418
- successMessage: yield tr(`Translated {count} items`, 'backend', {
419
- count: translatedCount,
420
- }),
421
- };
422
- })
423
- });
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 = {};
407
+ }
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 = [];
424
413
  }
425
- ;
414
+ resourceConfig.options.pageInjections.list.beforeActionButtons.push(pageInjection);
426
415
  // if there is menu item with resourceId, add .badge function showing number of untranslated strings
427
416
  const addBadgeCountToMenuItem = (menuItem) => {
428
417
  this.menuItemWithBadgeId = menuItem.itemId;
@@ -454,6 +443,7 @@ export default class I18nPlugin extends AdminForthPlugin {
454
443
  if (strings.length === 0) {
455
444
  return [];
456
445
  }
446
+ const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode] : null;
457
447
  if (strings.length > maxKeysInOneReq) {
458
448
  let totalTranslated = [];
459
449
  for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
@@ -464,14 +454,15 @@ export default class I18nPlugin extends AdminForthPlugin {
464
454
  }
465
455
  return totalTranslated;
466
456
  }
457
+ const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
467
458
  const lang = langIsoCode;
468
459
  const primaryLang = getPrimaryLanguageCode(lang);
469
460
  const langName = iso6391.getName(primaryLang);
470
461
  const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
471
462
  const region = ((_a = String(lang).split('-')[1]) === null || _a === void 0 ? void 0 : _a.toUpperCase()) || '';
472
463
  const prompt = `
473
- I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
474
- ${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.` : ''}
475
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]}"` : ''}
476
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:
477
468
 
@@ -482,8 +473,25 @@ export default class I18nPlugin extends AdminForthPlugin {
482
473
  }, {}), null, 2)}
483
474
  \`\`\`
484
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);
485
484
  // call OpenAI
486
- 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
+ });
487
495
  process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM resp >> ${prompt.length}, <<${resp.content.length} :\n\n`, JSON.stringify(resp));
488
496
  if (resp.error) {
489
497
  throw new AiTranslateError(resp.error);
@@ -495,7 +503,7 @@ export default class I18nPlugin extends AdminForthPlugin {
495
503
  // ```
496
504
  let res;
497
505
  try {
498
- res = resp.content.split("```json")[1].split("```")[0];
506
+ res = resp.content; //.split("```json")[1].split("```")[0];
499
507
  }
500
508
  catch (e) {
501
509
  console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`);
@@ -536,10 +544,11 @@ export default class I18nPlugin extends AdminForthPlugin {
536
544
  }
537
545
  // returns translated count
538
546
  bulkTranslate(_a) {
539
- return __awaiter(this, arguments, void 0, function* ({ selectedIds }) {
547
+ return __awaiter(this, arguments, void 0, function* ({ selectedIds, selectedLanguages }) {
540
548
  const needToTranslateByLang = {};
541
549
  const translations = yield this.adminforth.resource(this.resourceConfig.resourceId).list(Filters.IN(this.primaryKeyFieldName, selectedIds));
542
- for (const lang of this.options.supportedLanguages) {
550
+ const languagesToProcess = selectedLanguages || this.options.supportedLanguages;
551
+ for (const lang of languagesToProcess) {
543
552
  if (lang === 'en') {
544
553
  // all strings are in English, no need to translate
545
554
  continue;
@@ -893,5 +902,34 @@ export default class I18nPlugin extends AdminForthPlugin {
893
902
  return { record: updatedRecord };
894
903
  })
895
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
+ });
896
934
  }
897
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
-
11
+ import { parse } from 'bcp-47';
12
12
 
13
13
  const processFrontendMessagesQueue = new AsyncQueue();
14
14
 
@@ -52,15 +52,20 @@ function getPrimaryLanguageCode(langCode: SupportedLanguage): string {
52
52
  }
53
53
 
54
54
  function isValidSupportedLanguageTag(langCode: SupportedLanguage): boolean {
55
- const [primary, region] = String(langCode).split('-');
56
- if (!iso6391.validate(primary as any)) {
55
+ try {
56
+ const schema = parse(String(langCode),
57
+ {
58
+ normalize: true,
59
+ warning: (reason, code, offset) => {
60
+ console.warn(`Warning in validating language tag ${langCode}: reason=${reason}, code=${code}, offset=${offset}`);
61
+ }
62
+ }
63
+ );
64
+ const isValid = schema && schema.language && schema.language.length === 2;
65
+ return !!isValid;
66
+ } catch (e) {
57
67
  return false;
58
68
  }
59
- if (!region) {
60
- return true;
61
- }
62
- const regionUpper = region.toUpperCase();
63
- return /^[A-Z]{2}$/.test(regionUpper) && (regionUpper in iso31661Alpha2ToAlpha3);
64
69
  }
65
70
 
66
71
 
@@ -167,6 +172,14 @@ export default class I18nPlugin extends AdminForthPlugin {
167
172
  }
168
173
  });
169
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
+
170
183
  this.externalAppOnly = this.options.externalAppOnly === true;
171
184
 
172
185
  // find primary key field
@@ -435,46 +448,27 @@ export default class I18nPlugin extends AdminForthPlugin {
435
448
  }
436
449
 
437
450
  // add bulk action
438
- if (!resourceConfig.options.bulkActions) {
439
- 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
+ }
440
458
  }
441
-
442
- if (this.options.completeAdapter) {
443
- resourceConfig.options.bulkActions.push(
444
- {
445
- id: 'translate_all',
446
- label: 'Translate selected',
447
- icon: 'flowbite:language-outline',
448
- badge: 'AI',
449
- // if optional `confirm` is provided, user will be asked to confirm action
450
- confirm: 'Are you sure you want to translate selected items? Only empty strings will be translated',
451
- allowed: async ({ resource, adminUser, selectedIds, allowedActions }) => {
452
- process.env.HEAVY_DEBUG && console.log('allowedActions', JSON.stringify(allowedActions));
453
- return allowedActions.edit;
454
- },
455
- action: async ({ selectedIds, tr }) => {
456
- let translatedCount = 0;
457
- try {
458
- translatedCount = await this.bulkTranslate({ selectedIds });
459
- } catch (e) {
460
- process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
461
- if (e instanceof AiTranslateError) {
462
- return { ok: false, error: e.message };
463
- }
464
- throw e;
465
- }
466
- this.updateUntranslatedMenuBadge();
467
- return {
468
- ok: true,
469
- error: undefined,
470
- successMessage: await tr(`Translated {count} items`, 'backend', {
471
- count: translatedCount,
472
- }),
473
- };
474
- }
475
- }
476
- );
477
- };
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
+
478
472
 
479
473
  // if there is menu item with resourceId, add .badge function showing number of untranslated strings
480
474
  const addBadgeCountToMenuItem = (menuItem: AdminForthConfigMenuItem) => {
@@ -512,6 +506,7 @@ export default class I18nPlugin extends AdminForthPlugin {
512
506
  return [];
513
507
  }
514
508
 
509
+ const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode as any] : null;
515
510
  if (strings.length > maxKeysInOneReq) {
516
511
  let totalTranslated = [];
517
512
  for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
@@ -522,14 +517,15 @@ export default class I18nPlugin extends AdminForthPlugin {
522
517
  }
523
518
  return totalTranslated;
524
519
  }
520
+ const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
525
521
  const lang = langIsoCode;
526
522
  const primaryLang = getPrimaryLanguageCode(lang);
527
523
  const langName = iso6391.getName(primaryLang);
528
524
  const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
529
525
  const region = String(lang).split('-')[1]?.toUpperCase() || '';
530
526
  const prompt = `
531
- I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
532
- ${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.` : ''}
533
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]}"` : ''}
534
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:
535
531
 
@@ -543,11 +539,31 @@ export default class I18nPlugin extends AdminForthPlugin {
543
539
  \`\`\`
544
540
  `;
545
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
+
546
552
  // call OpenAI
547
553
  const resp = await this.options.completeAdapter.complete(
548
554
  prompt,
549
555
  [],
550
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
+ }
551
567
  );
552
568
 
553
569
  process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM resp >> ${prompt.length}, <<${resp.content.length} :\n\n`, JSON.stringify(resp));
@@ -563,7 +579,7 @@ export default class I18nPlugin extends AdminForthPlugin {
563
579
  // ```
564
580
  let res;
565
581
  try {
566
- res = resp.content.split("```json")[1].split("```")[0];
582
+ res = resp.content//.split("```json")[1].split("```")[0];
567
583
  } catch (e) {
568
584
  console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
569
585
  return [];
@@ -608,7 +624,7 @@ export default class I18nPlugin extends AdminForthPlugin {
608
624
  }
609
625
 
610
626
  // returns translated count
611
- async bulkTranslate({ selectedIds }: { selectedIds: string[] }): Promise<number> {
627
+ async bulkTranslate({ selectedIds, selectedLanguages }: { selectedIds: string[], selectedLanguages?: SupportedLanguage[] }): Promise<number> {
612
628
 
613
629
  const needToTranslateByLang : Partial<
614
630
  Record<
@@ -621,8 +637,8 @@ export default class I18nPlugin extends AdminForthPlugin {
621
637
  > = {};
622
638
 
623
639
  const translations = await this.adminforth.resource(this.resourceConfig.resourceId).list(Filters.IN(this.primaryKeyFieldName, selectedIds));
624
-
625
- for (const lang of this.options.supportedLanguages) {
640
+ const languagesToProcess = selectedLanguages || this.options.supportedLanguages;
641
+ for (const lang of languagesToProcess) {
626
642
  if (lang === 'en') {
627
643
  // all strings are in English, no need to translate
628
644
  continue;
@@ -1052,6 +1068,36 @@ export default class I18nPlugin extends AdminForthPlugin {
1052
1068
  }
1053
1069
  });
1054
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
+
1055
1101
  }
1056
1102
 
1057
1103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/i18n",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -30,6 +30,7 @@
30
30
  "dependencies": {
31
31
  "@aws-sdk/client-ses": "^3.654.0",
32
32
  "@sapphire/async-queue": "^1.5.5",
33
+ "bcp-47": "^2.1.0",
33
34
  "chokidar": "^4.0.1",
34
35
  "country-flag-svg": "^1.0.19",
35
36
  "fs-extra": "^11.3.2",
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
  }