Source: Tracker/builder.js

/**
 * @class
 * @classdesc Hits builder.
 * @name BuildManager
 * @param tag {object} Instance of the Tag used
 * @public
 */
var BuildManager = function (tag) {
    'use strict';

    var self = this;
    var MINSIZE = 2000;
    var MAXSIZE = 0;
    var AVAILABLESIZE = 0;

    /**
     * List of querystring parameters which can be truncated
     * For others parameters (ati, atc, pdtl, etc.), see truncate option
     * @type {string[]}
     * @private
     */
    var _multiHitable = ['dz'];

    /**
     * Request method
     * @type {string}
     * @private
     */
    var _requestMethod = '';

    /**
     * Create an element of the "preQuery" object.
     * @memberof BuildManager#
     * @function
     * @param param {string} name of a querystring parameter
     * @param value {string} value of a querystring parameter
     * @param truncate {boolean} true if the parameter can truncate
     * @param multihit {boolean} true if the parameter must be added in all multi-hits
     * @param separator {string} separator to consider for multi-hits
     * @param encode {boolean} true if if the parameter value is encoded
     * @param last {boolean} true if if the parameter must be placed at the end
     * @returns {object}
     * @private
     */
    var _makeSubQueryObject = function (param, value, truncate, multihit, separator, encode, last) {
        var p = '&' + param + '=';
        return {
            param: p,
            paramSize: p.length,
            str: value,
            strSize: value.length,
            truncate: truncate,
            multihit: multihit,
            separator: separator || '',
            encode: encode,
            last: last
        };
    };

    /**
     * Create one hit with the first elements of the given dictionary and deletes them from it.
     * @memberof BuildManager#
     * @function
     * @param subQueries {object} dictionary generated with the _preQuery method
     * @param maxSizeHit {number} size available for inserting hit parameters
     * @returns {Array}:
     *  - index 0: query string to put on a hit
     * <br />- index 1: dictionary remains unused, null if empty
     * @private
     */
    var _makeOneHit = function (subQueries, maxSizeHit) {
        var hit = '';
        var freeSpace = 0;
        var usedSpace = 0;
        var index = 0;
        var indexSeparator = -1;
        var subQueryObject = null;
        var remainingParameters = null;
        for (var key in subQueries) {
            if (subQueries.hasOwnProperty(key)) {
                subQueryObject = subQueries[key];
                if (subQueryObject) {
                    freeSpace = maxSizeHit - usedSpace;
                    if (subQueryObject.last && remainingParameters !== null) {
                        remainingParameters[key] = subQueryObject;
                    } else if (subQueryObject.strSize + subQueryObject.paramSize <= freeSpace) {
                        // The new parameter fits into the hit
                        hit += subQueryObject.param + subQueryObject.str;
                        usedSpace += subQueryObject.paramSize + subQueryObject.strSize;
                    } else {
                        remainingParameters = remainingParameters || {};
                        remainingParameters[key] = subQueryObject;
                        if (subQueryObject.truncate) {
                            // The new parameter does not fit into the hit but it may be truncated
                            index = freeSpace - subQueryObject.paramSize;
                            if (subQueryObject.separator) {
                                var query = subQueryObject.str.substring(0, freeSpace);
                                if (subQueryObject.encode) {
                                    indexSeparator = query.lastIndexOf(encodeURIComponent(subQueryObject.separator));
                                } else {
                                    indexSeparator = query.lastIndexOf(subQueryObject.separator);
                                }
                                if (indexSeparator > 0) {
                                    index = indexSeparator;
                                }
                            }
                            hit += subQueryObject.param + subQueryObject.str.substring(0, index);
                            usedSpace += subQueryObject.paramSize + subQueryObject.str.substring(0, index).length;
                            remainingParameters[key].str = subQueryObject.str.substring(index, subQueryObject.strSize);
                            remainingParameters[key].strSize = remainingParameters[key].str.length;
                        }
                    }
                }
            }
        }
        return [hit, remainingParameters];
    };

    /**
     * First step in the formatting of the querystring parameters: make a dictionary paramName => object (see _makeSubQueryObject).
     * <br />The resulting dictionary contains all parameters in string format, in the right order, ready to be truncate if they can, etc.
     * @memberof BuildManager#
     * @function
     * @param buffer {object} Buffer part to process
     * @returns {Array} :
     *  - index 0 : preProcessed parameters
     * <br />- index 1 : sum of the parameter size
     * <br />- index 2 : true if a parameter was too long and can't be truncated
     * <br />- index 3 : if sizeError name of the offending parameter
     * @private
     */
    var _preQuery = function (buffer) {
        var subQueries = {};
        var sizeError = false;
        var keySizeError = undefined;
        var bufferValue, bufferOptions;
        var encode, truncate, multihit, last;
        var subQueryObject, lastKey, lastSubQueryObject;
        var baseMultihit = '';
        for (var key in buffer) {
            if (buffer.hasOwnProperty(key)) {
                encode = false;
                truncate = false;
                multihit = false;
                last = false;
                bufferValue = buffer[key]._value;
                bufferOptions = buffer[key]._options || {};
                if (typeof bufferOptions.encode === 'boolean') {
                    encode = bufferOptions.encode;
                }
                // If the parameter is a function, it is executed to obtain a value
                // which will not pass the following processing (transformation into a string, encoding, etc.)
                if (typeof bufferValue === 'function') {
                    bufferValue = bufferValue();
                }
                if (bufferValue instanceof Array) {
                    // If the value is an array, it is transformed into strings separated by a given separator or by ","
                    bufferValue = bufferValue.join(bufferOptions.separator || ',');
                } else if (typeof bufferValue === 'object') {
                    // If it's an object, we serialize it
                    bufferValue = ATInternet.Utils.jsonSerialize(bufferValue);
                } else if (typeof bufferValue === 'undefined') {
                    bufferValue = 'undefined';
                } else {
                    bufferValue = bufferValue.toString();
                }
                if (encode) {
                    bufferValue = encodeURIComponent(bufferValue);
                }
                if (ATInternet.Utils.arrayIndexOf(_multiHitable, key) > -1) {
                    truncate = true;
                } else if (typeof bufferOptions.truncate === 'boolean') {
                    truncate = bufferOptions.truncate;
                }
                if (typeof bufferOptions.multihit === 'boolean') {
                    multihit = bufferOptions.multihit;
                }
                if (typeof bufferOptions.last === 'boolean') {
                    last = bufferOptions.last;
                }
                subQueryObject = _makeSubQueryObject(key, bufferValue, truncate, multihit, bufferOptions.separator, encode, last);
                if (multihit) {
                    // We remove the size of multihit parameters
                    AVAILABLESIZE -= (subQueryObject.paramSize + subQueryObject.strSize);
                    baseMultihit += subQueryObject.param + subQueryObject.str;
                } else {
                    if (!last) {
                        subQueries[key] = subQueryObject;
                        if (((subQueries[key].paramSize + subQueries[key].strSize) > AVAILABLESIZE) && !subQueries[key].truncate) {
                            // The parameter is too long and cannot be truncated
                            tag.emit('Tracker:Hit:Build:Error', {
                                lvl: 'ERROR',
                                msg: 'Too long parameter: "' + subQueries[key].param + '"',
                                details: {value: subQueries[key].str}
                            });
                            sizeError = true;
                            keySizeError = key;
                            break;
                        }
                    } else {
                        // The parameter has the option'last', we keep it to place it at the end of the dictionary
                        if (subQueryObject.paramSize + subQueryObject.strSize > AVAILABLESIZE) {
                            subQueryObject.str = subQueryObject.str.substring(0, AVAILABLESIZE - subQueryObject.paramSize);
                            subQueryObject.strSize = subQueryObject.str.length;
                        }
                        lastKey = key;
                        lastSubQueryObject = subQueryObject;
                    }
                }
            }
        }
        if (lastKey) {
            subQueries[lastKey] = lastSubQueryObject;
        }
        return [subQueries, sizeError, keySizeError, baseMultihit];
    };

    /**
     * Build at least one queryString (except for "?s=xxxx" parameter) using {@link ATInternet.Tracker.BufferManager parameters in the BufferManager}.
     * <br />Indeed if the total size of the params involves a too long hit it's truncated in several hits (multihits).
     * @memberof BuildManager#
     * @function
     * @param customParams {object} Custom parameters forced for queryString
     * @param filters {Array} List of buffer filters
     * @param callback {function} Callback which will use the queryString
     * @returns {string|Array} String if the total size was short enough, other wise it will be an array.
     * <br />Warning: it will be an array with one element if one of the parameter was too long and couldn't be truncated.
     * @private
     */
    var _buildParams = function (customParams, filters, callback) {

        var baseMultihit = '';
        var createQueriesString = function (buffer) {
            if (buffer === {}) {
                return [];
            }
            var queries = [];
            var resPreQuery = _preQuery(buffer);
            var queryStrParams = resPreQuery[0];
            var sizeError = resPreQuery[1];
            baseMultihit = resPreQuery[3];
            var resHit;
            if (sizeError) {
                // There is an error on the resPreQuery parameter[2]
                // We generate an array with only one element to indicate an error
                // This array will contain a hit with the parameter that caused the "truncated error" and
                // the parameter "mherr"
                var paramError = resPreQuery[2];
                var queryError = queryStrParams[paramError];
                queryError['str'] = queryError['str'].substring(0, AVAILABLESIZE - queryError['paramSize']);
                queryError['strSize'] = queryError['str'].length;
                var newPreQueries = {};
                newPreQueries['mherr'] = _makeSubQueryObject('mherr', '1', false, false, '', false, false);
                newPreQueries[paramError] = queryError;
                queryStrParams = newPreQueries;
            }
            resHit = _makeOneHit(queryStrParams, AVAILABLESIZE);
            if (resHit[1] === null) {
                // We are not in the case of a multihit so we return a string
                queries = resHit[0];
            } else {
                // We are in the case of a multihit so we return an array
                queries.push(resHit[0]);
                while (resHit[1] !== null) {
                    resHit = _makeOneHit(resHit[1], AVAILABLESIZE);
                    queries.push(resHit[0]);
                }
            }
            return queries;
        };

        var QueriesString = '';
        if (!tag.buffer.presentInFilters(filters, 'hitType')) {
            filters = tag.buffer.addInFilters(filters, 'hitType', ['page']);
        }
        filters = tag.buffer.addInFilters(filters, 'hitType', ['all']);
        var params, key, bufferParam;
        if (ATInternet.Utils.isObject(customParams)) {
            // Retrieve the filters and add the filter for permanent
            filters = tag.buffer.addInFilters(filters, 'permanent', true);
            params = tag.buffer.get(filters, true);
            var paramValue, paramOptions;
            for (key in customParams) {
                if (customParams.hasOwnProperty(key)) {
                    paramOptions = {};
                    if (customParams[key] && typeof customParams[key] === 'object' && customParams[key].hasOwnProperty('_value')) {
                        paramValue = customParams[key]._value;
                        if (customParams[key].hasOwnProperty('_options')) {
                            paramOptions = customParams[key]._options;
                        }
                    } else {
                        paramValue = customParams[key];
                    }
                    bufferParam = ATInternet.Utils.privacy.testBufferParam(key, paramValue);
                    if (bufferParam.toSetInBuffer) {
                        params[key] = {
                            '_value': bufferParam.value,
                            '_options': paramOptions
                        };
                    }
                }
            }
            QueriesString = createQueriesString(params);
        } else {
            params = tag.buffer.get(filters, true);
            QueriesString = createQueriesString(params);
            // We take out of the buffer all the parameters that are not permanent
            for (key in params) {
                if (params.hasOwnProperty(key)) {
                    if (!params[key]._options || !params[key]._options.permanent) {
                        tag.buffer.del(key);
                    }
                }
            }
        }
        callback && callback(QueriesString, baseMultihit);
    };

    /**
     * Get collect domain depending on configuration
     * @name getCollectDomain
     * @memberof BuildManager#
     * @function
     * @return {string}
     * @public
     */
    self.getCollectDomain = function () {
        var collectDomain = '';
        var log = tag.getConfig('logSSL') || tag.getConfig('log');
        var domain = tag.getConfig('domain');
        if (log && domain) {
            collectDomain = log + '.' + domain;
        } else {
            collectDomain = tag.getConfig('collectDomainSSL') || tag.getConfig('collectDomain');
        }
        return collectDomain;
    };

    /**
     * Build the base URL with level 1 and call the callback given with it
     * @memberof BuildManager#
     * @function
     * @param callback {function} Callback which will use the base URL
     * @private
     */
    var _buildConfig = function (callback) {
        var finalURL = '';
        var baseURL = tag.getConfig('baseURL');
        if (baseURL) {
            finalURL = baseURL;
        } else {
            // Collection domain
            var collectDomain = self.getCollectDomain();
            // Pixel path
            var pixelPath = tag.getConfig('pixelPath');
            pixelPath = pixelPath || '/';
            if (pixelPath.charAt(0) !== '/') {
                pixelPath = '/' + pixelPath;
            }
            // Final URL
            if (collectDomain) {
                var configHttp = tag.getConfig('forceHttp');
                var protocol = configHttp ? 'http://' : 'https://';
                finalURL = protocol + collectDomain + pixelPath;
            }
        }
        // Callback
        var site = tag.getConfig('site');
        if (finalURL && site) {
            callback && callback(null, finalURL + '?s=' + site);
        } else {
            callback && callback({message: 'Config error'});
        }
    };

    /**
     * Build a list of hits (base URL + queryString)
     * @memberof BuildManager#
     * @function
     * @param customParams {object} Custom parameters forced
     * @param filters {Array} List of buffer filters
     * @param callback {function} callback which will use the hit list
     * @private
     */
    var _build = function (customParams, filters, callback) {
        _buildConfig(function (err, baseHit) {
            if (!err) {
                // We remove the size of the hit base and reserve a space
                // for the multihit management parameter just in case
                AVAILABLESIZE = MAXSIZE - (baseHit.length + '&mh=xxxx-xxxx-xxxxxxxxxxxxx'.length);
                _buildParams(customParams, filters, function (queriesString, baseMultihit) {
                    var hits = [];
                    var uuid = ATInternet.Utils.uuid();
                    var guid = uuid.num(13);
                    if (!(queriesString instanceof Array)) {
                        hits.push(baseHit + baseMultihit + queriesString);
                    } else {
                        for (var i = 1; i <= queriesString.length; i++) {
                            hits.push(baseHit + baseMultihit + '&mh=' + i + '-' + queriesString.length + '-' + guid + queriesString[i - 1]);
                        }
                    }
                    callback && callback(null, hits);
                });
            } else {
                callback && callback(err);
            }
        });
    };

    /**
     * Emit trigger and execute callback
     * @memberof BuildManager#
     * @function
     * @param trackerTrigger {string} Trigger to emit
     * @param hit {string} Hit to send
     * @param method {string} Sending method (GET|POST)
     * @param callback {function} Callback to execute
     * @param level {string} Trigger level
     * @param isMultiHit {boolean} Is the hit to be sent is part of a multihit
     * @param elementType {string} Element type (mailto, form, redirection)
     * @private
     */
    var _makeTriggerCallback = function (trackerTrigger, hit, method, callback, level, isMultiHit, elementType) {
        return (function () {
            return function (evt) {
                tag.emit(trackerTrigger, {
                    lvl: level,
                    details: {
                        hit: hit,
                        method: method,
                        event: evt,
                        isMultiHit: isMultiHit,
                        elementType: elementType
                    }
                });
                callback && callback();
            };
        })();
    };

    /**
     * Send at least one hit, more if it's too long (multihits).
     * @name send
     * @memberof BuildManager#
     * @function
     * @param customParams {object} Object which contains some hit parameters that you would like to send specifically (they are given priority over the current buffer)
     * @param filters {Array} List of buffer filters
     * @param callback {function} Callback to execute
     * @param requestMethod {string} Overloading the global method of sending hits (GET|POST)
     * @param elementType {string} Element type (mailto, form, redirection)
     * @public
     */
    self.send = function (customParams, filters, callback, requestMethod, elementType) {
        _build(customParams, filters, function (err, hits) {
            if (!err) {
                for (var i = 0; i < hits.length; i++) {
                    self.sendUrl(hits[i], callback, requestMethod, elementType);
                }
            } else {
                tag.emit('Tracker:Hit:Build:Error', {
                    lvl: 'ERROR',
                    msg: err.message,
                    details: {}
                });
                callback && callback();
            }
        });
    };

    /**
     * Send GET request with image.
     * @name _sendImage
     * @memberof BuildManager#
     * @function
     * @param hit {string} Url to send
     * @param callback {function} Callback to execute
     * @param isMultiHit {boolean} Is the hit to be sent is part of a multihit
     * @param elementType {string} Element type (mailto, form, redirection)
     * @private
     */
    var _sendImage = function (hit, callback, isMultiHit, elementType) {
        var img = new Image();
        img.onload = _makeTriggerCallback('Tracker:Hit:Sent:Ok', hit, 'GET', callback, 'INFO', isMultiHit, elementType);
        img.onerror = _makeTriggerCallback('Tracker:Hit:Sent:Error', hit, 'GET', callback, 'ERROR', isMultiHit, elementType);
        img.src = hit;
    };

    /**
     * Send POST request with beacon.
     * @name _sendBeacon
     * @memberof BuildManager#
     * @function
     * @param hit {string} Url to send
     * @param callback {function} Callback to execute
     * @param isMultiHit {boolean} Is the hit to be sent is part of a multihit
     * @private
     */
    var _sendBeacon = function (hit, callback, isMultiHit) {
        var trackertrigger = 'Tracker:Hit:Sent:Error';
        var method = 'POST';
        var level = 'ERROR';
        if (window.navigator.sendBeacon(hit, null)) {
            trackertrigger = 'Tracker:Hit:Sent:Ok';
            level = 'INFO';
        }
        _makeTriggerCallback(trackertrigger, hit, method, callback, level, isMultiHit, '')();
    };

    /**
     * Init.
     * @memberof BuildManager#
     * @function
     * @private
     */
    var _init = function () {
        MAXSIZE = Math.max((tag.getConfig('maxHitSize') || 0), MINSIZE);
        AVAILABLESIZE = Math.max((tag.getConfig('maxHitSize') || 0), MINSIZE);
        _requestMethod = tag.getConfig('requestMethod');
    };

    // Initialise global values.
    _init();

    /**
     * Send single hit from complete url.
     * <br />An event will be sent {@link ATInternet.Tracker.TriggersManager} :
     * <br />- "Tracker:Hit:Sent:Ok" with hit data if succeed
     * <br />- "Tracker:Hit:Sent:Error" with error data otherwise
     * @name sendUrl
     * @memberof BuildManager#
     * @function
     * @param hit {string} Url to send
     * @param callback {function} Callback to execute
     * @param requestMethod {string} Overloading the global method of sending hits (GET|POST)
     * @param elementType {string} Element type (mailto, form, redirection)
     * @public
     */
    self.sendUrl = function (hit, callback, requestMethod, elementType) {
        var isMultiHit = (hit.indexOf('&mh=') > -1);
        var configRequestMethod = requestMethod || _requestMethod;
        if (ATInternet.Utils.isOptedOut() && !tag.getConfig('sendHitWhenOptOut')) {
            _makeTriggerCallback('Tracker:Hit:Sent:NoTrack', hit, configRequestMethod, callback, 'INFO', isMultiHit, elementType)();
        } else {
            if (configRequestMethod === 'POST' && ATInternet.Utils.isBeaconMethodAvailable()) {
                _sendBeacon(hit, callback, isMultiHit);
            } else {
                _sendImage(hit, callback, isMultiHit, elementType);
            }
        }
    };

    // For unit tests on private elements !!!
    /* @if test */
    self._makeSubQueryObject = _makeSubQueryObject;
    self._makeOneHit = _makeOneHit;
    self._preQuery = _preQuery;
    self._buildParams = _buildParams;
    self._buildConfig = _buildConfig;
    self._build = _build;
    self._makeTriggerCallback = _makeTriggerCallback;
    self._sendImage = _sendImage;
    self._sendBeacon = _sendBeacon;
    self.setAvailableSize = function (size) {
        AVAILABLESIZE = size;
    };
    self._multiHitable = _multiHitable;
    self._requestMethod = _requestMethod;
    /* @endif */
};