(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);
}
}
})();