import { Injectable } from '@angular/core'; import { BulkUpdateFunctionNames, GetTranslationsByLanguageFunctionNames, GetTranslationsFunctionNames, MachineTranslateBulkFunctionNames, MachineTranslateSingleFunctionNames, TranslationResources } from '@core/resources/translation.resources'; import { TranslationState } from '@core/states/translation.state'; import { ReferenceFieldAPI } from '@core/typings/api/reference-fields.typing'; import { ProgramDetail } from '@core/typings/program.typing'; import { BaseTranslation, BulkTranslationsUpdate, ExportTranslation, ProgramTranslationMap, TranslatableItems, Translation, TranslationKeyValue, ViewTranslations, ViewTranslationsBlank } from '@core/typings/translation.typing'; import { ReferenceFieldsUI } from '@core/typings/ui/reference-fields.typing'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { FormDefinitionForUi } from '@features/configure-forms/form.typing'; import { REQUIRED_MESSAGE, SPECIAL_HANDLING_FIELDS, SPECIAL_HANDLING_REQUIRED_DESC } from '@features/formio/formio-components/standard-formio-components/gc-special-handling/special-handling.constants'; import { ComponentHelperService } from '@features/formio/services/component-helper/component-helper.service'; import { UserService } from '@features/users/user.service'; import { ArrayHelpersService, AutoTableRepositoryFactory, FileService, PaginationOptions, SelectOption, TranslationService as CommonTranslationService } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { NotifierService } from '@yourcause/common/notifier'; import { AttachYCState, BaseYCService } from '@yourcause/common/state'; import { union, uniq } from 'lodash'; import * as parse from 'papaparse'; import { SpinnerService } from './spinner.service'; @AttachYCState(TranslationState) @Injectable({ providedIn: 'root' }) export class TranslationService extends BaseYCService { constructor ( private logger: LogService, private i18n: I18nService, private notifier: NotifierService, private fileService: FileService, private spinnerService: SpinnerService, private userService: UserService, private autoTableFactory: AutoTableRepositoryFactory, private translationResources: TranslationResources, private arrayHelper: ArrayHelpersService, private clientSettingsService: ClientSettingsService, private commonTranslationService: CommonTranslationService, private componentHelper: ComponentHelperService ) { super(); } get viewTranslations (): ViewTranslations { return this.get('viewTranslations') || ViewTranslationsBlank; } setViewTranslationsOnState (viewTranslations: ViewTranslations) { this.set('viewTranslations', viewTranslations); } async resetViewTranslations (showSpinner = true) { this.set('viewTranslations', undefined); await this.setViewTranslations(showSpinner); } translateLanguageName (key: string) { return this.commonTranslationService.translateLanguageName(key); } async setViewTranslations (showSpinner = true) { if (!this.get('viewTranslations')) { if (showSpinner) { this.spinnerService.startSpinner(); } try { const viewTranslations = await this.translationResources.getTranslationsForView(); const formatted = this.decodeViewTranslations(viewTranslations); this.setViewTranslationsOnState(formatted); } catch (e) { this.logger.error(e); } if (showSpinner) { this.spinnerService.stopSpinner(); } } } decodeViewTranslations (translations: TranslationKeyValue[]): ViewTranslations { if (translations.length === 0) { return { Grant_Program: {}, FormTranslation: {}, Grant_Program_Cycle: {} }; } const decoded = translations.reduce((acc, item) => { const strippedKey = item.key.replace(/Client\.\d+\./, ''); const [ objName, objId, attr ] = strippedKey.split('.'); if (['Grant_Program', 'FormTranslation', 'Grant_Program_Cycle'].includes(objName)) { const objMap = acc[objName] || {}; objMap[objId] = objMap[objId] || {}; const recordMap = objMap[objId]; recordMap[attr] = item.value; acc[objName] = objMap; } return acc; }, {}); if (!decoded.Grant_Program) { decoded.Grant_Program = {}; } if (!decoded.FormTranslation) { decoded.FormTranslation = {}; } if (!decoded.Grant_Program_Cycle) { decoded.Grant_Program_Cycle = {}; } return decoded; } getMostCommonDefaultLangFromArray ( items: TranslatableItems[], includedIds: number[]|string[] ) { // Pass in the translatable items, and return the most common default language const langs = items.filter((item) => { return !!item.defaultLanguageId && includedIds && ( includedIds.length === 0 || (includedIds as any).includes(item.id) ); }).map((prog) => prog.defaultLanguageId); if (langs.length > 0) { const langObj = langs.reduce((acc: any, val) => { return { ...acc, [val]: acc[val] ? acc[val] + 1 : 1 }; }, {}); return Object.keys(langObj).reduce((a, b) => { return langObj[a] > langObj[b] ? a : b; }); } return 'en-US'; } // Start Form Specific async getFormTranslationsByLanguage ( formId: number ) { const isManager = this.clientSettingsService.isManager; const func: GetTranslationsByLanguageFunctionNames = isManager ? 'getFormTranslationsByLanguage' : 'getFormTranslationsByLanguageApplicant'; if (this.clientSettingsService.clientSettings.hasInternational) { const lang = this.userService.getCurrentUserCulture(); const translations = await this.translationResources[func]( formId, lang ); const standardMap: { [x: string]: string; } = {}; const richTextMap: { [x: string]: string; } = {}; translations.forEach((translation) => { const translated = translation.translations.find((item) => { return item.language === lang; }) || {} as Translation; const rightSide = translated.translation || translation.defaultTranslation; const attr = translation.defaultTranslation; if (translation.isRichText) { richTextMap[attr] = rightSide; } else { standardMap[attr] = rightSide; } }); return { richTextMap, standardMap }; } return { richTextMap: {}, standardMap: {} }; } async extractFormComponentsForTranslation ( formDefinition: FormDefinitionForUi[], referenceFieldMap: Record, tableColumnsMap: Record, dataPointsMap: Record ) { let formAttributes: string[] = []; const richTextAttributes: string[] = []; formDefinition.forEach((tab) => { this.componentHelper.eachComponent( tab.components, (component) => { let extraFields: string[] = []; if (component.type === 'specialHandling') { extraFields = [ component.specialHandlingInstructions, SPECIAL_HANDLING_REQUIRED_DESC, REQUIRED_MESSAGE, ...SPECIAL_HANDLING_FIELDS ]; } if (component.type === 'inKindItems') { extraFields = [ component.validationErrorMessage ]; } if (this.componentHelper.isReferenceFieldComp(component.type)) { const key = component.type.split('-')[1]; const field = referenceFieldMap[key]; if (field?.type === ReferenceFieldsUI.ReferenceFieldTypes.Table) { const columns = tableColumnsMap[field.referenceFieldId]; columns.forEach((column) => { extraFields.push(column.label); }); } else if (field?.type === ReferenceFieldsUI.ReferenceFieldTypes.Subset) { const columns = dataPointsMap[field.referenceFieldId]; columns.forEach((column) => { extraFields.push(column.label); }); } } if (!!component.html && component.type === 'content') { richTextAttributes.push((component.html || '').trim()); } const potentialLangs: string[] = uniq( [ tab.tabName, component.label, component.placeholder, component.description, component.errorLabel, component.tooltip, component.tooltipText, component.legend, component.prefix, component.suffix, component.title, component.validate ? component.validate.customMessage : undefined, ...extraFields ].filter((lang) => !!lang) ); formAttributes = union(formAttributes, potentialLangs).map((attr) => { return (attr || '').trim(); }); }, true ); }); return { formAttributes, richTextAttributes }; } // End Form Specific // // Start Program Specific // getProgramTranslationMapForApplicant ( program: ProgramDetail ): ProgramTranslationMap { const map: ProgramTranslationMap = { name: program.grantProgramName, description: program.grantProgramDescription }; const lang = this.userService.getCurrentUserCulture(); program.nameLanguageTranslations.forEach((item) => { if (item.language === lang) { map.name = item.translation || program.grantProgramName; } }); program.descriptionLanguageTranslations.forEach((item) => { if (item.language === lang) { map.description = item.translation || program.grantProgramDescription; } }); return map; } // End Program Specific getDefaultLangSelectorValue () { const langKeys = this.clientSettingsService.get('selectedLanguages'); const alpha = this.arrayHelper.sort( langKeys.filter((key: string) => { return key !== this.clientSettingsService.defaultLanguage; }).map((key: string) => { const obj = { label: this.translateLanguageName(key), value: key }; return obj; }), 'label'); return alpha[0] ? alpha[0].value : this.clientSettingsService.defaultLanguage; } getFilteredTranslations ( repoName: string, columnName: string, optionsAttr: string, getTranslationsFunctionName: GetTranslationsFunctionNames, dropdownOptions: SelectOption[] ) { let paginationOptions: PaginationOptions; const table = this.autoTableFactory.getRepository(repoName); if (table) { paginationOptions = table.getPaginationOptions(); } else { paginationOptions = { rowsPerPage: 1000, pageNumber: 1, sortColumns: [], filterColumns: [], orFilterColumns: [], retrieveTotalRecordCount: true, returnAll: true }; } const options = this.formatPaginationOptions( paginationOptions, columnName, optionsAttr, dropdownOptions ); return this.translationResources[getTranslationsFunctionName]( options, (options as any)[optionsAttr] ); } async bulkUpdateTranslations ( translationInfo: BulkTranslationsUpdate[], repoName: string, bulkUpdateFunctionName: BulkUpdateFunctionNames, skipToastr = false ) { try { await this.translationResources[bulkUpdateFunctionName](translationInfo); if (!skipToastr) { this.notifier.success(this.i18n.translate( 'GLOBAL:textSuccessfullyUpdatedTranslations', {}, 'Successfully updated translations' )); } this.resetTranslationRepo(repoName); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'GLOBAL:textErrorUpdatingTranslations', {}, 'There was an error updating translations' )); } } resetTranslationRepo (repoName: string) { const repo = this.autoTableFactory.getRepository(repoName); if (repo) { repo.reset(repo.pageNumber); } } async machineTranslateSingle ( languageKeyId: number, language: string, defaultText: string, repoName: string, machineTranslateFunctionName: MachineTranslateSingleFunctionNames ) { this.spinnerService.startSpinner(); try { await this.translationResources[machineTranslateFunctionName]( languageKeyId, language, defaultText ); this.resetTranslationRepo(repoName); this.notifier.success(this.i18n.translate( 'GLOBAL:textSuccessMachineTranslateSingle', {}, 'Successfully added the machine translation' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'GLOBAL:textErrorMachineTranslateSingle', {}, 'There was an error adding the machine translation' )); } this.spinnerService.stopSpinner(); } async machineTranslateBulk ( languages: string[], repoName: string, machineTranslateBulkFunctionName: MachineTranslateBulkFunctionNames ) { try { await this.translationResources[machineTranslateBulkFunctionName]( languages ); this.resetTranslationRepo(repoName); this.notifier.success(this.i18n.translate( 'GLOBAL:textSuccessMachineTranslatedBulk', {}, 'Successfully added machine translations to all empty fields' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'GLOBAL:textErrorMachineTranslateBulk', {}, 'There was an error adding machine translations to all empty fields' )); } } async importTranslations ( langs: string[], repoName: string, bulkUpdateFunctionName: BulkUpdateFunctionNames ) { const input = document.createElement('input'); input.type = 'file'; input.hidden = true; input.addEventListener('change', (event: any) => { const [file] = event.target.files; const fileReader = new FileReader(); fileReader.addEventListener('loadend', async (loadEvent: any) => { const contents: string = loadEvent.target.result; const parsed = parse.parse(contents, { header: true }); const errors = this.verifyUpload(parsed.data, langs); if (!errors) { await this.prepareBulkUpdate( parsed.data, langs, repoName, bulkUpdateFunctionName ); } else { const topLevelError = this.i18n.translate( 'GLOBAL:textImportTranslationErrors', {}, 'We found the following errors with the file' ); this.notifier.error( topLevelError + ':' + '
' + errors ); } document.body.removeChild(input); }); fileReader.readAsText(file); }); document.body.appendChild(input); input.click(); } verifyUpload (parsed: ExportTranslation[], langs: string[]) { let langKeyIdErrors = 0; let extraKeyErrors = 0; parsed = this.removeEmptyRows(parsed); parsed.forEach((result) => { if (!result.languageKeyId) { langKeyIdErrors++; } const extraKeys = Object.keys(result).filter(key => { return (!langs.concat(['languageKeyId', 'default']).includes(key)); }); if (extraKeys.length) { extraKeyErrors++; } }); const langKeyIdErrorText = this.i18n.translate( 'GLOBAL:textLangKeyIdsMissing', {}, 'One or more rows in your file are missing languageKeyId.' ); const extraKeysErrorText = this.i18n.translate( 'GLOBAL:textExtraKeysInFileError', {}, 'One or more language headers in your file is unsupported.' ); if (langKeyIdErrors && extraKeyErrors) { return langKeyIdErrorText + '
' + extraKeysErrorText; } else if (langKeyIdErrors) { return langKeyIdErrorText; } else if (extraKeyErrors) { return extraKeysErrorText; } return ''; } removeEmptyRows (parsed: ExportTranslation[]) { return parsed.filter((item) => { return !!item.languageKeyId || !!item.default; }); } async prepareBulkUpdate ( parsed: ExportTranslation[], langs: string[], repoName: string, bulkUpdateFunctionName: BulkUpdateFunctionNames ) { const data = parsed.filter((row) => !!row.languageKeyId).map((row) => { const translations = langs.filter((lang) => { return row[lang as any]; }).map((lang) => { return { language: lang, translation: row[lang as any] }; }); return { languageKeyId: +row.languageKeyId, translations }; }); this.spinnerService.startSpinner(); try { await this.translationResources[bulkUpdateFunctionName](data); this.resetTranslationRepo(repoName); this.notifier.success(this.i18n.translate( 'GLOBAL:textSuccessImportingTranslations', {}, 'Successfully imported the translations' )); } catch (e) { this.logger.error(e); this.notifier.success(this.i18n.translate( 'GLOBAL:textErrorImportingTranslations', {}, 'There was an error importing the translations' )); } this.spinnerService.stopSpinner(); } async exportTranslations ( langs: string[], repoName: string, columnName: string, optionsAttr: string, getTranslationsFunctionName: GetTranslationsFunctionNames, dropdownOptions: SelectOption[] ) { this.spinnerService.startSpinner(); const response = await this.getFilteredTranslations( repoName, columnName, optionsAttr, getTranslationsFunctionName, dropdownOptions ); const defaultLang = response.records[0].defaultLanguage; const exportTranslations: ExportTranslation[] = response.records.map((translation) => { const obj = { languageKeyId: '' + translation.languageKeyId, default: translation.defaultTranslationText }; return langs.filter((lang) => { return lang !== defaultLang; }).reduce((acc, val) => { const found = translation.translations.find((item) => { return item.language === val; }); return { ...acc, [val]: found ? found.translation : '' }; }, obj); }); const csv = parse.unparse(exportTranslations); this.fileService.downloadCSV(csv); this.spinnerService.stopSpinner(); } formatPaginationOptions ( options: PaginationOptions, columnName = 'formId', optionsAttr = 'formIds', dropdownOptions?: SelectOption[] ): PaginationOptions { let ids: (number|string)[] = (options as any)[optionsAttr] || []; ids = this.filterOutFilterColumns( options, columnName, ids, 'filterColumns' ); ids = this.filterOutFilterColumns( options, columnName, ids, 'orFilterColumns' ); ids = ids.filter((id, index) => ids.indexOf(id) === index); if (!ids.length && dropdownOptions) { // In scenarios where we always want it to be filtering by the options in the dropdown list ids = dropdownOptions.map((opt) => opt.value); } options = { ...options, [optionsAttr]: ids }; return options; } private filterOutFilterColumns ( options: PaginationOptions, columnName: string, ids: (number|string)[], prop: 'filterColumns'|'orFilterColumns' ) { options[prop] = options[prop] .filter(column => { if (column.columnName === columnName) { ids = [ ...ids, ...column.filters .reduce((acc, filter) => { return [ ...acc, filter.filterValue ]; }, []) ]; return false; } return true; }); return ids; } }