Source: service/ConditionService.js

(function() {
    /**
     * @typedef {Object} Help4.service.ConditionService.ConditionType
     * @property {'system_element'} element
     * @property {'system_url'} url
     */

    /**
     * @typedef {Object} Help4.service.ConditionService.Condition
     * @property {string} c - condition name
     * @property {string|boolean} v - condition value
     * @property {string} o - condition operator
     * @property {string} subtype - condition subtype
     * @property {*} anchor - ???
     */

    /**
     * @typedef {Help4.service.Service.Params} Help4.service.ConditionService.Params
     */

    /**
     * @type {Help4.service.ConditionService.ConditionType}
     */
    const CONDITION_TYPE = {element: 'system_element', url: 'system_url', date: 'system_date'};

    /**
     * Condition service class.
     * @augments Help4.service.Service
     */
    Help4.service.ConditionService = class extends Help4.service.Service {
        /**
         * @override
         * @param {Help4.service.ConditionService.Params} params
         */
        constructor(params) {
            super(params);
            this._conditions = {};
        }

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

        /**
         * Add an API condition.
         * @param {string} name
         * @param {string|boolean} value
         * @return {boolean}
         */
        setCondition(name, value) {
            if (_checkCondition(name, value)) {
                this._conditions[name] = value;
                _refreshTiles.call(this);
                return true;
            }
            return false;
        }

        /**
         * Set multiple API conditions.
         * @param {Object} conditions
         * @return {boolean}
         */
        setConditions(conditions) {
            const keys = Object.keys(conditions);
            for (const key of keys) {
                if (!_checkCondition(key, conditions[key])) return false;
            }

            const {_conditions} = this;
            keys.forEach(key => _conditions[key] = conditions[key]);

            _refreshTiles.call(this);
            return true;
        }

        /**
         * Deliver API conditions.
         * @return {Object}
         */
        getConditions() {
            return this._conditions;
        }

        /**
         * Remove API condition.
         * @param {string} name
         * @return {boolean}
         */
        removeCondition(name) {
            const {_conditions} = this;
            if (typeof name === 'string' && _conditions.hasOwnProperty(name)) {
                delete _conditions[name];
                _refreshTiles.call(this);
                return true;
            }
            return false;
        }

        /**
         * Transforms conditions into a normalized boolean formula.
         * @param {Help4.service.ConditionService.Condition[][]} conditions
         * @return {Array}
         */
        transformConditionsToBooleanFormula(conditions) {
            const formula = [];
            if (conditions.length > 1) formula.push('OR');

            for (const condition of conditions) {
                const expression = [];

                /** @type Help4.service.ConditionService.Condition */
                for (const c of condition) {
                    const object = {};
                    let value = null;
                    switch (c.c) {
                        case 'system_element':
                            value = {value: c.v, subtype: c.subtype, anchor: c.anchor};
                            break;
                        case 'system_url':
                            value = {value: c.v, subtype: c.subtype};
                            break;
                        case 'system_date':
                            value = {date1: c.date1, date2: c.date2, recurrence: c.recurrence, every: c.every, custom: c.custom};
                            break;
                        default:
                            // API
                            value = c.v;
                            break;
                    }
                    object[c.c] = Help4.removeUndefined(value);

                    switch (c.o) {
                        case 'EQ':
                            expression.push(object);
                            break;
                        case 'NE':
                            expression.push(['NOT', object]);
                            break;
                        case 'IS_TRUE':
                            object[c.c] = true;
                            expression.push(object);
                            break;
                        case 'IS_FALSE':
                            object[c.c] = false;
                            expression.push(object);
                            break;
                        case 'CT':
                            expression.push(['CONTAINS', object]);
                            break;
                        case 'CN':
                            expression.push(['NOT', ['CONTAINS', object]]);
                            break;
                        case 'ST':
                            expression.push(['STARTS_WITH', object]);
                            break;
                        case 'SN':
                            expression.push(['NOT', ['STARTS_WITH', object]]);
                            break;
                        case 'EW':
                            expression.push(['ENDS_WITH', object]);
                            break;
                        case 'EN':
                            expression.push(['NOT', ['ENDS_WITH', object]]);
                            break;
                        case 'VIS':
                            expression.push(['VISIBLE', object]);
                            break;
                        case 'VISN':
                            expression.push(['NOT', ['VISIBLE', object]]);
                            break;
                        case 'VEQ':
                            expression.push(['VALUE_EQUALS', object]);
                            break;
                        case 'VNEQ':
                            expression.push(['NOT', ['VALUE_EQUALS', object]]);
                            break;
                        case 'VCT':
                            expression.push(['VALUE_CONTAINS', object]);
                            break;
                        case 'VGT':
                            expression.push(['VALUE_GREATER_THAN', object]);
                            break;
                        case 'VLT':
                            expression.push(['VALUE_LESSER_THAN', object]);
                            break;
                        case 'VEM':
                            expression.push(['VALUE_EMPTY', object]);
                            break;
                        case 'VNEM':
                            expression.push(['NOT', ['VALUE_EMPTY', object]]);
                            break;
                        case 'SEL':
                            expression.push(['SELECTED', object]);
                            break;
                        case 'SELN':
                            expression.push(['NOT', ['SELECTED', object]]);
                            break;
                        case 'BTWN':
                            expression.push(['BETWEEN', object]);
                            break;
                        case 'OUTS':
                            expression.push(['OUTSIDE', object]);
                            break;
                        case 'AFTR':
                            expression.push(['AFTER', object]);
                            break;
                        case 'BFOR':
                            expression.push(['BEFORE', object]);
                            break;
                    }
                }

                if (expression.length) {
                    expression.unshift('AND');
                    formula.push(expression);
                }
            }

            return formula;
        }

        /**
         * Transforms a normalized boolean formula into conditions.
         * @param {Array} formula
         * @param {number} [depth = 0]
         * @return {Help4.service.ConditionService.Condition[][]|null}
         */
        transformBooleanFormulaToConditions(formula, depth = 0) {
            if (!formula) return null;

            const clonedFormula = !depth ? Help4.cloneArray(formula) : formula;  // do not clone during recursion!
            const condition = [];

            let op;
            for (let bf of clonedFormula) {
                if (typeof bf === 'string') {
                    // bf is an operator
                    op = bf;
                    continue;
                }

                if (Help4.isArray(bf)) {
                    // bf is a non-atomic boolean formula
                    bf = this.transformBooleanFormulaToConditions(bf, 1);
                } else {
                    // bf is an atomic boolean formula
                    const c = Object.keys(bf)[0];
                    const v = bf[c];
                    const o = typeof v === 'boolean'
                        ? (v ? 'IS_TRUE' : 'IS_FALSE')
                        : 'EQ';

                    bf = {c, o};

                    switch (c) {
                        case 'system_element':
                            bf.v = v.value;
                            bf.subtype = v.subtype;
                            bf.anchor = v.anchor;
                            break;
                        case 'system_url':
                            bf.v = v.value;
                            bf.subtype = v.subtype;
                            break;
                        case 'system_date':
                            bf.date1 = v.date1;
                            bf.date2 = v.date2;
                            bf.recurrence = v.recurrence;
                            bf.every = v.every;
                            bf.custom = v.custom;
                            break;
                        default:
                            // API
                            bf.v = v;
                            break;
                    }
                }

                switch (op) {
                    case 'OR':
                        if (formula.length === 2) return bf;
                        condition.push(Help4.isArray(bf) ? bf : [bf]);
                        break;
                    case 'NOT':
                        switch (bf.o) {
                            case 'IS_TRUE':
                                bf.o = 'IS_FALSE';
                                break;
                            case 'EQ':
                                bf.o = 'NE';
                                break;
                            case 'CT':
                                bf.o = 'CN';
                                break;
                            case 'ST':
                                bf.o = 'SN';
                                break;
                            case 'EW':
                                bf.o = 'EN';
                                break;
                            case 'VIS':
                                bf.o = 'VISN';
                                break;
                            case 'VEQ':
                                bf.o = 'VNEQ';
                                break;
                            case 'VEM':
                                bf.o = 'VNEM';
                                break;
                            case 'SEL':
                                bf.o = 'SELN';
                                break;
                        }
                        return bf;
                    case 'CONTAINS':
                        bf.o = 'CT';
                        return bf;
                    case 'STARTS_WITH':
                        bf.o = 'ST';
                        return bf;
                    case 'ENDS_WITH':
                        bf.o = 'EW';
                        return bf;
                    case 'VISIBLE':
                        bf.o = 'VIS';
                        return bf;
                    case 'VALUE_EQUALS':
                        bf.o = 'VEQ';
                        return bf;
                    case 'VALUE_CONTAINS':
                        bf.o = 'VCT';
                        return bf;
                    case 'VALUE_GREATER_THAN':
                        bf.o = 'VGT';
                        return bf;
                    case 'VALUE_LESSER_THAN':
                        bf.o = 'VLT';
                        return bf;
                    case 'VALUE_EMPTY':
                        bf.o = 'VEM';
                        return bf;
                    case 'SELECTED':
                        bf.o = 'SEL';
                        return bf;
                    case 'BETWEEN':
                        bf.o = 'BTWN';
                        return bf;
                    case 'OUTSIDE':
                        bf.o = 'OUTS';
                        return bf;
                    case 'AFTER':
                        bf.o = 'AFTR';
                        return bf;
                    case 'BEFORE':
                        bf.o = 'BFOR';
                        return bf;
                    case 'AND':
                    default:
                        condition.push(bf);
                        break;
                }
            }

            return condition;
        }

        /**
         * @param {Object} tile
         * @param {Array} tile.conditions
         * @param {string} [tile.hotspotAnchor]
         * @return {Promise<boolean>}
         */
        async checkConditions(tile) {
            // example
            // [
            //      [condition1 AND condition2 AND condition3] - if all conditions are passed here, then conditions check is passed and rest of the groups will not be checked
            //      OR
            //      [condition4 AND condition5]
            //      OR
            //      [condition6]
            // ]

            if (!tile) return true;  // fix for Safari; will not evaluate the conditions; XRAY-5166

            // pre-requisite: 'conditions' should be in Boolean Formula format
            const {conditions, hotspotAnchor} = tile;
            if (!conditions?.length) return true;

            const {isEditMode} = this._params.controller.getConfiguration();
            if (isEditMode) return true;

            const validateAND = async (group) => {
                for (const condition of group) {
                    // if one condition within AND is false the whole AND is false
                    if (!await this.validateCondition(condition, hotspotAnchor)) return false;
                }
                return true;  // all conditions were true
            }

            // groups are OR connected
            const groups = this.transformBooleanFormulaToConditions(conditions);
            for (const group of groups) {
                // conditions within a group are AND connected
                // if one AND is true the whole OR is true
                if (await validateAND(group)) return true;
            }
            return false;  // no AND was true
        }

        /**
         * @param {Help4.service.ConditionService.Condition} condition
         * @param {string} hotspotAnchor
         * @return {boolean|Promise<boolean>}
         */
        validateCondition(condition, hotspotAnchor) {
            // pre-requisite: condition should be in plain JSON format
            switch (condition.c) {
                case CONDITION_TYPE.element:
                    return _validateElementCondition.call(this, condition, hotspotAnchor);
                case CONDITION_TYPE.url:
                    return _validateURLCondition.call(this, condition);
                case CONDITION_TYPE.date:
                    return _validateDateCondition.call(this, condition);
                default:
                    // API condition
                    return _validateAPICondition.call(this, condition);
            }
        }

        /**
         * Get value from URL.
         * @param {string} type
         * @return {string}
         */
        getURLValue(type) {
            const url = new URL(window.location.href);
            switch (type) {
                case 'full':
                    return url.href;
                case 'domain':
                    return url.host;
                case 'query':
                    return url.search.substring(1);  // remove ?
                case 'pathname':
                    return url.pathname.substring(1);  // remove /
                case 'hash':
                    return url.hash.substring(1);  // remove #
            }
        }
    };

    /**
     * @param {string} name
     * @param {string|boolean} value
     * @return {boolean}
     * @private
     */
    function _checkCondition(name, value) {
        const checkNameType = typeof name === 'string';
        const checkNameValue = !Help4.includes(['system_url', 'system_element', 'system_date'], name);  // reserved condition names used internally for URL, Element, Date types respectively
        const checkValueType = typeof value === 'string' || typeof value === 'boolean';
        return checkNameType && checkNameValue && checkValueType;
    }

    /**
     * @private
     */
    function _refreshTiles() {
        const {controller} = this._params;
        const {isOpen} = controller.getConfiguration();
        const handler = controller.getHandler();
        if (isOpen && handler instanceof Help4.controller.Help) {
            handler.setCarouselTab({force: true, reason: 'conditions'});
        }
    }

    /**
     * @param {Help4.service.ConditionService.Condition} condition
     * @return {boolean}
     * @private
     */
    function _validateURLCondition(condition) {
        const conditionValue = (condition.v || '').trim().toLowerCase();
        const realValue = this.getURLValue(condition.subtype).toLowerCase();

        switch (condition.o) {
            case 'CT':
                return realValue.indexOf(conditionValue) >= 0;
            case 'CN':
                return realValue.indexOf(conditionValue) < 0;
            case 'ST':
                return realValue.indexOf(conditionValue) === 0;
            case 'SN':
                return realValue.indexOf(conditionValue) !== 0;
            case 'EW':
                const lengthDiff1 = realValue.length - conditionValue.length;
                return realValue.lastIndexOf(conditionValue) === lengthDiff1;
            case 'EN':
                const lengthDiff2 = realValue.length - conditionValue.length;
                return realValue.lastIndexOf(conditionValue) < lengthDiff2;
        }
    }

    /**
     * @param {Help4.service.ConditionService.Condition} condition
     * @return {boolean}
     * @private
     */
    function _validateAPICondition(condition) {
        const {c, o, v} = condition;
        const {_conditions} = this;

        // condition fails without evaluating
        // 1. there isn't any conditions set via API
        // 2. condition is not available in the conditions set via API
        if (!Object.keys(_conditions).length || !_conditions.hasOwnProperty(c)) return false;

        switch (o) {
            case 'IS_TRUE':
                return _conditions[c] === true;
            case 'IS_FALSE':
                return _conditions[c] === false;
            default:
                // BooleanFormula doesn't support boolean value for CONTAINS operator
                if (o === 'CT' && typeof v === 'boolean') return _conditions[c] === v;

                try {
                    const formula = new Help4.BooleanFormula(this.transformConditionsToBooleanFormula([[condition]]));
                    const result = formula.resolve(_conditions);
                    formula.destroy();
                    return !!result;
                } catch (e) {
                    return false;
                }

        }
    }

    /**
     * @param {Help4.service.ConditionService.Condition} condition
     * @param {string} hotspotAnchor
     * @return {Promise<boolean>}
     * @private
     */
    async function _validateElementCondition(condition, hotspotAnchor) {
        const anchor = condition.subtype === 'assigned_hotspot' ? hotspotAnchor : condition.anchor;
        if (!anchor) return false;

        const checkDescendents = Help4.includes(['VEQ', 'VNEQ', 'VCT', 'VGT', 'VLT', 'VEM', 'VNEM', 'SEL', 'SELN'], condition.o);
        const status = await Help4.widget.companionCore.Core.getElementHotspotStatus(anchor, checkDescendents);

        const conditionValue = (condition.v || '').trim().toLowerCase();
        const elementValue = (status.value ?? (status.text || '')).trim().toLowerCase();

        switch (condition.o) {
            case 'VIS':
                return status.visible;
            case 'VISN':
                return !status.visible;
            case 'VEQ':
                return conditionValue === elementValue;
            case 'VNEQ':
                return conditionValue !== elementValue;
            case 'VCT':
                return elementValue.indexOf(conditionValue) >= 0;
            case 'VGT': {
                const elementNumber = Number(elementValue);
                const conditionNumber = Number(conditionValue);
                if (!conditionValue || isNaN(conditionNumber) || isNaN(elementNumber)) return false;
                return elementNumber > conditionNumber;
            }
            case 'VLT': {
                const elementNumber = Number(elementValue);
                const conditionNumber = Number(conditionValue);
                if (!conditionValue || isNaN(conditionNumber) || isNaN(elementNumber)) return false;
                return elementNumber < conditionNumber;
            }
            case 'VEM':
                return Help4.includes([undefined, null, ''], elementValue);
            case 'VNEM':
                return !Help4.includes([undefined, null, ''], elementValue);
            case 'SEL':
                return status.checked;
            case 'SELN':
                return !status.checked;
        }
    }

    /**
     * @param {Help4.service.ConditionService.Condition} condition
     * @return {boolean}
     * @private
     */
    function _validateDateCondition(condition) {
        const date1 = condition.date1 ? new Date(condition.date1).setHours(0) : 0;
        const date2 = condition.date2 ? new Date(condition.date2).setHours(0) : 0;
        const dateObj = new Date();
        const currentDate = new Date(dateObj.getFullYear() + '-' + (dateObj.getMonth() + 1) + '-' + dateObj.getDate()).setHours(0);
        const {o, recurrence} = condition;
        let isValid = false;

            switch (o) {
                case 'EQ':
                    return currentDate === date1;
                case 'NE':
                    return currentDate !== date1;
                case 'BTWN':
                    isValid = currentDate >= date1 && currentDate <= date2;
                    break;
                case 'OUTS':
                    isValid = (currentDate < date1 && currentDate < date2) || (currentDate > date1 && currentDate > date2) || (currentDate < date1 && currentDate > date2);
                    break;
                case 'AFTR':
                    isValid = currentDate > date1;
                    break;
                case 'BFOR':
                    isValid = currentDate < date2;
                    break;
        }

        if (isValid && recurrence !== 'always') {
            // validate recurring
            switch (recurrence) {
                case 'weekly':
                    return _validateWeekly(condition);
                case 'monthly':
                    return _validateMonthly(condition);
            }
        }
        return isValid;
    }

    function _validateWeekly(condition) {
        const {every} = condition;
        const everyList = every.split(',');
        const day = {
            0: 'sunday',
            1: 'monday',
            2: 'tuesday',
            3: 'wednesday',
            4: 'thursday',
            5: 'friday',
            6: 'saturday'
        }[new Date().getDay()];

        return Help4.includes(everyList, day);
    }

    function _validateMonthly(condition) {
        const {every} = condition;
        const fullYear = new Date().getFullYear();
        const month = new Date().getMonth() + 1;
        const currentDate = new Date().getDate();
        const totalDaysInCurrentMonth = new Date(fullYear, month, 0).getDate();

        const isValidNumber = num => !isNaN(num) && Number(num) > 0;
        switch (every) {
            case 'firstday':
                return currentDate === 1;
            case 'secondday':
                return currentDate === 2;
            case 'lastday':
                return currentDate === totalDaysInCurrentMonth;
            case 'secondtolastday':
                return currentDate === totalDaysInCurrentMonth - 1;
            case 'custom':
                const {custom} = condition;
                const datesList = [];
                custom?.split(',').map(date => {
                    if (date.indexOf('-') > 0) {
                        const [s, e] = date.split('-');
                        const start = Number(s);
                        const end = Number(e);
                        if (isValidNumber(start) && isValidNumber(end) && start <= end) {
                            for (let i = start; i <= end; i++) {
                                datesList.push(i);
                            }
                        }
                    } else {
                        const d = Number(date);
                        if (isValidNumber(d)) datesList.push(d);
                    }
                });
                return Help4.includes(datesList, currentDate);
        }
    }
})();