Skip to content

Commit 956d8d1

Browse files
authored
Merge pull request #11 from devforth/feature/AdminForth/1271/allow-to-select-languages-for-
Feature/admin forth/1271/allow to select languages for
2 parents 2ec84d4 + 2ae536e commit 956d8d1

6 files changed

Lines changed: 240 additions & 47 deletions

File tree

custom/BulkActionButton.vue

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

custom/langCommon.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ const countryISO31661ByLangISO6391 = {
7676
ja: 'jp', // Japanese → Japan
7777
uk: 'ua', // Ukrainian → Ukraine
7878
ur: 'pk', // Urdu → Pakistan
79+
sr: 'rs', // Serbian → Serbia
80+
da: 'dk' // Danish → Denmark
7981
};
8082

8183
export function getCountryCodeFromLangCode(langCode) {

custom/package-lock.json

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

custom/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@
1111
"description": "",
1212
"devDependencies": {
1313
"flag-icon-css": "^4.1.7"
14+
},
15+
"dependencies": {
16+
"country-list": "^2.4.1"
1417
}
1518
}

index.ts

Lines changed: 87 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import fs from 'fs-extra';
88
import chokidar from 'chokidar';
99
import { AsyncQueue } from '@sapphire/async-queue';
1010
import getFlagEmoji from 'country-flag-svg';
11-
import { parse } from 'bcp-47'
11+
import { parse } from 'bcp-47';
1212

1313
const processFrontendMessagesQueue = new AsyncQueue();
1414

@@ -172,6 +172,14 @@ export default class I18nPlugin extends AdminForthPlugin {
172172
}
173173
});
174174

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+
175183
this.externalAppOnly = this.options.externalAppOnly === true;
176184

177185
// find primary key field
@@ -440,46 +448,27 @@ export default class I18nPlugin extends AdminForthPlugin {
440448
}
441449

442450
// 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+
}
445458
}
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+
483472

484473
// if there is menu item with resourceId, add .badge function showing number of untranslated strings
485474
const addBadgeCountToMenuItem = (menuItem: AdminForthConfigMenuItem) => {
@@ -517,6 +506,7 @@ export default class I18nPlugin extends AdminForthPlugin {
517506
return [];
518507
}
519508

509+
const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode as any] : null;
520510
if (strings.length > maxKeysInOneReq) {
521511
let totalTranslated = [];
522512
for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
@@ -527,14 +517,15 @@ export default class I18nPlugin extends AdminForthPlugin {
527517
}
528518
return totalTranslated;
529519
}
520+
const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
530521
const lang = langIsoCode;
531522
const primaryLang = getPrimaryLanguageCode(lang);
532523
const langName = iso6391.getName(primaryLang);
533524
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
534525
const region = String(lang).split('-')[1]?.toUpperCase() || '';
535526
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.` : ''}
538529
${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]}"` : ''}
539530
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:
540531
@@ -548,11 +539,31 @@ export default class I18nPlugin extends AdminForthPlugin {
548539
\`\`\`
549540
`;
550541

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+
551552
// call OpenAI
552553
const resp = await this.options.completeAdapter.complete(
553554
prompt,
554555
[],
555556
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+
}
556567
);
557568

558569
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 {
568579
// ```
569580
let res;
570581
try {
571-
res = resp.content.split("```json")[1].split("```")[0];
582+
res = resp.content//.split("```json")[1].split("```")[0];
572583
} catch (e) {
573584
console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
574585
return [];
@@ -613,7 +624,7 @@ export default class I18nPlugin extends AdminForthPlugin {
613624
}
614625

615626
// returns translated count
616-
async bulkTranslate({ selectedIds }: { selectedIds: string[] }): Promise<number> {
627+
async bulkTranslate({ selectedIds, selectedLanguages }: { selectedIds: string[], selectedLanguages?: SupportedLanguage[] }): Promise<number> {
617628

618629
const needToTranslateByLang : Partial<
619630
Record<
@@ -626,8 +637,8 @@ export default class I18nPlugin extends AdminForthPlugin {
626637
> = {};
627638

628639
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) {
631642
if (lang === 'en') {
632643
// all strings are in English, no need to translate
633644
continue;
@@ -1057,6 +1068,36 @@ export default class I18nPlugin extends AdminForthPlugin {
10571068
}
10581069
});
10591070

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+
10601101
}
10611102

10621103
}

0 commit comments

Comments
 (0)