@adminforth/i18n 1.10.1 → 2.0.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/build.log +3 -2
- package/custom/BulkActionButton.vue +59 -15
- package/custom/TranslationJobViewComponent.vue +82 -0
- package/dist/custom/BulkActionButton.vue +59 -15
- package/dist/custom/TranslationJobViewComponent.vue +82 -0
- package/dist/index.js +279 -93
- package/index.ts +382 -118
- package/package.json +3 -2
- package/types.ts +14 -0
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock } from "adminforth";
|
|
2
|
-
import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction, AdminForthConfigMenuItem } from "adminforth";
|
|
1
|
+
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock, filtersTools, AdminForthFilterOperators } from "adminforth";
|
|
2
|
+
import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction, AdminForthConfigMenuItem, AdminUser } from "adminforth";
|
|
3
3
|
import type { PluginOptions, SupportedLanguage } from './types.js';
|
|
4
4
|
import iso6391 from 'iso-639-1';
|
|
5
5
|
import { iso31661Alpha2ToAlpha3 } from 'iso-3166';
|
|
@@ -9,6 +9,9 @@ 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
|
+
import pLimit from 'p-limit';
|
|
13
|
+
import { randomUUID } from 'crypto';
|
|
14
|
+
import { afLogger } from "adminforth";
|
|
12
15
|
|
|
13
16
|
const processFrontendMessagesQueue = new AsyncQueue();
|
|
14
17
|
|
|
@@ -68,6 +71,12 @@ function isValidSupportedLanguageTag(langCode: SupportedLanguage): boolean {
|
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
interface IFailedTranslation {
|
|
75
|
+
lang: SupportedLanguage;
|
|
76
|
+
en_string: string;
|
|
77
|
+
failedReason: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
|
|
72
81
|
interface ICachingAdapter {
|
|
73
82
|
get(key: string): Promise<any>;
|
|
@@ -165,6 +174,15 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
165
174
|
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
166
175
|
super.modifyResourceConfig(adminforth, resourceConfig);
|
|
167
176
|
|
|
177
|
+
if (!this.adminforth.config.componentsToExplicitRegister) {
|
|
178
|
+
this.adminforth.config.componentsToExplicitRegister = [];
|
|
179
|
+
}
|
|
180
|
+
this.adminforth.config.componentsToExplicitRegister.push(
|
|
181
|
+
{
|
|
182
|
+
file: this.componentPath('TranslationJobViewComponent.vue')
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
168
186
|
// validate each supported language: ISO 639-1 or BCP-47 with region (e.g., en-GB)
|
|
169
187
|
this.options.supportedLanguages.forEach((lang) => {
|
|
170
188
|
if (!isValidSupportedLanguageTag(lang)) {
|
|
@@ -463,12 +481,11 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
463
481
|
if (!resourceConfig.options.pageInjections.list) {
|
|
464
482
|
resourceConfig.options.pageInjections.list = {};
|
|
465
483
|
}
|
|
466
|
-
if (!resourceConfig.options.pageInjections.list.
|
|
467
|
-
resourceConfig.options.pageInjections.list.
|
|
484
|
+
if (!resourceConfig.options.pageInjections.list.threeDotsDropdownItems) {
|
|
485
|
+
resourceConfig.options.pageInjections.list.threeDotsDropdownItems = [];
|
|
468
486
|
}
|
|
469
487
|
|
|
470
|
-
(resourceConfig.options.pageInjections.list.
|
|
471
|
-
|
|
488
|
+
(resourceConfig.options.pageInjections.list.threeDotsDropdownItems as AdminForthComponentDeclaration[]).push(pageInjection);
|
|
472
489
|
|
|
473
490
|
// if there is menu item with resourceId, add .badge function showing number of untranslated strings
|
|
474
491
|
const addBadgeCountToMenuItem = (menuItem: AdminForthConfigMenuItem) => {
|
|
@@ -494,51 +511,19 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
494
511
|
});
|
|
495
512
|
}
|
|
496
513
|
|
|
497
|
-
async
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (strings.length > maxKeysInOneReq) {
|
|
511
|
-
let totalTranslated = [];
|
|
512
|
-
for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
|
|
513
|
-
const slicedStrings = strings.slice(i, i + maxKeysInOneReq);
|
|
514
|
-
process.env.HEAVY_DEBUG && console.log('🪲🔪slicedStrings len', slicedStrings.length);
|
|
515
|
-
const madeKeys = await this.translateToLang(langIsoCode, slicedStrings, plurals, translations, updateStrings);
|
|
516
|
-
totalTranslated = totalTranslated.concat(madeKeys);
|
|
517
|
-
}
|
|
518
|
-
return totalTranslated;
|
|
519
|
-
}
|
|
520
|
-
const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
|
|
521
|
-
const lang = langIsoCode;
|
|
522
|
-
const primaryLang = getPrimaryLanguageCode(lang);
|
|
523
|
-
const langName = iso6391.getName(primaryLang);
|
|
524
|
-
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
|
|
525
|
-
const region = String(lang).split('-')[1]?.toUpperCase() || '';
|
|
526
|
-
const prompt = `
|
|
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.` : ''}
|
|
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]}"` : ''}
|
|
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:
|
|
531
|
-
|
|
532
|
-
\`\`\`json
|
|
533
|
-
${
|
|
534
|
-
JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object => {
|
|
535
|
-
acc[s.en_string] = '';
|
|
536
|
-
return acc;
|
|
537
|
-
}, {}), null, 2)
|
|
538
|
-
}
|
|
539
|
-
\`\`\`
|
|
540
|
-
`;
|
|
541
|
-
|
|
514
|
+
async generateAndSaveBunch (
|
|
515
|
+
prompt: string,
|
|
516
|
+
strings: { en_string: string, category: string }[],
|
|
517
|
+
translations: any[],
|
|
518
|
+
updateStrings: Record<string, { updates: any, category: string, strId: string, enStr: string, translatedStr: string }> = {},
|
|
519
|
+
lang: string,
|
|
520
|
+
failedToTranslate: IFailedTranslation[],
|
|
521
|
+
needToTranslateByLang: Record<string, any> = {},
|
|
522
|
+
jobId: string,
|
|
523
|
+
promptCost: number,
|
|
524
|
+
): Promise<void>{
|
|
525
|
+
|
|
526
|
+
// return [];
|
|
542
527
|
const jsonSchemaProperties = {};
|
|
543
528
|
strings.forEach(s => {
|
|
544
529
|
jsonSchemaProperties[s.en_string] = {
|
|
@@ -548,7 +533,7 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
548
533
|
});
|
|
549
534
|
|
|
550
535
|
const jsonSchemaRequired = strings.map(s => s.en_string);
|
|
551
|
-
|
|
536
|
+
const dedupRequired = Array.from(new Set(jsonSchemaRequired));
|
|
552
537
|
// call OpenAI
|
|
553
538
|
const resp = await this.options.completeAdapter.complete(
|
|
554
539
|
prompt,
|
|
@@ -560,36 +545,56 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
560
545
|
schema: {
|
|
561
546
|
type: "object",
|
|
562
547
|
properties: jsonSchemaProperties,
|
|
563
|
-
required:
|
|
548
|
+
required: dedupRequired,
|
|
564
549
|
},
|
|
565
550
|
},
|
|
566
551
|
}
|
|
567
552
|
);
|
|
568
|
-
|
|
553
|
+
|
|
569
554
|
process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM resp >> ${prompt.length}, <<${resp.content.length} :\n\n`, JSON.stringify(resp));
|
|
570
555
|
|
|
571
556
|
if (resp.error) {
|
|
572
557
|
throw new AiTranslateError(resp.error);
|
|
573
558
|
}
|
|
574
559
|
|
|
575
|
-
// parse response like
|
|
576
|
-
// Here are the translations for the strings you provided:
|
|
577
|
-
// ```json
|
|
578
|
-
// [{"live": "canlı"}, {"Table Games": "Masa Oyunları"}]
|
|
579
|
-
// ```
|
|
580
560
|
let res;
|
|
581
561
|
try {
|
|
582
|
-
res = resp.content
|
|
562
|
+
res = resp.content;
|
|
583
563
|
} catch (e) {
|
|
584
564
|
console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
|
|
585
|
-
|
|
565
|
+
strings.forEach(s => {
|
|
566
|
+
failedToTranslate.push({
|
|
567
|
+
lang: lang as SupportedLanguage,
|
|
568
|
+
en_string: s.en_string,
|
|
569
|
+
failedReason: "Error in parsing LLM response"
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
return null;
|
|
586
573
|
}
|
|
587
574
|
|
|
575
|
+
const backgroundJobsPlugin = this.adminforth.getPluginByClassName<any>('BackgroundJobsPlugin');
|
|
576
|
+
|
|
577
|
+
backgroundJobsPlugin.updateJobFieldsAtomically(jobId, async () => {
|
|
578
|
+
// do all set / get fields in this function to make state update atomic and there is no conflicts when 2 tasks in parallel do get before set.
|
|
579
|
+
// don't do long awaits in this callback, since it has exclusive lock.
|
|
580
|
+
let totalUsedTokens = await backgroundJobsPlugin.getJobField(jobId, 'totalUsedTokens');
|
|
581
|
+
totalUsedTokens += promptCost;
|
|
582
|
+
await backgroundJobsPlugin.setJobField(jobId, 'totalUsedTokens', totalUsedTokens);
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
|
|
588
586
|
try {
|
|
589
587
|
res = JSON.parse(res);
|
|
590
588
|
} catch (e) {
|
|
591
589
|
console.error(`Error in parsing LLM resp json: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
|
|
592
|
-
|
|
590
|
+
strings.forEach(s => {
|
|
591
|
+
failedToTranslate.push({
|
|
592
|
+
lang: lang as SupportedLanguage,
|
|
593
|
+
en_string: s.en_string,
|
|
594
|
+
failedReason: "Error in parsing LLM response JSON"
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
return null;
|
|
593
598
|
}
|
|
594
599
|
|
|
595
600
|
|
|
@@ -598,7 +603,6 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
598
603
|
// might be several with same en_string
|
|
599
604
|
for (const translation of translationsTargeted) {
|
|
600
605
|
//translation[this.trFieldNames[lang]] = translatedStr;
|
|
601
|
-
// process.env.HEAVY_DEBUG && console.log(`🪲translated to ${lang} ${translation.en_string}, ${translatedStr}`)
|
|
602
606
|
if (!updateStrings[translation[this.primaryKeyFieldName]]) {
|
|
603
607
|
|
|
604
608
|
updateStrings[translation[this.primaryKeyFieldName]] = {
|
|
@@ -620,16 +624,181 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
620
624
|
}
|
|
621
625
|
}
|
|
622
626
|
|
|
623
|
-
|
|
627
|
+
const langsInvolved = new Set(Object.keys(needToTranslateByLang));
|
|
628
|
+
|
|
629
|
+
// here we need to save updateStrings
|
|
630
|
+
await Promise.all(
|
|
631
|
+
Object.entries(updateStrings).map(
|
|
632
|
+
async ([_, { updates, strId }]: [string, { updates: any, category: string, strId: string }]) => {
|
|
633
|
+
// get old full record
|
|
634
|
+
const oldRecord = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.primaryKeyFieldName, strId)]);
|
|
635
|
+
|
|
636
|
+
// because this will translate all languages, we can set completedLangs to all languages
|
|
637
|
+
const futureCompletedFieldValue = await this.computeCompletedFieldValue({ ...oldRecord, ...updates });
|
|
638
|
+
|
|
639
|
+
await this.adminforth.resource(this.resourceConfig.resourceId).update(strId, {
|
|
640
|
+
...updates,
|
|
641
|
+
[this.options.completedFieldName]: futureCompletedFieldValue,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
)
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
for (const lang of langsInvolved) {
|
|
649
|
+
const categoriesInvolved = new Set();
|
|
650
|
+
for (const { enStr, category } of Object.values(updateStrings)) {
|
|
651
|
+
categoriesInvolved.add(category);
|
|
652
|
+
await this.cache.clear(`${this.resourceConfig.resourceId}:${category}:${lang}:${enStr}`);
|
|
653
|
+
}
|
|
654
|
+
for (const category of categoriesInvolved) {
|
|
655
|
+
await this.cache.clear(`${this.resourceConfig.resourceId}:${category}:${lang}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
624
659
|
}
|
|
625
660
|
|
|
626
|
-
|
|
627
|
-
|
|
661
|
+
async getTranslateToLangTasks (
|
|
662
|
+
langIsoCode: SupportedLanguage,
|
|
663
|
+
strings: { id: string, en_string: string, category: string }[],
|
|
664
|
+
plurals=false,
|
|
665
|
+
translations: any[],
|
|
666
|
+
updateStrings: Record<string, { updates: any, category: string, strId: string, enStr: string, translatedStr: string }> = {},
|
|
667
|
+
needToTranslateByLang: Record<string, any> = {},
|
|
668
|
+
adminUser: AdminUser,
|
|
669
|
+
): Promise<any> {
|
|
670
|
+
|
|
671
|
+
const maxInputTokens = this.options.inputTokensPerBatch ?? 30000;
|
|
672
|
+
|
|
673
|
+
const limit = pLimit(30);
|
|
674
|
+
const enStringsTokenLengthCache: Record<string, any> = {};
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
const tokenLengthPerString = async ({ str, id }: { str: string; id: string }): Promise<void> => {
|
|
678
|
+
const objectToPush = {
|
|
679
|
+
en_string: str,
|
|
680
|
+
numOfTokens: await this.options.completeAdapter.measureTokensCount(`"${str}":"", \n`)
|
|
681
|
+
};
|
|
682
|
+
enStringsTokenLengthCache[id] = objectToPush;
|
|
683
|
+
}
|
|
684
|
+
const promises = strings.map(s => limit(() => tokenLengthPerString({ str: s.en_string, id: s.id })));
|
|
685
|
+
|
|
686
|
+
await Promise.all(promises);
|
|
687
|
+
if (strings.length === 0) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode as any] : null;
|
|
692
|
+
const langSchema = parse(String(langIsoCode),
|
|
693
|
+
{
|
|
694
|
+
normalize: true,
|
|
695
|
+
}
|
|
696
|
+
);
|
|
697
|
+
const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
|
|
698
|
+
const lang = langIsoCode;
|
|
699
|
+
const primaryLang = getPrimaryLanguageCode(lang);
|
|
700
|
+
const langName = iso6391.getName(primaryLang);
|
|
701
|
+
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
|
|
702
|
+
const region = langSchema.region;
|
|
703
|
+
const basePrompt = `
|
|
704
|
+
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.
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
${region ? `Use the regional conventions for ${langCode} (region ${region}), including spelling, punctuation, and formatting.` : ''}
|
|
708
|
+
${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]}"` : ''}
|
|
709
|
+
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:
|
|
710
|
+
\`\`\`json
|
|
711
|
+
{
|
|
628
712
|
|
|
713
|
+
}
|
|
714
|
+
\`\`\`
|
|
715
|
+
`;
|
|
716
|
+
|
|
717
|
+
const failedToTranslate: IFailedTranslation[] = [];
|
|
718
|
+
const basePromptTokenLength = await this.options.completeAdapter.measureTokensCount(basePrompt);
|
|
719
|
+
const allowedTokensAmountForFields = maxInputTokens - basePromptTokenLength;
|
|
720
|
+
const stringsToTranslate: Record<string, { id: string; en_string: string; category: string }> = Object.fromEntries(strings.map(s => [s.id, s]));
|
|
721
|
+
const generationTasksInitialData = []
|
|
722
|
+
while (Object.keys(stringsToTranslate).length !== 0) {
|
|
723
|
+
const stringBanch = [];
|
|
724
|
+
const stringIdsInBatch = [];
|
|
725
|
+
let banchTokens = 0;
|
|
726
|
+
for (const id of Object.keys(stringsToTranslate)) {
|
|
727
|
+
const { en_string } = stringsToTranslate[id];
|
|
728
|
+
const numberOfTokensForString = enStringsTokenLengthCache[id]?.numOfTokens || 0;
|
|
729
|
+
if( banchTokens + numberOfTokensForString <= allowedTokensAmountForFields ) {
|
|
730
|
+
stringBanch.push( en_string );
|
|
731
|
+
stringIdsInBatch.push(id);
|
|
732
|
+
banchTokens += numberOfTokensForString;
|
|
733
|
+
} else {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if ( stringBanch.length === 0 ) {
|
|
738
|
+
Object.values(stringsToTranslate).forEach(s => {
|
|
739
|
+
failedToTranslate.push({
|
|
740
|
+
lang,
|
|
741
|
+
en_string: s.en_string,
|
|
742
|
+
failedReason: "Not enough input generation tokens"
|
|
743
|
+
});
|
|
744
|
+
generationTasksInitialData.push(
|
|
745
|
+
{
|
|
746
|
+
state: {
|
|
747
|
+
failedToTranslate,
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
)
|
|
751
|
+
});
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
for ( const id of stringIdsInBatch) {
|
|
756
|
+
delete stringsToTranslate[id];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const promptToGenerate = basePrompt.split(`\`\`\`json`)[0] +
|
|
760
|
+
`\`\`\`json
|
|
761
|
+
{
|
|
762
|
+
${
|
|
763
|
+
stringBanch.map(s => `"${s}": ""`).join(",\n")
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
\`\`\``;
|
|
767
|
+
const stringBanchCopy = [...stringBanch];
|
|
768
|
+
generationTasksInitialData.push(
|
|
769
|
+
{
|
|
770
|
+
state: {
|
|
771
|
+
taskName: `Translate ${strings.length} strings`,
|
|
772
|
+
prompt: promptToGenerate,
|
|
773
|
+
strings: strings.filter(s => stringBanchCopy.includes(s.en_string)),
|
|
774
|
+
translations: translations.filter(t => stringBanchCopy.includes(t.en_string)),
|
|
775
|
+
updateStrings,
|
|
776
|
+
lang,
|
|
777
|
+
failedToTranslate,
|
|
778
|
+
needToTranslateByLang,
|
|
779
|
+
promptCost: basePromptTokenLength + banchTokens,
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return generationTasksInitialData;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
// returns translated count
|
|
790
|
+
async bulkTranslate({ selectedIds, selectedLanguages, adminUser }:
|
|
791
|
+
{
|
|
792
|
+
selectedIds: string[],
|
|
793
|
+
selectedLanguages?: SupportedLanguage[],
|
|
794
|
+
adminUser: AdminUser,
|
|
795
|
+
}): Promise<string>
|
|
796
|
+
{
|
|
629
797
|
const needToTranslateByLang : Partial<
|
|
630
798
|
Record<
|
|
631
799
|
SupportedLanguage,
|
|
632
800
|
{
|
|
801
|
+
id: string;
|
|
633
802
|
en_string: string;
|
|
634
803
|
category: string;
|
|
635
804
|
}[]
|
|
@@ -649,6 +818,7 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
649
818
|
needToTranslateByLang[lang] = [];
|
|
650
819
|
}
|
|
651
820
|
needToTranslateByLang[lang].push({
|
|
821
|
+
id: translation[this.primaryKeyFieldName],
|
|
652
822
|
'en_string': translation[this.enFieldName],
|
|
653
823
|
category: translation[this.options.categoryFieldName],
|
|
654
824
|
})
|
|
@@ -665,58 +835,39 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
665
835
|
}> = {};
|
|
666
836
|
|
|
667
837
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
let totalTranslated = [];
|
|
671
|
-
|
|
838
|
+
let generationTasksInitialData = [];
|
|
672
839
|
await Promise.all(
|
|
673
840
|
Object.entries(needToTranslateByLang).map(
|
|
674
|
-
async ([lang, strings]: [SupportedLanguage, { en_string: string, category: string }[]]) => {
|
|
841
|
+
async ([lang, strings]: [SupportedLanguage, { id: string, en_string: string, category: string }[]]) => {
|
|
675
842
|
// first translate without plurals
|
|
676
843
|
const stringsWithoutPlurals = strings.filter(s => !s.en_string.includes('|'));
|
|
677
|
-
const
|
|
678
|
-
|
|
844
|
+
const noPluralTranslationsTasks = await this.getTranslateToLangTasks(lang, stringsWithoutPlurals, false, translations, updateStrings, needToTranslateByLang, adminUser);
|
|
679
845
|
|
|
680
846
|
const stringsWithPlurals = strings.filter(s => s.en_string.includes('|'));
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
totalTranslated = totalTranslated.concat(noPluralKeys, pluralKeys);
|
|
847
|
+
const pluralTranslationsTasks = await this.getTranslateToLangTasks(lang, stringsWithPlurals, true, translations, updateStrings, needToTranslateByLang, adminUser);
|
|
848
|
+
generationTasksInitialData = generationTasksInitialData.concat(noPluralTranslationsTasks || []).concat(pluralTranslationsTasks || []);
|
|
685
849
|
}
|
|
686
850
|
)
|
|
687
851
|
);
|
|
688
852
|
|
|
689
|
-
await Promise.all(
|
|
690
|
-
Object.entries(updateStrings).map(
|
|
691
|
-
async ([_, { updates, strId }]: [string, { updates: any, category: string, strId: string }]) => {
|
|
692
|
-
// get old full record
|
|
693
|
-
const oldRecord = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.primaryKeyFieldName, strId)]);
|
|
694
|
-
|
|
695
|
-
// because this will translate all languages, we can set completedLangs to all languages
|
|
696
|
-
const futureCompletedFieldValue = await this.computeCompletedFieldValue({ ...oldRecord, ...updates });
|
|
697
853
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
854
|
+
const totalTranslationTokenCost = generationTasksInitialData.reduce((a, b) => a + (b.state?.promptCost || 0), 0);
|
|
855
|
+
const backgroundJobsPlugin = this.adminforth.getPluginByClassName<any>('BackgroundJobsPlugin');
|
|
856
|
+
const jobId = await backgroundJobsPlugin.startNewJob(
|
|
857
|
+
`Translate ${selectedIds.length} items`, //job name
|
|
858
|
+
adminUser, // adminuser
|
|
859
|
+
generationTasksInitialData, //initial tasks
|
|
860
|
+
'translation_job_handler', //job handler name
|
|
704
861
|
);
|
|
705
862
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
categoriesInvolved.add(category);
|
|
710
|
-
await this.cache.clear(`${this.resourceConfig.resourceId}:${category}:${lang}:${enStr}`);
|
|
711
|
-
}
|
|
712
|
-
for (const category of categoriesInvolved) {
|
|
713
|
-
await this.cache.clear(`${this.resourceConfig.resourceId}:${category}:${lang}`);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
863
|
+
afLogger.info(`Started background job with ID ${jobId} `);
|
|
864
|
+
await backgroundJobsPlugin.setJobField(jobId, 'totalTranslationTokenCost', totalTranslationTokenCost);
|
|
865
|
+
await backgroundJobsPlugin.setJobField(jobId, 'totalUsedTokens', 0);
|
|
716
866
|
|
|
717
|
-
return
|
|
867
|
+
return jobId
|
|
718
868
|
}
|
|
719
869
|
|
|
870
|
+
|
|
720
871
|
async processExtractedMessages(adminforth: IAdminForth, filePath: string) {
|
|
721
872
|
await processFrontendMessagesQueue.wait();
|
|
722
873
|
// messages file is in i18n-messages.json
|
|
@@ -768,6 +919,69 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
768
919
|
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
769
920
|
// optional method where you can safely check field types after database discovery was performed
|
|
770
921
|
// ensure each trFieldName (apart from enFieldName) is nullable column of type string
|
|
922
|
+
const backgroundJobsPlugin = adminforth.getPluginByClassName<any>('BackgroundJobsPlugin');
|
|
923
|
+
|
|
924
|
+
if (!backgroundJobsPlugin) {
|
|
925
|
+
throw new Error(`BackgroundJobsPlugin is required for ${this.constructor.name} to work, please add it to your plugins`);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
backgroundJobsPlugin.registerTaskHandler({
|
|
929
|
+
// job handler name
|
|
930
|
+
jobHandlerName: 'translation_job_handler',
|
|
931
|
+
//handler function
|
|
932
|
+
handler: async ({ jobId, setTaskStateField, getTaskStateField }) => {
|
|
933
|
+
const initialState: {
|
|
934
|
+
taskName: string,
|
|
935
|
+
prompt?: string,
|
|
936
|
+
strings?: { en_string: string, category: string }[],
|
|
937
|
+
translations?: any[],
|
|
938
|
+
updateStrings?: Record<string, { updates: any, category: string, strId: string, enStr: string, translatedStr: string }>,
|
|
939
|
+
lang?: string,
|
|
940
|
+
failedToTranslate?: IFailedTranslation[],
|
|
941
|
+
needToTranslateByLang?: Record<string, any>,
|
|
942
|
+
promptCost?: number,
|
|
943
|
+
} = await getTaskStateField();
|
|
944
|
+
|
|
945
|
+
if ( initialState.prompt && initialState.strings && initialState.translations && initialState.updateStrings && initialState.lang && initialState.failedToTranslate && initialState.needToTranslateByLang) {
|
|
946
|
+
await this.generateAndSaveBunch(
|
|
947
|
+
initialState.prompt,
|
|
948
|
+
initialState.strings,
|
|
949
|
+
initialState.translations,
|
|
950
|
+
initialState.updateStrings,
|
|
951
|
+
initialState.lang,
|
|
952
|
+
initialState.failedToTranslate,
|
|
953
|
+
initialState.needToTranslateByLang,
|
|
954
|
+
jobId,
|
|
955
|
+
initialState.promptCost
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
afLogger.debug(`Translation task for language ${initialState.lang} completed.`);
|
|
959
|
+
|
|
960
|
+
const stateToSave = {
|
|
961
|
+
taskName: initialState.taskName,
|
|
962
|
+
lang: initialState.lang,
|
|
963
|
+
failedToTranslate: initialState.failedToTranslate,
|
|
964
|
+
}
|
|
965
|
+
await setTaskStateField(stateToSave);
|
|
966
|
+
|
|
967
|
+
this.adminforth.websocket.publish('/translation_progress', {});
|
|
968
|
+
if (initialState.failedToTranslate.length > 0) {
|
|
969
|
+
afLogger.error(`Failed to translate some strings for language ${initialState.lang} in plugin ${this.constructor.name}:, ${initialState.failedToTranslate}`);
|
|
970
|
+
throw new Error(`Failed to translate some strings for language ${initialState.lang}, check job details for more info`);
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
//limit of tasks, that are running in parallel
|
|
974
|
+
parallelLimit: this.options.parallelTranslationLimit || 20,
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
backgroundJobsPlugin.registerTaskDetailsComponent({
|
|
978
|
+
jobHandlerName: 'translation_job_handler', // Handler name
|
|
979
|
+
component: {
|
|
980
|
+
file: this.componentPath('TranslationJobViewComponent.vue') //custom component for the job details
|
|
981
|
+
},
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
|
|
771
985
|
if (this.options.completeAdapter) {
|
|
772
986
|
this.options.completeAdapter.validate();
|
|
773
987
|
}
|
|
@@ -1071,31 +1285,81 @@ export default class I18nPlugin extends AdminForthPlugin {
|
|
|
1071
1285
|
method: 'POST',
|
|
1072
1286
|
path: `/plugin/${this.pluginInstanceId}/translate-selected-to-languages`,
|
|
1073
1287
|
noAuth: false,
|
|
1074
|
-
handler: async ({ body, tr }) => {
|
|
1288
|
+
handler: async ({ body, tr, adminUser }) => {
|
|
1075
1289
|
const selectedLanguages = body.selectedLanguages;
|
|
1076
1290
|
const selectedIds = body.selectedIds;
|
|
1291
|
+
|
|
1292
|
+
const jobId = await this.bulkTranslate({ selectedIds, selectedLanguages, adminUser });
|
|
1077
1293
|
|
|
1078
|
-
let translatedCount = 0;
|
|
1079
|
-
try {
|
|
1080
|
-
translatedCount = await this.bulkTranslate({ selectedIds, selectedLanguages });
|
|
1081
|
-
} catch (e) {
|
|
1082
|
-
process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
|
|
1083
|
-
if (e instanceof AiTranslateError) {
|
|
1084
|
-
return { ok: false, error: e.message };
|
|
1085
|
-
}
|
|
1086
|
-
throw e;
|
|
1087
|
-
}
|
|
1088
|
-
this.updateUntranslatedMenuBadge();
|
|
1089
1294
|
return {
|
|
1090
1295
|
ok: true,
|
|
1091
|
-
|
|
1092
|
-
successMessage: await tr(`Translated {count} items`, 'backend', {
|
|
1093
|
-
count: translatedCount,
|
|
1094
|
-
}),
|
|
1296
|
+
jobId: jobId,
|
|
1095
1297
|
};
|
|
1096
1298
|
}
|
|
1097
1299
|
});
|
|
1098
1300
|
|
|
1301
|
+
server.endpoint({
|
|
1302
|
+
method: 'POST',
|
|
1303
|
+
path: `/plugin/${this.pluginInstanceId}/get_filtered_ids`,
|
|
1304
|
+
handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
|
|
1305
|
+
const resource = this.resourceConfig;
|
|
1306
|
+
|
|
1307
|
+
for (const hook of resource.hooks?.list?.beforeDatasourceRequest || []) {
|
|
1308
|
+
const filterTools = filtersTools.get(body);
|
|
1309
|
+
body.filtersTools = filterTools;
|
|
1310
|
+
const resp = await hook({
|
|
1311
|
+
resource,
|
|
1312
|
+
query: body,
|
|
1313
|
+
adminUser,
|
|
1314
|
+
//@ts-ignore
|
|
1315
|
+
filtersTools: filterTools,
|
|
1316
|
+
extra: {
|
|
1317
|
+
body, query, headers, cookies, requestUrl
|
|
1318
|
+
},
|
|
1319
|
+
adminforth: this.adminforth,
|
|
1320
|
+
});
|
|
1321
|
+
if (!resp || (!resp.ok && !resp.error)) {
|
|
1322
|
+
throw new Error(`Hook must return object with {ok: true} or { error: 'Error' } `);
|
|
1323
|
+
}
|
|
1324
|
+
if (resp.error) {
|
|
1325
|
+
return { error: resp.error };
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
const filters = body.filters;
|
|
1329
|
+
|
|
1330
|
+
const normalizedFilters = { operator: AdminForthFilterOperators.AND, subFilters: [] };
|
|
1331
|
+
if (filters) {
|
|
1332
|
+
if (typeof filters !== 'object') {
|
|
1333
|
+
throw new Error(`Filter should be an array or an object`);
|
|
1334
|
+
}
|
|
1335
|
+
if (Array.isArray(filters)) {
|
|
1336
|
+
// if filters are an array, they will be connected with "AND" operator by default
|
|
1337
|
+
normalizedFilters.subFilters = filters;
|
|
1338
|
+
} else if (filters.field) {
|
|
1339
|
+
// assume filter is a SingleFilter
|
|
1340
|
+
normalizedFilters.subFilters = [filters];
|
|
1341
|
+
} else if (filters.subFilters) {
|
|
1342
|
+
// assume filter is a AndOr filter
|
|
1343
|
+
normalizedFilters.operator = filters.operator;
|
|
1344
|
+
normalizedFilters.subFilters = filters.subFilters;
|
|
1345
|
+
} else {
|
|
1346
|
+
// wrong filter
|
|
1347
|
+
throw new Error(`Wrong filter object value: ${JSON.stringify(filters)}`);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const records = await this.adminforth.resource(this.resourceConfig.resourceId).list(normalizedFilters);
|
|
1352
|
+
if (!records) {
|
|
1353
|
+
return { ok: true, recordIds: [] };
|
|
1354
|
+
}
|
|
1355
|
+
const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
|
|
1356
|
+
|
|
1357
|
+
const recordIds = records.map(record => record[primaryKeyColumn.name]);
|
|
1358
|
+
|
|
1359
|
+
return { ok: true, recordIds }
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1099
1363
|
}
|
|
1100
1364
|
|
|
1101
1365
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adminforth/i18n",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"country-flag-svg": "^1.0.19",
|
|
36
36
|
"fs-extra": "^11.3.2",
|
|
37
37
|
"iso-3166": "^4.3.0",
|
|
38
|
-
"iso-639-1": "^3.1.3"
|
|
38
|
+
"iso-639-1": "^3.1.3",
|
|
39
|
+
"p-limit": "^7.3.0"
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"adminforth": "next"
|
package/types.ts
CHANGED
|
@@ -74,4 +74,18 @@ export interface PluginOptions {
|
|
|
74
74
|
* key - one of the values form supportedLanguages, value -BCP47 tag
|
|
75
75
|
*/
|
|
76
76
|
translateLangAsBCP47Code?: Partial<Record<LanguageCode, Bcp47LanguageTag>>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Batch size of one translation generation request.
|
|
80
|
+
* This is an optional parameter that can be used to control the size of strings sent in a single request to the completion adapter.
|
|
81
|
+
* Default value is 30000 tokens
|
|
82
|
+
*/
|
|
83
|
+
inputTokensPerBatch?: number;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Limit of parallel translation generation requests.
|
|
87
|
+
* This is an optional parameter that can be used to control the number of concurrent requests sent to the completion adapter.
|
|
88
|
+
* Default value is 20
|
|
89
|
+
*/
|
|
90
|
+
parallelTranslationLimit?: number;
|
|
77
91
|
}
|