Source: public/modules/core/services/trial-data.client.service.js

'use strict';

/**
 * The `TrialData` service is responsible for collecting the responses
 * provided by the user over the course of an experiment session.
 *
 * @class Angular.TrialData
 * @memberof Angular
 */

angular.module('core').factory('TrialData', [
    '$log',
    'lodash',
    function($log, lodash) {

        $log.info('Instantiating TrialData service.');

        /**
         * This function returns the object that will be populated with data
         * as responses are provided by the user. You are free to manipulate
         * this object to suit your needs, but take note of the properties
         * that are configured with the demo app that are described here. In
         * particular, take care when modifying the metadata and state
         * properties.
         *
         * @function BlankDataObject
         * @inner
         * @property {{}} answers This object is where the actual
         * responses from users are stored. For example, when you create a
         * question in your study specification document (see the
         * [README](index.html)) with its `questionStoragePath` property set
         * to `data.answers.foo`, it will appear in this object under
         * `answers.foo`.
         * @property {Date} date The date and time that this session commenced
         * @property {Object[]} media The media that were played during this
         * session
         * @property {*} timestamps Timestamps for different points during
         * the session
         * @property {{}} metadata Various important metadata (location,
         * language, terminal number, session number, etc.)
         * @property {{}} state Important data for maintaining the state of
         * the session
         * @memberof Angular.TrialData~
         * @returns {{}}
         */
        function BlankDataObject() {

            $log.debug('Creating new BlankDataObject.');

            return {
                answers: {},
                date: null,
                media: [],
                timestamps: {
                    start: null,
                    test: null,
                    media: [],
                    end: null
                },
                metadata: {
                    language: null,
                    session_number: null,
                    location: null,
                    terminal: null
                },
                state: {
                    currentSlideIndex: -1,
                    mediaPlayCount: 0
                }
            };
        }

        var trialData = {

            /**
             * The data object for this session. Originally set to a new
             * instance of a {@link
             * Angular.TrialData~BlankDataObject|BlankDataObject}.
             *
             * @name data
             * @memberof Angular.TrialData#
             */
            data: null,

            /**
             * Converts the object in {@link Angular.TrialData#data|data}
             * to JSON and returns it.
             *
             * @function toJson
             * @memberof Angular.TrialData#
             * @returns {string}
             */
            toJson: function toJsonFn() {
                $log.debug('TrialData service returning data as JSON.');
                return angular.toJson(this.data, true);
            },

            /**
             * Converts the object in {@link Angular.TrialData#data|data},
             * keeping only those properties specified in {@link
             * Angular.TrialData#exportedProperties|exportedProperties},
             * to JSON and returns it. If {@link
             * Angular.TrialData#exportedProperties|exportedProperties} is an
             * empty array, this method delegates to {@link
             * Angular.TrialData#toJson|toJson}.
             *
             * @function toJsonCompact
             * @memberof Angular.TrialData#
             * @returns {string}
             */
            toJsonCompact: function toJsonCompactFn() {
                $log.debug('TrialData service returning data as compact JSON.');

                return angular.toJson(this.toCompact(), true);
            },

            /**
             * Returns a copy of the object in {@link
             * Angular.TrialData#data|data}, keeping only those properties
             * specified in {@link
             * Angular.TrialData#exportedProperties|exportedProperties},
             * If {@link
             * Angular.TrialData#exportedProperties|exportedProperties} is an
             * empty array, this method returns {@link
             * Angular.TrialData#data|data}.
             *
             * @function toCompact
             * @memberof Angular.TrialData#
             * @returns {{}}
             */
            toCompact: function toCompactFn() {
                $log.debug('TrialData service returning compacted data.');

                if (this.exportedProperties.length === 0) {
                    return this.data;
                }

                var pruned = {};
                var that = this;

                this.exportedProperties.forEach(function(prop) {
                    lodash.set(pruned, prop, lodash.get(that.data, prop));
                });

                return pruned;
            },

            /**
             * The properties beneath {@link Angular.TrialData#data|data} that
             * {@link Angular.TrialData#toJsonCompact|toJsonCompact} should
             * include in the JSON it outputs. This should be an array of
             * strings, each of which represent an individual property that
             * should be exported. The strings should be dot-delimited paths.
             * Wildcards are not supported.
             *
             * @var exportedProperties
             * @type {string[]}
             * @memberof Angular.TrialData#
             */
            exportedProperties: [],

            /**
             * Sets {@link Angular.TrialData#data|data} to a new
             * instance of a {@link
             * Angular.TrialData~BlankDataObject|BlankDataObject}.
             *
             * @function reset
             * @memberof Angular.TrialData#
             * @return {undefined}
             */
            reset: function resetFn() {
                $log.info('Resetting TrialData service.');
                this.data = new BlankDataObject();
            },

            /**
             * Sets the value for a 'keypath' in {@link
             * Angular.TrialData#data|data}. Keypaths are dot-delimited
             * 'addresses' into the {@link Angular.TrialData#data|data}
             * object. For instance, the string `'foo.bar.baz'` interpreted
             * as a keypath points to the `baz` property of an object, which
             * is the value of the `bar` property on the `foo` object:
             *
             * ```
             * data = {
             *     foo: {
             *         bar: {
             *             baz: 13 // foo.bar.baz points to this value
             *         }
             *     }
             * }
             * ```
             *
             * @function setValueForPath
             * @memberof Angular.TrialData#
             * @param {string} path The keypath (address) into {@link
             * Angular.TrialData#data|data}
             * @param {*} value The value that this keypath should hold
             * @param {*} options Currently only one option is supported:
             * `array_index`. If you pass `{array_index: 2}` with a keypath
             * of `foo`, this method will expect to find an *`Array`* at the
             * keypath `foo`, and will set the value at index 2 of this
             * array to `value`.
             * @return {undefined}
             */
            setValueForPath: function setValueForPathFn(path, value, options) {

                $log.debug('Setting ' + path + ' in TrialData to: ' +
                    value, options);

                if (value === 'true') {
                    value = true;
                } else if (value === 'false') {
                    value = false;
                }

                var numericRegex = /\d+\.?\d?/;
                if (typeof value === 'string' && value.match(numericRegex)) {
                    value = parseFloat(value);
                }

                // Downcase simple strings
                if (typeof value === 'string') {
                    value = value.toLowerCase();
                }

                if (path && value !== undefined) {
                    // A moving reference to internal objects within this
                    // (this TrialData)
                    var schema = this;
                    var pList = path.split('.');
                    var len = pList.length;
                    for (var i = 0; i < len - 1; i++) {
                        var elem = pList[i];
                        if (!schema[elem]) schema[elem] = {};
                        schema = schema[elem];
                    }

                    if (options && options.hasOwnProperty('array_index') &&
                        typeof options.array_index === 'number') {

                        if (schema[pList[len - 1]] === undefined) {
                            schema[pList[len - 1]] = [];
                        }

                        // Iterate over array up to one less than
                        // options.array_index. If each index isn't set up to
                        // this point, set it to null.
                        for (var j = 0; j < options.array_index; j++) {
                            if (schema[pList[len - 1]][j] === undefined) {
                                schema[pList[len - 1]][j] = null;
                            }
                        }

                        schema[pList[len - 1]][options.array_index] = value;
                    } else {
                        schema[pList[len - 1]] = value;
                    }
                }
            },

            /**
             * Sets the value for a 'keypath' in {@link
             * Angular.TrialData#data|data}. (See {@link
             * Angular.TrialData~setValueForPath|setValueForPath} for more
             * infomation on 'paths'.)
             *
             * When this method is used, the current media in the experiment
             * session is observed. `value` is expected to be placed into an
             * `Array` that is stored at the keypath, and the number of the
             * current media is used to decide the index of this array at
             * which `value` should be stored. For instance, if the second
             * media excerpt was just played and this method was called with
             * `'data.answers.foo'` for `path` and `14` for `'value'`, `14`
             * would be stored in the second index of the array at
             * `'data.answers.foo'`.
             *
             * @function setValueForPathForCurrentmedia
             * @memberof Angular.TrialData#
             * @param {string} path The keypath (address) into {@link
                * Angular.TrialData#data|data}
             * @param {*} value The value that this keypath should hold
             * @return {undefined}
             */
            setValueForPathForCurrentMedia:
                function setValueForPathForCurrentMediaFn(path, value) {

                    $log.debug('Setting ' + path + ' in TrialData for current' +
                        ' media to: ' + value + '.');

                    var index;

                    // If no media have played (we're likely debugging)
                    if (this.data.state.mediaPlayCount <= 0) {
                        index = 0;
                    } else {
                        index = this.data.state.mediaPlayCount - 1;
                    }

                    this.setValueForPath(path, value, {array_index: index});
            },

            /**
             * Updates the language stored at the `metadata.language`
             * property of {@link Angular.TrialData#data|data}
             *
             * @function language
             * @memberof Angular.TrialData#
             * @param {string} newLanguage The language
             * @returns {string} The language to which the
             * `metadata.language` property of {@link
             * Angular.TrialData#data|data} is set after the call to this
             * method.
             */
            language: function languageFn(newLanguage) {

                if (typeof newLanguage === 'string') {

                    $log.info('Setting language in TrialData to ' +
                        newLanguage);

                    this.data.metadata.language = newLanguage;
                } else {
                    $log.error('Not updating language in TrialData. A string' +
                        ' was not received.');
                }

                return this.data.metadata.language;
            }
        };

        trialData.data = new BlankDataObject();

        return trialData;
    }
]);