Source: engine/MLTEngine.js

(function() {
    /**
     * @typedef {Object} Help4.engine.MLTEngine.UrlConfig
     * @property {string} serverBaseUrl - base URL of the content server
     * @property {string} translation - URL extension for translation calls
     * @property {string} feature - URL extension to check if feature is supported in SEN
     * @property {string} permission - URL extension to check if user has permission in SEN
     * @property {string} supportedLanguages - URL extension to get supported languages
     */

    /**
     * @typedef {Object} Help4.engine.MLTEngine.ContentObject
     * @property {string} data
     * @property {string} contentType
     * @property {string} language
     */

    /**
     * @typedef {Object} Help4.engine.MLTEngine.TranslationData
     * @property {Object} data - {en-US: Help4.engine.MLTEngine.ContentObject[], ...}
     * @property {Object} meta - {en-US: [{original: <string>, id: <string>, widgetName: <string>, subName?: <string>}], de-DE: [{}]}
     * @property {Object} cache - {en-US: [{translation: <string>, original: <string>, id: <string>, widgetName: <string>, subName?: <string>}], de-DE: [{}]}
     */

    /**
     * @typedef {
     *  Help4.widget.help.view2.GlobalHelpDialogControl|
     *  Help4.widget.help.view2.BubbleControl|
     *  Help4.widget.tour.BubbleControl
     * } Help4.engine.MLTEngine.Bubbles
     */

    /**
     * @typedef {Help4.jscore.ControlBase.Params} Help4.engine.MLTEngine.Params
     * @property {Help4.controller.Controller} controller
     * @property {Help4.engine.DomRefreshEngine} domRefreshEngine
     */

    /**
     * @typedef {Object} Help4.engine.MLTEngine.SupportedFeaturesResponse
     * @property {Object} response
     * @property {Object} response.feature
     * @property {Array<string>} response.feature.featureNames
     */

    /**
     * @typedef {Object} Help4.engine.MLTEngine.PermissionsResponse
     * @property {Object} response
     * @property {Object} response.permission
     * @property {Array<string>} response.permission.permissionNames
     */

    /**
     * @typedef {Object} Help4.engine.MLTEngine.ServerResponseContent
     * @property {string} contentType - content type of the text
     * @property {string} data - translated text
     * @property {string} encoding
     * @property {number} status - status code of the translation e.g. 200, 500 etc.
     */

    /**
     * @typedef {Object} Help4.engine.MLTEngine.ServerResponse
     * @property {Help4.engine.MLTEngine.ServerResponseContent} content
     * @property {string} sourceLanguage
     * @property {string} targetLanguage
     */

    const CACHE_EXEMPTIONS = ['content-caption.text'];

    /**
     * MLTEngine class translates content on-the-fly
     * @augments Help4.jscore.ControlBase
     * @property {Help4.controller.Controller} controller
     * @property {Help4.engine.DomRefreshEngine} domRefreshEngine
     * @property {boolean} _started
     * @property {Function} _domRefreshExecutor
     * @property {Function} _onPanelEvent
     * @property {string} _targetLanguage
     * @property {string[]} _sourceLanguages
     * @property {string} _preferredLanguage
     * @property {Object} _languageMap - {en-US: ['de-DE', 'es-ES'], de-DE: ['en-US', 'es-ES'], ...}
     * @property {boolean} _translationActive
     * @property {Help4.engine.MLTEngine.Bubbles[]} _openBubbles
     * @property {Help4.engine.MLTEngine.Bubbles} _activeBubble
     * @property {Help4.engine.MLTEngine.Bubbles} _translatedBubble
     * @property {string|null} _excludeBubble
     * @property {boolean} _translationAvailable
     * @property {Object} _cache - {en: {id1: {translation: <string>, original: <string>}, id2: {}}, de: {id3: {...}, id4: {...}}}
     * @property {Object} _xhrPromiseContainer
     * @property {boolean} _xhrError
     */
    Help4.engine.MLTEngine = class extends Help4.jscore.ControlBase {
        /**
         * @override
         * @param {Help4.engine.MLTEngine.Params} params
         */
        constructor(params) {
            const domRefreshExecutor = async () => {
                this._languageSelectionDialog?.align();
                _translate.call(this);
            }

            const onPanelEvent = async () => await _startStopTranslation.call(this);

            const {TYPES: T} = Help4.jscore.ControlBase;
            super(params, {
                params: {
                    controller:       {type: T.instance, mandatory: true, readonly: true},
                    domRefreshEngine: {type: T.instance, mandatory: true, readonly: true}
                },
                statics: {
                    _started:                 {init: false, destroy: false},
                    _domRefreshExecutor:      {init: domRefreshExecutor, destroy: false},
                    _onPanelEvent:            {init: onPanelEvent, destroy: false},
                    _targetLanguage:          {init: null, destroy: false},
                    _sourceLanguages:         {init: [], destroy: false},
                    _preferredLanguage:       {init: null, destroy: false},
                    _languageMap:             {init: {}, destroy: false},
                    _translationActive:       {init: false, destroy: false},
                    _openBubbles:             {init: [], destroy: false},
                    _activeBubble:            {init: null, destroy: false},
                    _translatedBubble:        {init: null, destroy: false},
                    _excludeBubble:           {init: null, destroy: false},
                    _translationAvailable:    {init: false, destroy: false},
                    _cache:                   {init: {}, destroy: false},
                    _languageSelectionDialog: {init: null},
                    _xhrPromiseContainer:     {init: null, destroy: false},
                    _xhrError:                {init: false, destroy: false}
                }
            });
        }

        /**
         * @type {Object}
         * @property {'success'} success
         * @property {'fail'} fail
         * @property {'partial'} partial
         * @property {'skipped'} skipped
         */
        static TRANSLATION_STATES = {
            success: 'success',
            fail: 'fail',
            partial: 'partial',
            skipped: 'skipped'
        }

        /** @override */
        destroy() {
            this.stop();
            super.destroy();
        }

        /** @returns {Promise<void>} */
        async start() {
            if (this._started) return;

            // XRAY-6404
            const {controller} = this;
            const panel = controller?.getPanel();
            const {STATUS: {done}} = Help4.StartStatus;
            const statusService = Help4.getController()?.getService('startStatus');
            if (!panel || !statusService.has(done)) return void setTimeout(() => this.start(), 100);

            if (this.isDestroyed() || !(await _checkPreRequirements.call(this))) return;

            this._started = true;

            const {
                domRefreshEngine,
                _domRefreshExecutor,
                _onPanelEvent,
                _languageSelectionDialog
            } = this;

            _languageSelectionDialog && _closeLanguageSelectionDialog.call(this);

            domRefreshEngine.addExecutor(_domRefreshExecutor);

            panel.addListener('translate', _onPanelEvent);

            const config = /** @type {Help4.typedef.SystemConfiguration} */ controller.getConfiguration();
            const {translation: {continuousMLT}} = config;

            const {mltPreferredLanguage, mltTranslationActive, mltTargetLanguage} = controller.getCmp4Handler();
            this._preferredLanguage = mltPreferredLanguage;
            if (continuousMLT && mltTranslationActive) {
                this._targetLanguage = mltTargetLanguage;
                await _startStopTranslation.call(this, {active: true, force: true});
            }

            await _translate.call(this);
        }

        /** @returns {Promise<void>} */
        async stop() {
            if (!this._started) return;
            this._started = false;

            const {
                domRefreshEngine,
                _domRefreshExecutor,
                controller,
                _onPanelEvent,
                _openBubbles
            } = this;

            domRefreshEngine.removeExecutor(_domRefreshExecutor);

            const panel = controller.getPanel();
            if (panel) {
                panel.removeListener('translation', _onPanelEvent);
                panel.translation = false;
                panel.animateTranslationButton = false;
                panel.showTranslationButton = false;
            }

            for (const bubble of _openBubbles) {
                bubble.enableTranslation = false;
                bubble.activeTranslation = false;
            }

            this._translationActive = false;
            this._translationAvailable = false;
            _cleanXhrPromise.call(this, true);
            await _revertTranslation.call(this);
        }

        /** @returns {boolean} */
        isStarted() {
            return this._started;
        }

        /**  @returns {boolean} */
        isTranslationActive() {
            return this._translationActive;
        }

        /** @returns {string} */
        getTargetLanguage() {
            return this._targetLanguage;
        }

        /** @returns {boolean} */
        isTranslationAvailable() {
            return this._translationAvailable;
        }

        /** @returns {string} */
        getPreferredLanguage() {
            return this._preferredLanguage;
        }

        /** @param {Help4.engine.MLTEngine.Bubbles} bubble */
        async registerBubble(bubble) {
            if (!this.isStarted()) return;

            // should also work if engine is not started
            const {_openBubbles} = this;
            if (_openBubbles.indexOf(bubble) < 0) _openBubbles.push(bubble);

            await _checkAvailableTargetLanguages.call(this);

            _setButtonVisibilities.call(this);
        }

        /** @param {Help4.engine.MLTEngine.Bubbles} bubble */
        unregisterBubble(bubble) {
            if (!this.isStarted()) return;

            // should also work if engine is not started
            const {_openBubbles} = this;
            const index = _openBubbles.indexOf(bubble);
            if (index >= 0) _openBubbles.splice(index, 1);

            this._activeBubble = null;
            this._excludeBubble = null;

            const {_translatedBubble} = this;
            if (_translatedBubble === bubble) this._translatedBubble = null;
        }

        /**
         * @param {Object} params
         * @param {Help4.engine.MLTEngine.Bubbles} params.bubble
         * @param {boolean} params.active
         * @throws {Error}
         * @return {Promise<void>}
         */
        async onBubbleClick({bubble, active}) {
            if (!this.isStarted()) return;

            const {_openBubbles} = this;
            const index = _openBubbles.indexOf(bubble);
            if (index < 0) throw new Error('MLT Engine: Unable to handle click for unregistered bubble!');

            await _startStopTranslation.call(this, {bubble, active});
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {Object} [params = {}]
     * @param {Help4.engine.MLTEngine.Bubbles} [params.bubble]
     * @param {boolean} [params.active]
     * @param {boolean} [params.force]
     * @returns {Promise<void>}
     */
    async function _startStopTranslation({bubble, active, force} = {}) {
        if (this._xhrPromiseContainer) _cleanXhrPromise.call(this, true);

        const {_translationActive} = this;
        if (bubble) {
            // activated from bubble, translate only bubble
            // only open language selection dialog if _targetLanguage is not set or not supported

            this._translatedBubble = bubble;

            if (!_translationActive && active && await _checkBubbleLanguageMismatch.call(this) && !(await _openLanguageSelectionDialog.call(this))) return;  // dialog canceled, return

            this._activeBubble = bubble;

            this._excludeBubble = active ? null : bubble.id;

            _setButtonStates.call(this, active, bubble);
        } else {
            // activated from panel, translate all

            // XRAY-6432: revert bubble translation before activating panel translation
            const {_translatedBubble} = this;
            if (_translatedBubble) await _startStopTranslation.call(this, {bubble: _translatedBubble, active: false});

            // always open LanguageSelectionDialog when mlt is activated from panel
            if (!force && !_translationActive && !(await _openLanguageSelectionDialog.call(this))) return;  // dialog canceled, return

            this._translationActive = active = !_translationActive;
            this._excludeBubble = null;

            _setButtonStates.call(this, active);
        }

        active
            ? await _translate.call(this, !!bubble)
            : await _revertTranslation.call(this);

        if (!active) this._translatedBubble = null;

        this._activeBubble = null;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<boolean>}
     */
    async function _checkBubbleLanguageMismatch() {
        const {_targetLanguage, _languageMap, controller} = this;

        const config = /** @type {Help4.typedef.SystemConfiguration} */ controller.getConfiguration();
        const {translation: {continuousMLT}} = config;

        // if no targetLanguage force to open dialog
        if (!_targetLanguage) return true;

        if (continuousMLT) return false;

        const {_translatedBubble, _openBubbles} = this;
        let openBubbles = _translatedBubble ? [_translatedBubble] : _openBubbles;

        // if at least one is supported, no mismatch
        for (const bubble of openBubbles) {
            const bubbleText = await bubble.getTexts();
            if (Object.values(bubbleText).some(({language}) => _languageMap[language].includes(_targetLanguage))) return false;
        }

        return true;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<boolean>}
     */
    function _openLanguageSelectionDialog() {
        const {Localization} = Help4;
        const {controller, _preferredLanguage} = this;
        const dom = controller.getDom2('dom');
        const languages = _buildLanguageSelectionData.call(this);
        const {core: {language: {uacp}}} = /** @type {Help4.typedef.SystemConfiguration} */ controller.getConfiguration();

        // 1. check if preferredLang is selectable - this is cached so not be available after page refresh with different content
        // 2. check if only one language is selectable - if yes, preselect
        // 3. check if system language is selectable
        const preferredLang = languages.find(lang => lang.id === _preferredLanguage)?.id;
        const onlyLang = languages.length === 1 && languages[0].id;
        const systemLang = languages.find(lang => lang.id === uacp)?.id;
        const value = preferredLang || onlyLang || systemLang;

        return new Help4.Promise(resolve => {
            if (this._languageSelectionDialog) return void resolve(false);

            const dialog = this._languageSelectionDialog = new Help4.control2.bubble.LanguageSelection({
                caption: Localization.getText('label.languagedialogcaption'),
                dom,
                languages,
                value
            })
            .addListener('close', () => {
                _closeLanguageSelectionDialog.call(this);
                resolve(false);
            })
            .addListener('apply', async ({value}) => {
                if (value) {
                    this._preferredLanguage = this._targetLanguage = value;
                }

                _closeLanguageSelectionDialog.call(this);
                resolve(!!value);
            });
            dialog.align();
        });
    }

    function _closeLanguageSelectionDialog() {
        const {_languageSelectionDialog} = this;
        _languageSelectionDialog?.destroy();
        this._languageSelectionDialog = null;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Help4.control2.bubble.content.Selection.Data[]}
     */
    function _buildLanguageSelectionData() {
        const {Localization} = Help4;
        const {_languageMap, _translatedBubble, _sourceLanguages} = this;
        const data = [];
        let sourceLanguages = _sourceLanguages;

        // bubble triggered, only show languages from bubble
        if (_translatedBubble) {
            const bubbleText = _translatedBubble.getTexts();
            sourceLanguages = Object.values(bubbleText).map(({language}) => language);
        }

        for (const sourceLanguage of sourceLanguages) {
            const languages = _languageMap[sourceLanguage];
            if (!languages) continue;

            data.push(...languages.filter(lang => !data.some(d => d.id === lang)).map(lang => ({
                id: lang,
                text: Localization.getText(`label.mlt.${lang}`) || lang
            })));
        }
        return data.sort((a, b) => a.text.localeCompare(b.text));
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {boolean} active
     * @param {Help4.engine.MLTEngine.Bubbles} [bubble = null]
     */
    function _setButtonStates(active, bubble = null) {
        if (bubble) {
            // the translation button in a bubble was clicked
            bubble.activeTranslation = active;
        } else {
            const panel = this.controller.getPanel();
            // the translation button in panel was clicked
            panel.translation = active;

            for (const bubble of this._openBubbles) {
                // XRAY-6268: UR bubbles are ignored
                if (bubble.isDestroyed() || bubble.getMetadata('isUR')) continue;

                bubble.activeTranslation = active;
            }
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<void>}
     */
    async function _checkAvailableTargetLanguages() {
        const texts = await Help4.widget.getTexts();
        let sourceLanguage = [];

        const getLanguage = data => {
            for (const content of Object.values(data)) {
                if (!content) continue;

                if (_isAtomic.call(this, content)) {
                    const {language} = content;
                    if (sourceLanguage.indexOf(language) < 0) sourceLanguage.push(language);
                } else {
                    getLanguage(content);
                }
            }
        }

        getLanguage(texts);
        this._sourceLanguages = sourceLanguage;

        // set _sourceLanguages first, then filter out the ones we have in the map to avoid unnecessary call
        const {_languageMap} = this;
        sourceLanguage = sourceLanguage.filter(lang => !_languageMap[lang]);

        if (!sourceLanguage.length) return void _checkLanguageMismatch.call(this);

        // new languages are detected, hide buttons until we get the supported languages
        _setButtonVisibilities.call(this, false);

        const {
            widget: {companionCore: {SEN}},
            ajax: {Ajax}
        } = Help4;
        const {serverBaseUrl, supportedLanguages} = _getUrls.call(this);

        try {
            const result = await Ajax({
                url: serverBaseUrl + supportedLanguages,
                saml: true,
                xcsrf: SEN.getXCSRF(serverBaseUrl),
                method: 'POST',
                promise: true,
                data: {sourceLanguage}
            });

            const {response} = result;
            for (const [language, targetLanguages] of Object.entries(response)) {
                this._languageMap[language] = Help4.isArray(targetLanguages)
                    ? targetLanguages
                    : [];  // ignore the "Unsupported Language" reply to avoid later crashes; XRAY-6451
            }

            _setButtonVisibilities.call(this);
            _checkLanguageMismatch.call(this);
        } catch (e) {
            const {error, status} = e;
            if (error === 'error' && status) {
                const {
                    control2: {InfoBar: {TYPES: {error}}},
                    Localization
                } = Help4;

                const {controller} = this;
                controller.getService('infobar4').add({
                    type: error,
                    content: Localization.getText('label.translationerror')
                });
                this.stop();
            }
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     */
    function _checkLanguageMismatch() {
        // check if previous _sourceLanguage is supported (can happen in multi-language scenario)
        // if not, reset _sourceLanguage and reset button status
        const {_languageMap, _targetLanguage, _sourceLanguages, _translationActive, controller} = this;
        const config = /** @type {Help4.typedef.SystemConfiguration} */ controller.getConfiguration();
        const {translation: {continuousMLT}} = config;
        if (continuousMLT || !_translationActive || !_targetLanguage || !_sourceLanguages.length) return;

        for (const sourceLanguage of Object.values(_sourceLanguages)) {
            if (_languageMap[sourceLanguage].some(lang => lang === _targetLanguage)) return;
        }

        this._translationActive = false;
        this._targetLanguage = null;
        _setButtonStates.call(this, false);

        const infobar = controller.getService('infobar4');
        const {Localization} = Help4;
        infobar.add({content: Localization.getText('label.translationmismatch')});
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {?boolean} visibility
     */
    function _setButtonVisibilities(visibility) {
        const {controller, _sourceLanguages, _languageMap, _openBubbles} = this;
        const translationAvailable = this._translationAvailable = visibility ?? _sourceLanguages.some(lang => _languageMap[lang]);

        const panel = controller.getPanel();
        panel.showTranslationButton = translationAvailable;

        for (const bubble of _openBubbles) {
            // XRAY-6268: UR bubbles are ignored
            if (bubble.isDestroyed() || bubble.getMetadata('isUR')) continue;

            bubble.enableTranslation = translationAvailable;
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {boolean} [isBubble = false] - is it triggered from a bubble
     * @returns {Promise<void>}
     */
    async function _translate(isBubble = false) {
        await _checkAvailableTargetLanguages.call(this);
        // if bubble turned off the translation, collect it data for state (_getTranslationData) but exclude it from translation (_getTranslations)
        const data = /** @type {?Help4.engine.MLTEngine.TranslationData} */ await _getTranslationData.call(this);
        if (data && !this.isDestroyed() && (this._translationActive || isBubble)) {
            const texts = await _getTranslations.call(this, data);
            if (!texts || this.isDestroyed()) return;

            await _setTextsToWidgets.call(this, texts);
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<void>}
     */
    async function _revertTranslation() {
        const {cache = {}} = /** @type {?Help4.engine.MLTEngine.TranslationData} */ await _getTranslationData.call(this) || {};
        if (this.isDestroyed()) return;

        const texts = {};
        for (const langCache of Object.values(cache)) {
            for (const {path, original} of Object.values(langCache)) {
                _addToTexts(path, original, texts);
            }
        }

        await _setTextsToWidgets.call(this, texts);
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {Help4.engine.MLTEngine.ContentObject} content
     * @returns {boolean}
     */
    function _isAtomic({data, contentType, language}) {
        return !!data && !!contentType && !!language;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<Help4.engine.MLTEngine.TranslationData|null>}
     */
    async function _getTranslationData() {
        const {_targetLanguage} = this;

        /**
         * @param {Object} data
         * @param {string[]} ids
         * @param {Help4.engine.MLTEngine.TranslationData} [result = {data: {}, meta: {}, cache: {}}]
         * @returns {Help4.engine.MLTEngine.TranslationData}
         */
        const process = (data, ids = [], result = {data: {}, meta: {}, cache: {}}) => {
            for (const [id, content] of Object.entries(data)) {
                if (!content) continue;

                const path = [...ids, id];
                _isAtomic.call(this, content)
                    ? addToData(content, path, result)
                    : process(content, path, result);
            }
            return result;
        }

        /**
         * @param {Help4.engine.MLTEngine.ContentObject} content
         * @param {string[]} path
         * @param {Object} result
         * @param {Object} result.data
         * @param {Object} result.meta
         * @param {Object} result.cache
         */
        const addToData = (content, path, {data, meta, cache}) => {
            const {language, data: text} = content;
            const id = path[path.length - 1];
            if (!language || !text || _targetLanguage === language || _isURText.call(this, id)) return;

            const {translation, original} = _getFromCache.call(this, language, id, text);
            if (translation && original) {
                // found in cache, use it both for translate from cache and for revert original
                cache[language] ||= [];
                cache[language].push({path, translation, original});
            } else {
                data[language] ||= [];
                meta[language] ||= [];
                data[language].push(content);
                meta[language].push({path: path, text});
            }
        };

        const {_activeBubble} = this;
        const texts = _activeBubble
            ? await _activeBubble.getTexts()
            : await Help4.widget.getTexts();
        if (this.isDestroyed()) return null;

        return process(texts);
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {string} id
     * @returns {boolean}
     */
    function _isURText(id) {
        const instance = /** @type {?Help4.widget.Widget} */ Help4.widget.getActiveInstance();
        const name = instance?.getName();
        if (name !== 'help') return false;

        const {controller} = this;
        const panelControls = controller.getPanel()?.getHostedControls();
        const tiles = instance.getContext().widget.help.view.getTiles() || [];
        const isURTile = tiles.some(({id: tileId, _isUR}) => {
            if (!_isUR) return false;

            const controlId = panelControls.find(control => control.getMetadata('tileId') === tileId)?.id;
            return id.includes(controlId);
        });

        if (isURTile) return true;

        const {_openBubbles = []} = this;
        return !!_openBubbles.some(bubble => !bubble.isDestroyed() && bubble.getMetadata('isUR') && id.includes(bubble.id));
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {Help4.engine.MLTEngine.TranslationData} translationData
     */
    function _excludeBubbleData({data, meta, cache}) {
        const {_excludeBubble} = this;

        /**
         * @param {Object} values
         * @param {boolean} [deleteFromData = true] - deletes the excluded data from the original data object
         */
        const exclude = (values, deleteFromData = true) => {
            for (const [language, langValues] of Object.entries(values)) {
                for (const [index, {path}] of Object.entries(langValues)) {
                    if (path[path.length - 1].includes(_excludeBubble)) {
                        delete langValues[index];
                        if (deleteFromData) delete data[language][index];
                    }
                }
            }
        }

        if (_excludeBubble) {
            exclude(meta);
            exclude(cache, false);
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {Help4.engine.MLTEngine.TranslationData} translationData
     * @returns {Promise<?Object>}
     */
    async function _getTranslations({data, meta, cache}) {
        // ongoing translation. wait for it to finish. domRefresh will call again.
        if (this._xhrPromiseContainer) return null;

        // exclude bubble if it is turned off from bubble button
        _excludeBubbleData.call(this, {data, meta, cache});

        const {TRANSLATION_STATES: states} = this.constructor;
        const newTexts = {};

        const handleCached = () => {
            if (!Object.keys(cache).length) return;

            for (const [language, langCache] of Object.entries(cache)) {
                for (const {path, translation, original} of Object.values(langCache)) {
                    _addToTexts(path, translation, newTexts);

                    // save to cache in case it is retrieved by text and not with id e.g. bubbles
                    _saveToCache.call(this, path, translation, original, language);
                }
            }
        }

        let status = null;
        const {_targetLanguage, _languageMap} = this;
        // skip unsupported source languages
        for (const lang of Object.keys(data)) {
            if (!_languageMap[lang]?.includes(_targetLanguage)) {
                status = states.partial;
                delete data[lang];
            }
        }

        if (!Object.keys(data).length) {
            handleCached();
            _handleTranslationStatus.call(this, states.skipped);
            return newTexts;
        }

        // abort protection
        for (const langData of Object.values(data)) {
            if (!langData.length || langData.findIndex(val => val == null) > -1) return null;
        }

        const {controller} = this;
        _setPanelAnimation.call(this, true);

        const responses = /** @type {Help4.engine.MLTEngine.ServerResponse[]} */ await _callMLTranslation.call(this, data);

        if (this._xhrError)  {
            _handleTranslationStatus.call(this, states.fail);
            return null;
        }

        if (!responses.length || this.isDestroyed()) return null;

        const updateStatus = update => {
            if (update) {
                if (status === null) status = states.success;
                if (status !== states.success) status = states.partial;
            } else {
                if (status === null) status = states.fail;
                if (status !== states.fail) status = states.partial;
            }
        }

        const {translation: {retryFailedMLTranslations}} = controller.getConfiguration();
        for (const response of responses) {
            const {content, sourceLanguage} = response;

            if (!content) {
                updateStatus(false);
                continue;
            }

            for (const [index, translatedObject] of content.entries()) {
                const {data, status} = translatedObject;
                const {path, text} = meta[sourceLanguage][index];

                if (status !== 200) {
                    updateStatus(false);
                    // cache original to skip trying again
                    retryFailedMLTranslations || _saveToCache.call(this, path, text, text, sourceLanguage);
                    continue;
                }

                updateStatus(true);
                _saveToCache.call(this, path, data, text, sourceLanguage);
                _addToTexts(path, data, newTexts);
            }
        }

        handleCached();
        _handleTranslationStatus.call(this, status);
        _cleanXhrPromise.call(this);

        _setPanelAnimation.call(this, false);

        return newTexts;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {boolean} animate
     */
    function _setPanelAnimation(animate) {
        const {_activeBubble, controller} = this;
        const panel = controller.getPanel();
        if (!_activeBubble) panel.animateTranslationButton = animate;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {Object} data - {en-US: Help4.engine.MLTEngine.ContentObject[], ...}
     * @returns {Promise<Help4.engine.MLTEngine.ServerResponse[]>}
     */
    async function _callMLTranslation(data) {
        const {SEN, Core} = Help4.widget.companionCore;
        const {_targetLanguage: targetLanguage} = this;
        const {serverBaseUrl, translation} = _getUrls.call(this);
        const url = serverBaseUrl + translation;

        const promises = new Help4.XhrPromiseContainer();
        for (let [sourceLanguage, content] of Object.entries(data)) {
            content = content.map(({contentType, data}) => ({contentType, data}));  // remove language to reduce payload

            const promise = Help4.ajax.Ajax({
                url,
                method: 'POST',
                data: {
                    sourceLanguage,
                    targetLanguage,
                    content
                },
                saml: true,
                xcsrf: SEN.getXCSRF(serverBaseUrl),
                xhrPromise: true
            });

            const onComplete = () => {
                const xhr = promise.getXhr();
                Core.broadcastXhrStatus(xhr);
                if (xhr.error) this._xhrError = true;
            }

            promise
            .then(onComplete)
            .catch(onComplete);

            promises.add(promise);
        }

        this._xhrPromiseContainer = promises.then(data => data?.response?.translation || {}, () => null);
        return await this._xhrPromiseContainer.all();
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {Help4.engine.MLTEngine.TRANSLATION_STATES} status
     * @return {Promise<void>}
     */
    async function _handleTranslationStatus(status) {
        const {
            control2: {InfoBar: {TYPES: {warning, error}}},
            Localization
        } = Help4;

        const {controller, constructor} = this;
        const {TRANSLATION_STATES: states} = constructor;
        const infobar = controller.getService('infobar4');

        if (status !== states.skipped) {
            if (status !== states.fail) {
                const disclaimerShown = await _showMLTranslationDisclaimer.call(this);
                if (!disclaimerShown && status === states.partial) await _showPartialDisclaimer.call(this);
            } else {
                infobar.add({type: error, content: Localization.getText('label.translationfail')});
                _setPanelAnimation.call(this, false);
                this._translationActive = false;
                _setButtonStates.call(this, false);
            }
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<boolean>} - true if disclaimer was shown
     */
    async function _showMLTranslationDisclaimer() {
        const {Localization} = Help4;
        const {controller} = this;
        const storageService = controller.getService('storage');
        const infobar = controller.getService('infobar4');

        const alreadyShown = await storageService.get(Help4.SERIALIZE_MLTRANSLATION_KEY, {target: 'session'});
        if (!alreadyShown) {
            infobar.add({content: Localization.getText('label.translationdisclaimer'), timeout: 0});
            storageService.set(Help4.SERIALIZE_MLTRANSLATION_KEY, 1, {target: 'session'});
        }
        return !alreadyShown;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<void>}
     */
    async function _showPartialDisclaimer() {
        const {
            control2: {InfoBar: {TYPES: {warning}}},
            Localization
        } = Help4;
        const {controller} = this;
        const storageService = controller.getService('storage');
        const infobar = controller.getService('infobar4');

        if (!await storageService.get(Help4.SERIALIZE_MLT_MISMATCH_KEY, {target: 'session'})) {
            infobar.add({type: warning, content: Localization.getText('label.translationpartialfail')});
            storageService.set(Help4.SERIALIZE_MLT_MISMATCH_KEY, 1, {target: 'session'});
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {string[]} path
     * @param {string} text
     * @param {Object} texts
     */
    function _addToTexts(path, text, texts) {
        let obj = texts;
        for (let i = 0; i < path.length; i++) {
            const id = path[i];
            obj[id] ||= {};
            if (i < path.length - 1) {
                obj = obj[id];
            } else {
                obj[id] = text;
            }
        }
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {Object} texts
     * @returns {Promise<void>}
     */
    async function _setTextsToWidgets(texts) {
        const {_activeBubble} = this;

        if (!Object.keys(texts).length) return;

        _activeBubble
            ? await _activeBubble?.setTexts(texts)
            : await Help4.widget.setTexts(texts);
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @property {boolean} [abort = false]
     */
    function _cleanXhrPromise(abort = false) {
        const {_xhrPromiseContainer} = this;
        if (!_xhrPromiseContainer) return;

        _setPanelAnimation.call(this, false);
        abort && _xhrPromiseContainer.abort();
        _xhrPromiseContainer.destroy();
        this._xhrPromiseContainer = null;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {string[]} path
     * @param {string} translation
     * @param {string} text
     * @param {string} language
     */
    function _saveToCache(path, translation, text, language) {
        const {_cache, _targetLanguage} = this;
        let id = path[path.length - 1];

        // XRAY-6357
        if (CACHE_EXEMPTIONS.find(exempId => id.includes(exempId))) {
            id = `${id}-${text}`;
        }

        _cache[language] ||= {};
        _cache[language][_targetLanguage] ||= {};
        _cache[language][_targetLanguage][id] = {translation, original: text};
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @param {string} language
     * @param {string} id
     * @param {string} [text = '']
     * @returns {{translation: ?string, original: ?string}}
     */
    function _getFromCache(language, id = '', text = '') {
        const {_cache, _targetLanguage} = this;
        const cache = _cache[language]?.[_targetLanguage];
        const failObj = {translation: null, original: null};
        if (cache) {
            // XRAY-6357
            if (CACHE_EXEMPTIONS.find(exID => id.includes(exID))) {
                return Object.values(cache).find(({translation, original}) => translation === text || original === text) || failObj;
            }

            // first try to find it from id
            if (cache[id]) return cache[id];

            if (text) {
                // if not found, try to find it from text
                // dom elements might be created with new ids, so we need to find it from text e.g. bubbles
                const item = Object.values(cache).find(({original}) => original === text);
                if (item) return item;
            }
        }

        return failObj;
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Promise<boolean>}
     */
    async function _checkPreRequirements() {
        const {SERVICE_LAYER: {uacp}, widget: {companionCore: {SEN}}} = Help4;
        const {controller} = this;
        const config = /** @type {Help4.typedef.SystemConfiguration} */ controller.getConfiguration();
        const {
            help: {serviceLayer},
            translation: {allowMLTranslations},
            CMP4
        } = config;

        if (!allowMLTranslations || serviceLayer === uacp || !CMP4) return false;

        const {serverBaseUrl, feature, permission} = _getUrls.call(this);
        const request = /** @type {Array<{url: string}>} */ [feature, permission].map(url => ({url}));
        const [
            /** @type {Help4.engine.MLTEngine.SupportedFeaturesResponse} */ supportedFeatures,
            /** @type {Help4.engine.MLTEngine.PermissionsResponse} */ permissions
        ] = await SEN.doMultifileRequest(config, {serverBaseUrl, request}) || [];

        return !!supportedFeatures?.response?.feature.find(feature => feature === 'machine_translation') &&
            !!permissions?.response?.permission.find(permission => permission === 'mlt_enabled');
    }

    /**
     * @memberof Help4.engine.MLTEngine#
     * @private
     * @returns {Help4.engine.MLTEngine.UrlConfig}
     */
    function _getUrls() {
        const {SERVICE_LAYER: {wpb}} = Help4;
        const {controller} = this;
        const {help: {serviceUrl, serviceUrl2, serviceLayer}} = controller.getConfiguration();
        const {SEN} = Help4.widget.companionCore;
        const serverBaseUrl = SEN.getServerBaseUrl(serviceLayer === wpb ? serviceUrl : serviceUrl2);
        return {
            serverBaseUrl,
            translation: '/ml_translation_sync',
            feature: '/server/supported_features',
            permission: '/self/permission',
            supportedLanguages: '/ml_supported_languages'
        };
    }
})();