Source: selector/methods/Utils.js

(function() {
    /**
     * @enum {number}
     */
    Help4.selector.SELECTOR_QUALITY = {
        best: 1,  // DataAttrSelector
        good: 2,  // "safe" selectors
        lang: 3,  // "safe" but language dependent
        other: 10  // "unsafe"
    };

    /**
     * @enum {number}
     */
    Help4.selector.SELECTOR_SCORES = {
        safe: 1.6,
        good: 1.4,
        lang: 1.2,
        unsafe: 1,
        aria: 1.1
    };

    Help4.selector.methods._CACHE = {};

    /**
     * @typedef {Object} Help4.selector.methods.Utils.Config
     * @property {number} depthThreshold
     * @property {number} desiredMinLength
     * @property {boolean} enabled
     * @property {string[]} filter
     * @property {boolean} ignoreNumbers
     * @property {string[]} invalidClasses
     * @property {number} minLength
     * @property {string[]} tags
     * @property {Help4.control2.SizeWidthHeight} textSize
     * @property {number} truncationLength
     */

    /**
     * @typedef {Object} Help4.selector.methods.Utils.Config2
     * @property {boolean} enabled
     * @property {Array<{tagName: string}>} elements
     * @property {number} searchLevel
     * @property {string[]} skippedTags
     */

    /** Util class for selector methods */
    Help4.selector.methods.Utils = class {
        /**
         * @param {string} selector
         * @returns {?HTMLElement}
         */
        static getElement(selector) {
            const elements = Help4.selector.methods.$(selector);
            for (const element of elements) {
                if (Help4.Element.isVisible(element)) return element;  // return the 1st visible one
            }
            return elements[0] || null;  // none is visible; return the 1st
        }

        /** record selector */
        static record() {
            Help4.API._stopRecording = function() {
                Help4.selector.Selector.stopRecording();
            };

            const controller = Help4.getController();
            let s = controller.getSelector() || {};
            s = Help4.jsonClone(s);

            // only run urEngine closed if panel is not already open
            const {isOpen} = controller.getConfiguration();
            const urEngine = isOpen ? null : controller.getEngine('urHarmonization');
            const previous = urEngine?.runClosed();

            Help4.selector.Selector.startRecording(s, selector => {
                const decoded = Help4.selector.Selector.base64ToUtf8(selector);
                console.log(decoded);

                // queue after this thread / fired events (hotspotAssignStop)
                setTimeout(() => {
                    Help4.API._stopRecording();
                    urEngine?.runClosed(previous);
                }, 1);
            });

            Help4.selector.Selector.toggleRecordingInfo();
        }

        /**
         * @param {Help4.selector.methods.Utils.Config} config
         * @param {HTMLElement} elem
         * @param {string} expectedText
         * @returns {?string}
         */
        static matchesText(config, elem, expectedText) {
            if (!config.depthThreshold) config.depthThreshold = 0;

            let extractedText = this.extractText(config, elem);
            const {text, depth} = _getTextInfo.call(this, extractedText);

            const min = Math.max(0, depth - config.depthThreshold);
            const max = depth + config.depthThreshold;
            const {text: eText, depth: eDepth} = _getTextInfo.call(this, expectedText);

            return text === eText && Help4.isPointInRange(eDepth, min, max) ? expectedText : '';
        }

        /**
         * IE11 compatibility.
         * checks if element matches the selector
         * @param {HTMLElement} elem
         * @param {string} selector
         * @returns {boolean}
         */
        static matchesSelector(elem, selector) {
            const m = elem.matches || elem.msMatchesSelector || elem.webkitMatchesSelector;
            return m ? m.call(elem, selector) : false;
        }


        /**
         * @param {Help4.selector.methods.Utils.Config} config
         * @param {HTMLElement} elem
         * @returns {string}
         */
        static extractText(config, elem) {
            if (Help4.control.Store.find(elem) || Help4.control2.Store.find(elem)) return '';  // ignore our own DOM

            const {enabled, tags, truncationLength} = config;
            if (enabled !== false && (!tags || tags.indexOf(elem.tagName) >= 0)) {
                let v = _getFirstNodeValue.call(this, config, elem);

                if (v && truncationLength > 0) {
                    const va = v.split(' ');  // white space is delimiter for value and depth; see _getFirstNodeValue
                    const de = va.pop();  // last item is depth

                    let txt = va.join('');  // merge all texts
                    if (txt.length > truncationLength) {
                        txt = txt.substring(0, truncationLength);

                        const out = [];
                        for (const t of va) {
                            if (txt.length >= t.length) {
                                out.push(txt.substring(0, t.length));
                                txt = txt.substring(t.length);
                                if (!txt) break;
                            } else {
                                out.push(txt);
                                break;
                            }
                        }

                        v = out.join(' ') + ' ' + de;
                    }
                }

                return v;
            }

            return '';
        }

        /**
         * extracts href (full url) from given element
         * @param {{enabled: boolean, tags: string[], useQuery: boolean, useHash: boolean, invalidQueryKeys: string[]}} config
         * @param {HTMLElement} elem
         * @returns {string}
         */
        static extractHref(config, elem) {
            const {enabled, tags, useHash, useQuery, invalidQueryKeys} = config;
            if (enabled === false || tags && tags.indexOf(elem.tagName) < 0) return '';

            // getAttribute will return local href, such as "/img/img1.png"
            // while elem.href will always return absolute urls
            // absolute urls do not match with querySelectorAll
            const v = elem.getAttribute('href') || elem.href;
            if (!v || typeof v !== 'string') return '';

            let xq = v.indexOf('?');
            const xh = v.indexOf('#');
            if (xh > 0 && xq > xh) xq = -1;  // fiori: query strings within hash

            const p = [];
            if (xh > 0 && useHash) {
                p.push(v.substring(xh));
            }
            if (xq > 0 && useQuery) {
                const q = v.substring(xq + 1, xh > 0 ? xh : undefined).split('&');
                const r = [];

                for (const k of q) {
                    if (invalidQueryKeys.indexOf(k.split('=')[0]) < 0) r.push(k);
                }

                p.unshift('?' + r.sort().join('&'));
            }

            let x = -1;
            if (xq > 0 && xh > 0) {
                x = Math.min(xq, xh);
            } else if (xq > 0 || xh > 0) {
                x = Math.max(xq, xh);
            }
            p.unshift(x > 0 ? v.substring(0, x) : v);

            return p.join('');
        }

        /**
         * extracts src (reduced url) from given element
         * @param {{enabled: boolean}} config
         * @param {HTMLElement} elem
         * @returns {string}
         */
        static extractSrc(config, elem) {
            if (config.enabled === false) return '';

            let v = elem.src || elem.getAttribute('src');
            if (!v || typeof v !== 'string') return '';

            if (v) {
                const x = v.lastIndexOf('/');
                v = v.substring(x < 0 ? 0 : x + 1).replace(/_hover/, '');
            }

            return v;
        }

        /**
         * @param {string} type
         * @param {string} url
         * @returns {string[]}
         */
        static createSmartUrl(type, url) {
            // href: _extractHref provides full url
            // src: _extractSrc provides reduced url
            const first = type === 'href' ? '^' : '*';

            let c = url.split(/\?|#/);
            const s = ['[' + type + first + '="' + Help4.selector.methods.$E(c.shift()) + '"]'];

            if (url.indexOf('#') >= 0) {
                s.push('[' + type + '$="' + Help4.selector.methods.$E('#' + c.pop()) + '"]');
            }

            if (url.indexOf('?') >= 0) {
                c = c[0].split('&');
                for (const part of c) {
                    s.push('[' + type + '*="' + Help4.selector.methods.$E(part) + '"]');
                }
            }

            return s;
        }

        /**
         * @param {Help4.selector.methods.Utils.Config2} config
         * @param {HTMLElement} elem
         * @returns {HTMLElement}
         */
        static improveInitialElement(config, elem) {
            const {enabled, skippedTags, searchLevel} = config;
            if (enabled === false) return elem;

            let e = skippedTags.indexOf(elem.tagName) >= 0 ? Help4.Element.getParent(elem, true) : elem;

            let i = 0;
            while (e && i++ < searchLevel) {
                if (_isGroupElement.call(this, config, e)) return e;
                e = Help4.Element.getParent(e, true);
            }

            return elem;
        }
    };

    /**
     * delivers text info
     * @memberof Help4.selector.methods.Utils
     * @private
     * @param {string} text
     * @returns {{depth: number, text: string}}
     */
    function _getTextInfo(text) {
        text = text.split(' ');

        return {
            depth: Number(text.pop()),  // depth of text relative to node
            text: text.join(' ')  // extracted text
        };
    }

    /**
     * @memberof Help4.selector.methods.Utils
     * @private
     * @param {Help4.selector.methods.Utils.Config} config
     * @param {HTMLElement} elem
     * @returns {string}
     */
    function _getFirstNodeValue(config, elem) {
        const value = [];
        const depth = [];
        let {filter, textSize, invalidClasses, ignoreNumbers, desiredMinLength} = config;
        if (invalidClasses) invalidClasses = invalidClasses.join(' ').toLowerCase().split(' ');

        const css = e => {
            const cn = e.className.toLowerCase().trim().replace(/\s+/g, ' ').split(' ');
            for (const c of cn) {
                if (invalidClasses.indexOf(c) >= 0) return false;
            }
            return true;
        }

        const scan = (e, d) => {
            // check for invalid classes
            if (invalidClasses && !css(e)) return false;

            const {childNodes, offsetWidth, offsetHeight} = e;
            const d1 = d + 1;

            for (const n of childNodes) {
                let v;
                const {nodeName, nodeType, nodeValue} = n;
                if (nodeType === 1) {  // element node
                    if (nodeName === 'svg') continue;  // XRAY-2080; looking into svg not supported

                    if (!filter || filter.indexOf(nodeName) >= 0) {  // only scan if allowed by whitelist filter
                        if (v = scan(n, d1)) return true;
                    }
                } else if (nodeType === 3) {  // text node
                    // text size restrictions to avoid recording of invisible texts
                    if (textSize && (offsetWidth < textSize.width || offsetHeight < textSize.height)) continue;

                    v = nodeValue.replace(/\s/g, '');  // remove all whitespaces
                    if (ignoreNumbers) v = v.replace(/\d/g, '#');  // remove all numbers

                    if (v) {
                        value.push(v);
                        depth.push(d);

                        // value is considered to be long enough
                        if (value.join('').length >= desiredMinLength) return true;
                    }
                }
            }

            return false;
        }

        // case 1: desiredMinLength is reached; all fine
        // case 2: desiredMinLength not reached but at least minLength is reached
        return scan(elem, 0) || value.length && value.join('').length >= config.minLength
            // white space is removed from value; therefore free to use as delimiter
            ? value.join(' ') + ' ' + depth[0]
            : '';
    }

    /**
     * @memberof Help4.selector.methods.Utils
     * @private
     * @param {Help4.selector.methods.Utils.Config2} config
     * @param {HTMLElement} elem
     * @returns {boolean}
     */
    function _isGroupElement(config, elem) {
        for (const c of config.elements) {
            for (const k in c) {
                if (c.hasOwnProperty(k) && c[k] !== elem[k]) return false;
            }
        }
        return true;
    }
})();