Source: engine/ur/Update.js

(function() {
    /** Update texts handling for UR harmonization */
    Help4.engine.ur.Update = class {
        /**
         * Hotspot icon position options (see i18n.properties)
         * @type {{middle: {START: string, END: string}, top: {CENTERED: string, start: {OUTSIDE: string, ABOVE: string}, end: {OUTSIDE: string, ABOVE: string}, START: string, END: string}}}
         */
        static ICON_POSITION = {
            top: {
                start: {
                    OUTSIDE: 'A',
                    ABOVE: 'B'
                },
                CENTERED: 'C',
                end: {
                    ABOVE: 'D',
                    OUTSIDE: 'E',
                },
                START: 'F',
                END: 'G'
            },
            middle: {
                START: 'H',
                END: 'I'
            }
        };
        /**
         * Handle hotspot update
         * sap.companion.services.UpdateHotspots (API v2), sap.webassistant.services.updateHotspots (API v1)
         * @param {Help4.engine.ur.UrHarmonizationEngine} engine
         * @param {Help4.engine.ur.UrHarmonizationEngine.UrDataV1 | Help4.engine.ur.UrHarmonizationEngine.UrDataV2} data
         */
        static handle(engine, data) {
            let {technology, elements, hotspots} = data;
            if (typeof technology === 'string' && Help4.isArray(elements)) hotspots = elements;
            if (!hotspots || !Help4.isArray(hotspots)) hotspots = [];

            const {APP_TYPE} = Help4.engine.ur.Connection;
            const {_currentApp, _tiles, _tileStatus, _urHotspots, constructor: {QUERIES}} = engine;
            const isUI5 = _currentApp === APP_TYPE.UI5;

            // changedHotspots - true:
            // - a new hotspot has been added; could affect existing assignments OR
            // - an existing hotspot has been changed OR
            // - an existing hotspot has been removed
            let changedHotspots = false;
            // removedTiles - true: an existing tile has been removed
            let removedTiles = false;

            // update existing hotspots or add new ones in new collection to keep incoming data ordering
            const newUrHotspots = /** @type {Map<string, Object>} */ new Map();
            for (const hotspot of hotspots) {
                if (isUI5) hotspot.backendHelpCDSQuery ??= QUERIES.UI5;
                const {_stableId, _type, _urId} = _harmonize(hotspot, technology, hotspots);
                const harmonizedId = hotspot.harmonizedId = _stableId || _urId;
                const old = _urHotspots.get(harmonizedId) || newUrHotspots.get(harmonizedId);

                // new hotspot
                if (!old) {
                    changedHotspots = true;
                    newUrHotspots.set(harmonizedId, {...hotspot, _id: Help4.createId(), _stableId, _type, _urId});
                    continue;
                }

                // old or duplicate hotspot
                // in UI5 only! check old/duplicate hotspots for additional help keys, no position handling
                if (isUI5) {
                    if (old._urId !== _urId && !old.additionalHelpKeys?.includes(_urId)) {
                        old.additionalHelpKeys?.push(_urId) || (old.additionalHelpKeys = [_urId]);
                    }
                    newUrHotspots.set(harmonizedId, old);
                    continue;
                }

                // check if position has changed, then update status
                if (!this.positionsEqual(old.position, hotspot.position)) {
                    old.position = hotspot.position;
                    old._urMoved = true;
                    changedHotspots = true;
                }
                newUrHotspots.set(harmonizedId, old);
            }

            // remove old tiles without matching hotspots
            for (const key of _urHotspots.keys()) {
                if (!hotspots.some(({harmonizedId}) => harmonizedId === key)) {
                    const {_id} = _urHotspots.get(key);
                    removedTiles = _tiles.delete(_id) || removedTiles;
                    delete _tileStatus[_id];
                    changedHotspots = true;
                }
            }

            // update hotspot collection
            engine._urHotspots = newUrHotspots;

            _getHelpTexts(engine)
            .then(() => {
                // wait for texts to be available then updateTiles now before setting timestamp
                // otherwise a state without tiles (having texts) will be cached
                // no need to update tiles again in getTile
                const addedTiles = _updateTiles(engine);

                // structural: redraw needed
                const structural = addedTiles || removedTiles;
                // moved: hotspots have been moved
                const moved = structural || changedHotspots;

                if (moved) {
                    engine._log?.('CMP: Update ' + (addedTiles ? '| Tiles added ' : '') + (removedTiles ? '| Tiles removed ' : '') + (changedHotspots ? '| Hotspots changed ' : ''));
                }

                engine._sendUpdateNotification({structural, moved});
            });
        }

        static positionsEqual(a, b) {
            return a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
        }
    };

    /**
     * Prepares ids and values for text retrieval
     * @private
     * @param {Help4.engine.ur.UrHarmonizationEngine.UrHotspot|Help4.engine.ur.UrHarmonizationEngine.UrElement} hotspot
     * @param {?string} technology
     * @returns {{_stableId: ?string, _type: ?string, _urId: ?string}|null} - harmonized hotspotData
     */
    function _harmonize(hotspot, technology) {
        const {hotspotId, id, backendHelpKey, backendHelpCDSQuery} = hotspot;

        // API V2
        if (hotspotId) {
            // get _type and _urId that are needed for Help4.ajax.UrHarmonization
            // _stableId will be used later for XRAY-3663
            // converting empty objects to empty strings to filter later
            const _urId = typeof backendHelpKey === 'object' && Object.keys(backendHelpKey).length
                ? Help4.JSON.stringify(backendHelpKey)
                : '';

            return {_stableId: hotspotId, _type: backendHelpCDSQuery, _urId};
        }

        // API V1
        if (id) {
            let _urId;
            let _stableId;

            if (typeof id === 'object') {
                if (technology === 'WDA') {
                    _urId = id.dtel || null;
                    _stableId = id.id || null;
                } else {
                    _urId = _stableId = Help4.JSON.stringify(id);
                }
            } else {  // old WDA, does not support stable IDs
                _urId = /** @type {string} */ id;
                _stableId = null;
            }

            const {QUERIES} = Help4.engine.ur.UrHarmonizationEngine;
            return {_stableId, _type: QUERIES[technology], _urId};
        }

        return null;
    }

    /**
     * @private
     * @param {Help4.engine.ur.UrHarmonizationEngine} engine
     * @returns {Help4.Promise<boolean>}
     */
    async function _getHelpTexts(engine) {
        const {_docu_texts, _ignoreLongText, _mock, _texts, _urHotspots, _useABAPHelpTexts} = engine;

        const newItems = [];
        for (const {_urId, additionalHelpKeys = [], backendHelpCDSQuery} of _urHotspots.values()) {
            // UI5 hotspots can have multiple help texts, only _urId otherwise
            for (const key of [_urId, ...additionalHelpKeys] || []) {
                if (key && !_texts.hasOwnProperty(key)) {
                    _texts[key] = null;
                    newItems.push({_type: backendHelpCDSQuery, _urId: key});
                }
            }
        }
        if (!newItems.length) return false;

        const {
            ajax: {UrHarmonization},
            engine: {ur: {Transformation}}
        } = Help4;

        /** @type {?Help4.engine.ur.UrHarmonizationEngine.UrTextResponse[] | ?Help4.engine.ur.UrHarmonizationEngine.ErrorResponse[]} */
        const helpTexts = _useABAPHelpTexts && await UrHarmonization.requestMultipartContent(newItems, _mock) || [];

        _useABAPHelpTexts
            ? engine._log?.('CMP: received help texts', undefined, ()=>helpTexts)
            : engine._log?.('CMP: useABAPHelpTexts is disabled');
        if (!Help4.isArray(helpTexts)) return false;

        let hasDocuLinks = false;
        for (const text of helpTexts) {
            const {
                dtel,
                //error,
                esid,
                hotspotId,
                long_text
            } = text;
            // different queries use different key names: hotspotId, esid, dtel
            const key = hotspotId || esid || dtel;

            if (long_text || _ignoreLongText) {
                // WARNING: this is not UACP format do not use Help4.control.input.HtmlEditor.transformFromUACP
                const {content, docuLinks, size} = Transformation.format(long_text) || {};
                text.content = content;
                text.size = size;
                docuLinks.forEach(key => {
                    if (!_docu_texts.hasOwnProperty(key)) {
                        _docu_texts[key] = null;
                        hasDocuLinks ||= true;
                    }
                });
                _texts[key] = text;
            }
        }

        if (hasDocuLinks) {
            // no await, texts are needed when links in the bubble are clicked, not to show the bubble in the first place
            _getDocuTexts(engine);
        }

        return true;
    }

    async function _getDocuTexts(engine) {
        const {
            ajax: {UrHarmonization},
            engine: {ur: {Transformation, UrHarmonizationEngine: {QUERIES}}}
        } = Help4;

        // collect missing DOCU_LINK texts
        const newTexts = [];
        const backendHelpCDSQuery = QUERIES.DOC;
        for (const [backendHelpKey, value] of Object.entries(engine._docu_texts)) {
            if (value === null) {
                newTexts.push({_type: backendHelpCDSQuery, _urId: backendHelpKey});
            }
        }
        if (newTexts.length === 0) {
            return;
        }

        // request texts
        engine._log?.('CMP: found new DOCU_LINKS', undefined, ()=>newTexts.map(({_urId}) => _urId));
        const {_docu_texts, _mock} = engine;
        const response = await UrHarmonization.requestMultipartContent(newTexts, _mock);
        if (!Help4.isArray(response)) {
            engine._log?.('CMP: unexpected response to request', undefined, ()=>({backendHelpCDSQuery, response}));
            return;
        }
        engine._log?.('CMP: received DOCU_LINK texts', undefined, ()=>response);

        // transform and extract new DOCU_LINK texts
        let hasDocuLinks = false;
        for (const {id_name, label_text, long_text, res_langu, short_text} of response) {
            // WARNING: this is not UACP format do not use Help4.control.input.HtmlEditor.transformFromUACP
            const {content, docuLinks, size} = Transformation.format(long_text) || {};
            _docu_texts[id_name] = {content, label_text, res_langu, short_text, size};
            // check for new DOCU_LINKs
            docuLinks.forEach(key => {
                if (!_docu_texts.hasOwnProperty(key)) {
                    _docu_texts[key] = null
                    hasDocuLinks ||= true;
                }
            });
        }

        // recursive check
        if (hasDocuLinks) await _getDocuTexts(engine);
    }

    /**
     * Update existing tiles if the position of the hotspot has changed,
     * create new tiles for new hotspots
     * @private
     * @param {Help4.engine.ur.UrHarmonizationEngine} engine
     * @returns {boolean}
     */
    function _updateTiles(engine) {
        const {
            Feature: {UrDebugging},
            selector: {Selector, methods},
            service: {recording: {SameWindowPlayback}}
        } = Help4;

        const {
            Connection: {APP_TYPE},
            Update: {ICON_POSITION: {top}},
        } = Help4.engine.ur;

        const {_currentApp, _urHotspots, _texts, _tiles, _tileStatus, controller} = engine;
        const appFrame = engine.getAppFrame();
        const isUI5 = _currentApp === APP_TYPE.UI5;

        // do not store tile status for UI5
        const selectorToInfo = (selector, tile) => isUI5
            ? null
            : SameWindowPlayback.urSelectorToInfo({
                appFrame,
                appFrameRect: appFrame.getBoundingClientRect(),
                selector,
                position: tile._apiData.position,
                window: {document: appFrame.ownerDocument}
            });

        const tilePositions = new Set();    // XRAY-5205 - avoid duplicate entries based on position
        // setting the offset to aid the topmost check when updating hotspot status;
        // the hotspot will be Top End (Above), so the test point should be in the top right corner
        const offset = {x: 0.95, y: 0};
        const posToString = ({x, y, width: w, height: h}) => `${x}:${y}-${w}x${h}`;

        // update moved tiles
        // don't check for existing tile positions here - tiles are not considered duplicates, if they move on top of each other
        const updateMovedTile = (hotspot) => {
            const {_id, position} = hotspot;
            const tile = _tiles.get(_id);
            tile._apiData.position = position;
            tilePositions.add(posToString(position));

            const selector = {rule: 'UrHarmonizationTileSelector', offset, value: _id};
            _tileStatus[_id] = selectorToInfo(selector, tile);
            return tile;
        };

        const {core: {screenId}} = controller.getConfiguration();
        const def = engine._getDefaults();

        // create new tiles
        // consider duplicates, i.e. when the position is already filled by another tile
        const createNewTile = (hotspot) => {
            const {_id, hotspotId, labelText, position, multiContent, contentSize, text} = hotspot;
            let {content, label_text, short_text, res_langu: language, size} = text;
            let selector;
            let alignWithText = false;
            let hotspotIconPos = top.end.ABOVE;

            if (isUI5) {
                /** use provided hotspotId directly instead of running {@link Help4.selector.methods.IdSelectorUI5.getSelector}, the selector is only used for UR playback;
                 * New UI5 assignments use {@link Help4.Help4.service.recording.SameWindowPlayback.pointToElement}, instead of {@link Help4.service.recording.IFrameService.pointToElement}.
                 * Playback uses _getHotspotStatus instead of _getUrHotspotStatus in {@link Help4.service.recording.PlaybackService.update}.
                 */
                selector = {rule: 'IdSelectorUI5', offset, value: '#' + methods.$E(hotspotId)};

                // XRAY-6490 - improve hotspot icon position or use default
                const adjust = _adjustHotspotPos(hotspotId, selector);
                alignWithText = adjust?.alignWithText || alignWithText;
                hotspotIconPos = adjust?.hotspotIconPos || hotspotIconPos;
                selector = adjust?.selector || selector;
            } else {
                const p = posToString(position);
                if (tilePositions.has(p)) return null;

                tilePositions.add(p);
                selector = {rule: 'UrHarmonizationTileSelector', offset, value: _id};
            }

            const tile = /** @type {Help4.widget.help.ProjectTile} */ Help4.cloneObject(def);
            // size found when transforming text or fallback calculation
            const bubbleSize = contentSize || size || _calcBubbleSize(multiContent || content);
            const label = labelText || label_text;  // use labelText from updateHotspots if available, backend text otherwise
            let title = label || short_text;  // use labelText from updateHotspots if available, use short text as fallback
            if (UrDebugging) title = (multiContent ? '🚧📎' : '🚧') + title;  // mark UR tiles for easier spotting in panel

            Help4.extendObject(tile, {
                _apiData: hotspot,
                _dataId: _id,
                _isUR: true,
                _standalone: true,
                alignWithText,
                bubbleSize,
                content: multiContent || content,
                hotspotAnchor: Selector.utf8ToBase64(selector),
                hotspotIconPos,
                hotspotIconType: 'help',
                hotspotId: _id + '-hs',
                hotspotStyle: 'ICON',
                hotspotSize: '1',
                hotspotTrianglePos: 'bottomend',
                id: _id,
                language,
                loio: _id,
                pageUrl: screenId,
                showTitleBar: true,  // be compliant with S/4 defaults; XRAY-4292
                summaryText: label ? short_text : '', // use short text unless we don't have a title, then use short text as title instead
                title,
                type: 'help'
            });

            _tileStatus[_id] = selectorToInfo(selector, tile);
            return tile;
        };

        let structural = false;
        // create a new tile map sorted in order of _urHotspots to replace previous _tiles
        const newTiles = /** @type {Map<string, Help4.widget.help.ProjectTile>} */ new Map();
        // do NOT use forEach here; as new Map().values().forEach is not supported in FF and Safari!
        for (const hotspot of _urHotspots.values()) {
            const {_id, _urId, _urMoved, additionalHelpKeys} = hotspot;
            let tile = _tiles.get(_id);
            const text = _texts[_urId];

            if (tile && _urMoved) {
                delete hotspot._urMoved;
                tile = updateMovedTile(hotspot);
            } else if (!tile && text) {
                hotspot.text = text;
                const {multiContent, contentSize} = _combineMultiContent(engine, _urId, additionalHelpKeys);
                hotspot.multiContent = multiContent;
                hotspot.contentSize = contentSize;
                tile = createNewTile(hotspot);
                structural ||= !!tile;
            }
            if (tile) newTiles.set(_id, tile);
        }

        // replace existing tiles with new collection
        engine._tiles = newTiles;
        return structural;
    }

    /**
     * UI5 hotspot icon position can be improved from the standard top end above, because we have access to the elements
     * shrink hotspot to text nodes if possible, except for input fields and lists - this shows hotspots closer to their context,
     * prevent overflow to the start when the text is very short (in texts/links, empty indicators, and ObjectStatus/ObjectNumber)
     *
     * @param {string} hotspotId
     * @param {Object} selector
     * @return {?Object} - can return updates to the selector, alignWithText, hotspotIconPos
     * @private
     */
    function _adjustHotspotPos(hotspotId, selector) {  // XRAY-6490
        const element = document.getElementById(hotspotId);
        if (!element) return null;

        const {top, middle} = Help4.engine.ur.Update.ICON_POSITION;
        const {methods} = Help4.selector;
        const {classList} = element;
        if (classList?.contains('sapMList')) {  // table
            return {alignWithText: true, hotspotIconPos: top.start.OUTSIDE};

        } else if (element.matches('.sapMInputBase, :has(> .sapMInputBase)')) {   // input fields and their wrappers
            return null;    // keep defaults

        } else if (element.matches('.sapMText, .sapMLnk, .sapMObjStatus, .sapMObjectNumber')) { // values
            // move hotspot behind unit of measure (outside the smart field)
            const closest = element.closest('.sapUiCompSmartField');
            if (closest) selector.value = '#' + methods.$E(closest.id);
            return {selector, alignWithText: true, hotspotIconPos: middle.END};

        } else if (classList?.contains('sapMCb')) { // checkbox
            // not checking if element exists, it can still be loading when Companion is already open (lazy loading data)
            selector.offset = {x: 0.6, y: 0.5};
            selector.value += ' .sapMCbBg';
            return {selector, hotspotIconPos: middle.END};  // align with icon (no padding)

        } else if (classList?.contains('sapMRb')) { // radio button
            // not checking if element exists, it can still be loading when Companion is already open (lazy loading data)
            selector.offset = {x: 0.6, y: 0.5};
            selector.value += ' .sapMRbSvg';
            return {selector, hotspotIconPos: middle.END};  // align with icon (no padding)

        } else if (classList?.contains('sapMSwtCont')) { // switch container
            selector.offset = {x: 0.6, y: 0.5};
            selector.value += ' .sapMSwt';
            return {selector, hotspotIconPos: middle.END};  // align with icon (no padding)
        } else if (classList?.contains('sapMSwt')) { // switch
            selector.offset = {x: 0.6, y: 0.5};
            return {selector, hotspotIconPos: middle.END};  // align with icon (no padding)
        }

        return {alignWithText: true};
    }

    function _combineMultiContent (engine, mainKey, additionalKeys) {
        const {Connection: {APP_TYPE}, Transformation} = Help4.engine.ur;
        const {_currentApp, _texts} = engine;
        if (_currentApp !== APP_TYPE.UI5 || !additionalKeys?.length) return {};

        const sizeMap = {
            's': 1,
            'm': 2,
            'l': 3,
            'xl': 4
        }
        const {content, label_text, short_text, size} = _texts[mainKey] || {};
        let contentSize = size || 's';
        const tooltip = label_text && short_text ? ` title="${short_text}"` : '';

        let multiContent = `<h2${tooltip}>${label_text || short_text || '...'}</h2>${Transformation.reduceHeadingLevels(content, 3)}`;
        for (const key of additionalKeys) {
            const {content, label_text, short_text, size} = _texts[key] || {};
            if (content) {
                const tooltip = label_text && short_text ? ` title="${short_text}"` : '';
                if (size && sizeMap[size] > sizeMap[contentSize]) contentSize = size;
                multiContent += `<br><h2${tooltip}>${label_text || short_text || '...'}</h2>${Transformation.reduceHeadingLevels(content, 3)}`;
            }
        }

        return {multiContent, contentSize};
    }

    /**
     * adjust bubble size to content
     * removing line breaks and splitting at tags, basically trying to find the longest line,
     * but this ignores inline tags being inline
     * @private
     * @param {string} content
     * @returns {'xs'|'s'|'m'|'l'|'xl'} size
     */
    function _calcBubbleSize(content) {
        const bubbleSize = content.match(/ data-bubble-size="(xs|s|m|l|xl)"/)?.[1];
        if (bubbleSize) return bubbleSize;

        const contentSize = content
        .replace(/\n/g, '')
        .split(/</)
        .reduce((previousValue, currentValue) => Math.max(previousValue, currentValue.length), 0);
        return contentSize > 150 ? 'm' : 's';
    }
})();