<commit_before>'use strict';

var Exporter = require('../Exporter');
var util = require('../../util/util');
var through = require('through');
var csv = require('csv');
var _ = require('lodash');

/**
 * Create a CSV exporter.
 * @param form
 * @param res
 * @param req
 * @constructor
 */
var CSVExporter = function(form, req, res) {
  Exporter.call(this, form, req, res);
  this.extension = 'csv';
  this.contentType = 'text/csv';
  this.stringifier = csv.stringify({
    delimiter: ',',
    quoted: true
  });
  this.fields = [];

  var ignore = ['password', 'button'];
  util.eachComponent(form.components, function(component, path) {
    if (component.input && component.key && ignore.indexOf(component.type) === -1) {
      var items = [];

      // If a component has multiple parts, pick what we want.
      if (component.type === 'address') {
        items.push({
          path: 'formatted_address',
          rename: 'formatted'
        });
        items.push({
          path: 'geometry.location.lat',
          rename: 'lat'
        });
        items.push({
          path: 'geometry.location.lng',
          rename: 'lng'
        });
      }
      else if (component.type === 'selectboxes') {
        _.each(component.values, function(option) {
          items.push({path: option.label, type: 'boolean'});
        });
      }
      else if (component.type === 'checkbox') {
        items.push({type: 'boolean'});
      }
      else {
        // Default to the current component item.
        items.push({});
      }

      items.forEach(function(item) {
        var finalItem = {
          path: (_.filter([path, item.path])).join('.')
        };

        if (item.hasOwnProperty('rename')) {
          finalItem.rename = (_.filter([path, item.rename])).join('.')
        }

        if (item.hasOwnProperty('type')) {
          finalItem.type = item.type;
        }

        this.fields.push(finalItem);
      }.bind(this))
    }
  }.bind(this));
};

CSVExporter.prototype = Object.create(Exporter.prototype);
CSVExporter.prototype.constructor = CSVExporter;

/**
 * Start the CSV export by creating the headers.
 *
 * @param deferred
 */
CSVExporter.prototype.start = function(deferred) {
  var row = null;
  this.stringifier.on('readable', function() {
    /* eslint-disable no-cond-assign */
    while (row = this.stringifier.read()) {
      this.res.write(row.toString());
    }
    /* eslint-enable no-cond-assign */

    deferred.resolve();
  }.bind(this));

  var labels = ['ID', 'Created', 'Modified'];
  this.fields.forEach(function(item) {
    if (item.hasOwnProperty('rename')) {
      labels.push(item.rename);
      return;
    }

    labels.push(item.path);
  });

  this.stringifier.write(labels);
};

/**
 * Stream the CSV export.
 *
 * @param stream
 * @returns {*}
 */
CSVExporter.prototype.stream = function(stream) {
  var self = this;
  var write = function(row) {
    var data = [
      row._id.toString(),
      row.created.toISOString(),
      row.modified.toISOString()
    ];

    self.fields.forEach(function(item) {
      var temp = _.get(row.data, item.path, '');
      if (temp instanceof Array && temp.length === 0) {
        temp = '';
      }
      else if (item.type === 'boolean') {
        temp = Boolean(temp).toString();
      }

      data.push(temp);
    });

    this.queue(data);
  };

  return stream
    .pipe(through(write, function() {
      return self.res.end();
    }))
    .pipe(this.stringifier);
};

module.exports = CSVExporter;
<commit_msg>Adding container components to the ignore list. Expanding survey components in the csv export.<commit_after>'use strict';

var Exporter = require('../Exporter');
var util = require('../../util/util');
var through = require('through');
var csv = require('csv');
var _ = require('lodash');

/**
 * Create a CSV exporter.
 * @param form
 * @param res
 * @param req
 * @constructor
 */
var CSVExporter = function(form, req, res) {
  Exporter.call(this, form, req, res);
  this.extension = 'csv';
  this.contentType = 'text/csv';
  this.stringifier = csv.stringify({
    delimiter: ',',
    quoted: true
  });
  this.fields = [];

  var ignore = ['password', 'button', 'container'];
  util.eachComponent(form.components, function(component, path) {
    if (component.input && component.key && ignore.indexOf(component.type) === -1) {
      var items = [];

      // If a component has multiple parts, pick what we want.
      if (component.type === 'address') {
        items.push({
          path: 'formatted_address',
          rename: 'formatted'
        });
        items.push({
          path: 'geometry.location.lat',
          rename: 'lat'
        });
        items.push({
          path: 'geometry.location.lng',
          rename: 'lng'
        });
      }
      else if (component.type === 'selectboxes') {
        _.each(component.values, function(option) {
          items.push({path: option.label, type: 'boolean'});
        });
      }
      else if (component.type === 'checkbox') {
        items.push({type: 'boolean'});
      }
      else if (component.type === 'survey') {
        _.each(component.questions, function(question) {
          items.push({path: question.value});
        });
      }
      else {
        // Default to the current component item.
        items.push({});
      }

      items.forEach(function(item) {
        var finalItem = {
          path: (_.filter([path, item.path])).join('.')
        };

        if (item.hasOwnProperty('rename')) {
          finalItem.rename = (_.filter([path, item.rename])).join('.')
        }

        if (item.hasOwnProperty('type')) {
          finalItem.type = item.type;
        }

        this.fields.push(finalItem);
      }.bind(this))
    }
  }.bind(this));
};

CSVExporter.prototype = Object.create(Exporter.prototype);
CSVExporter.prototype.constructor = CSVExporter;

/**
 * Start the CSV export by creating the headers.
 *
 * @param deferred
 */
CSVExporter.prototype.start = function(deferred) {
  var row = null;
  this.stringifier.on('readable', function() {
    /* eslint-disable no-cond-assign */
    while (row = this.stringifier.read()) {
      this.res.write(row.toString());
    }
    /* eslint-enable no-cond-assign */

    deferred.resolve();
  }.bind(this));

  var labels = ['ID', 'Created', 'Modified'];
  this.fields.forEach(function(item) {
    if (item.hasOwnProperty('rename')) {
      labels.push(item.rename);
      return;
    }

    labels.push(item.path);
  });

  this.stringifier.write(labels);
};

/**
 * Stream the CSV export.
 *
 * @param stream
 * @returns {*}
 */
CSVExporter.prototype.stream = function(stream) {
  var self = this;
  var write = function(row) {
    var data = [
      row._id.toString(),
      row.created.toISOString(),
      row.modified.toISOString()
    ];

    self.fields.forEach(function(item) {
      var temp = _.get(row.data, item.path, '');
      if (temp instanceof Array && temp.length === 0) {
        temp = '';
      }
      else if (item.type === 'boolean') {
        temp = Boolean(temp).toString();
      }

      data.push(temp);
    });

    this.queue(data);
  };

  return stream
    .pipe(through(write, function() {
      return self.res.end();
    }))
    .pipe(this.stringifier);
};

module.exports = CSVExporter;
<|endoftext|>


<commit_before>(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        define(['backbone', 'underscore', 'exports'], function (Backbone, _, exports) {
            return factory(root, exports, Backbone, _);
        });
    } else if (typeof exports !== 'undefined') {
        var Backbone = require('backbone');
        var _ = require('underscore');
        module.exports = factory(root, exports, Backbone, _);
    } else {
        root.BB = factory(root, {}, root.Backbone, root._);
    }
}(this, function (root, BB, Backbone, _) {
    /**
     * @author Enzo Martin <enzo.r.martin@gmail.com>
     * @module backbone/backbone-helper
     * @desc Backbone Helper functions to manage memory/instances smartly by keeping track of instanciated views/models/collections and making sure you don't have multiple instances of anything in the background by accident
     */

    BB = {
        view_definitions : {},
        model_definitions : {},
        collection_definitions : {},
        view_instances : {},
        model_instances : {},
        collection_instances : {}
    };

    // Private functions
    /**
     * Generic method for returning an object
     *
     * @param  {string} item model|collection
     * @param  {object} data
     * @return {instance}
     * @private
     */
    function get_item(item,data){
        var instances = this[item + '_instances'];
        var name = data.name;
        var defined = typeof instances[name] !== 'undefined';
        var instance;

        if(data.reset && defined){
            if(item == "model"){
                instances[name].destroy();
            } else if (item == "collection"){
                instances[name].reset();
            }
            delete instances[name];
            instance = create_item.call(this,item,data);
        } else {
            instance = defined ? instances[name] : create_item.call(this,item,data);
        }
        return instance;
    }

    /**
     * Generic method for creating an object
     *
     * @param  {string} item model|collection
     * @param  {object} data
     * @return {instance}
     * @private
     */
    function create_item(item,data){
        var definitions = this[item + '_definitions'];
        var name = data.name;
        var defined = typeof definitions[name] !== 'undefined';
        var instance;
        if(defined){
            instance = this[item + '_instances'][name] = new definitions[name](data.data || null);
            if(data.options){
                for(var option in data.options){
                    var value = data.options[option];
                    instance[option] = value;
                }
            }
            instance.name = name;
            if(item == 'model'){
                this[item + '_instances'][name].listenTo(this[item + '_instances'][name],'destroy',_.bind(function(){
                    delete this[item + '_instances'][name];
                },this));
            }
        } else {
            console.trace();
            throw new Error(item + ' definition not found: ' + name +'. Did you add it to the BB.' + item + '_definitions object?');
        }
        return instance;
    }

    /**
     * Gets the specified model
     * @param model_data {string|*}
     * @returns {*}
     */
    BB.get_model = function(model_data){
        if(typeof model_data.name === 'undefined'){
            model_data = {name:model_data};
        }
        return get_item.call(this,'model',model_data);
    };

    /**
     * Gets the specified collection
     * @param collection_data {string|*}
     * @returns {*}
     */
    BB.get_collection = function(collection_data){
        if (typeof collection_data.name === 'undefined') {
            collection_data = {name: collection_data};
        }
        return get_item.call(this,'collection',collection_data);
    };

    /**
     * Get the specified view
     * @param view_data {string|*} The view's options to set
     * @param model_data {*} The model to bind to the view's model property
     * @param collection_data The collection to bind to the view's collection property
     * @returns {*}
     */
    BB.get_view = function(view_data,model_data,collection_data){
        if (typeof view_data.name === 'undefined') {
            view_data = {name: view_data};
        }
        var views = this.view_instances;
        var name = view_data.name;
        if(view_data.reset && views[name]){
            views[name].remove();
        }

        return views[name] ? views[name] : this.create_view(view_data,model_data,collection_data);
    };

    /**
     * Creates a view and sets it's options, including fetching and binding it's model/collection, also sets up removal of the view instance when removed
     * @param view_data {*} The view's options to set
     * @param model_data {*} The model to get and it's data
     * @param collection_data {*} The collection to get and it's data
     * @returns {*} The view with it's model and/or collection bound
     */
    BB.create_view = function(view_data,model_data,collection_data){
        var name = view_data.name;
        var view = this.view_definitions[name];
        var model = model_data && !(model_data instanceof Backbone.Model) ? this.get_model(model_data) : model_data;
        var collection = collection_data && !(collection_data instanceof Backbone.Collection) ? this.get_collection(collection_data) : collection_data;
        view = view ? this.view_instances[name] = new view({model: model,collection: collection}) : '';

        if(view_data.options){
            for(var option in view_data.options){
                var value = view_data.options[option];
                view[option] = value;
            }
        }

        this.view_instances[name].on('remove',function(){
            delete this.view_instances[name]
        },this);
        view.name = name;
        return view;
    };

    /**
     * @desc The main function of the helper library, it takes an object with view/collection/model as a key, and an object inside with the name of the type you want, and will return your requested item
     * @param object
     * @returns {{}}
     *
     * @example
     * {
     *	view: {
     *		name: 'view-name',
     *		reset: true, // Delete existing and create new instance
     *		options: {} // Set any number of view parameters like initialize, el, tagName, etc
     *	},
     *	model: {
     *		name: 'model-name',
     *		reset: true, // Delete existing and create new instance
     *		data: {}, // Set model initial data
     *		options: {} // Set any number of model parameters like attributeId, initialize, etc
     *	},
     *	collection: {
     *		name: 'collection-name',
     *		reset: true // Delete existing and create new instance
     *	}
     *}
     */
    BB.get = function(object){
        var item;
        if(object.view){
            item = this.get_view(object.view,object.model,object.collection);
        } else if(object.model){
            item = this.get_model(object.model);
        } else if(object.collection){
            item = this.get_collection(object.collection);
        }
        return item;
    };

    // Upgrading Backbone to fire a remove event when removing a view
    var _super = Backbone.View.prototype.remove;
    Backbone.View.prototype.remove = function(){
        this.trigger('remove');
        return _super.apply(this,arguments);
    };

    return BB;
}));
<commit_msg>Fix destroy for model as well<commit_after>(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        define(['backbone', 'underscore', 'exports'], function (Backbone, _, exports) {
            return factory(root, exports, Backbone, _);
        });
    } else if (typeof exports !== 'undefined') {
        var Backbone = require('backbone');
        var _ = require('underscore');
        module.exports = factory(root, exports, Backbone, _);
    } else {
        root.BB = factory(root, {}, root.Backbone, root._);
    }
}(this, function (root, BB, Backbone, _) {
    /**
     * @author Enzo Martin <enzo.r.martin@gmail.com>
     * @module backbone/backbone-helper
     * @desc Backbone Helper functions to manage memory/instances smartly by keeping track of instanciated views/models/collections and making sure you don't have multiple instances of anything in the background by accident
     */

    BB = {
        view_definitions : {},
        model_definitions : {},
        collection_definitions : {},
        view_instances : {},
        model_instances : {},
        collection_instances : {}
    };

    // Private functions
    /**
     * Generic method for returning an object
     *
     * @param  {string} item model|collection
     * @param  {object} data
     * @return {instance}
     * @private
     */
    function get_item(item,data){
        var instances = this[item + '_instances'];
        var name = data.name;
        var defined = typeof instances[name] !== 'undefined';
        var instance;

        if(data.reset && defined){
            if(item == "model"){
                instances[name].destroy();
            } else if (item == "collection"){
                instances[name].reset();
            }
            delete instances[name];
            instance = create_item.call(this,item,data);
        } else {
            instance = defined ? instances[name] : create_item.call(this,item,data);
        }
        return instance;
    }

    /**
     * Generic method for creating an object
     *
     * @param  {string} item model|collection
     * @param  {object} data
     * @return {instance}
     * @private
     */
    function create_item(item,data){
        var definitions = this[item + '_definitions'];
        var name = data.name;
        var defined = typeof definitions[name] !== 'undefined';
        var instance;
        if(defined){
            instance = this[item + '_instances'][name] = new definitions[name](data.data || null);
            if(data.options){
                for(var option in data.options){
                    var value = data.options[option];
                    instance[option] = value;
                }
            }
            instance.name = name;
            if(item == 'model'){
                this[item + '_instances'][name].on('destroy',function(){
                    delete this[item + '_instances'][name];
                },this);
            }
        } else {
            console.trace();
            throw new Error(item + ' definition not found: ' + name +'. Did you add it to the BB.' + item + '_definitions object?');
        }
        return instance;
    }

    /**
     * Gets the specified model
     * @param model_data {string|*}
     * @returns {*}
     */
    BB.get_model = function(model_data){
        if(typeof model_data.name === 'undefined'){
            model_data = {name:model_data};
        }
        return get_item.call(this,'model',model_data);
    };

    /**
     * Gets the specified collection
     * @param collection_data {string|*}
     * @returns {*}
     */
    BB.get_collection = function(collection_data){
        if (typeof collection_data.name === 'undefined') {
            collection_data = {name: collection_data};
        }
        return get_item.call(this,'collection',collection_data);
    };

    /**
     * Get the specified view
     * @param view_data {string|*} The view's options to set
     * @param model_data {*} The model to bind to the view's model property
     * @param collection_data The collection to bind to the view's collection property
     * @returns {*}
     */
    BB.get_view = function(view_data,model_data,collection_data){
        if (typeof view_data.name === 'undefined') {
            view_data = {name: view_data};
        }
        var views = this.view_instances;
        var name = view_data.name;
        if(view_data.reset && views[name]){
            views[name].remove();
        }

        return views[name] ? views[name] : this.create_view(view_data,model_data,collection_data);
    };

    /**
     * Creates a view and sets it's options, including fetching and binding it's model/collection, also sets up removal of the view instance when removed
     * @param view_data {*} The view's options to set
     * @param model_data {*} The model to get and it's data
     * @param collection_data {*} The collection to get and it's data
     * @returns {*} The view with it's model and/or collection bound
     */
    BB.create_view = function(view_data,model_data,collection_data){
        var name = view_data.name;
        var view = this.view_definitions[name];
        var model = model_data && !(model_data instanceof Backbone.Model) ? this.get_model(model_data) : model_data;
        var collection = collection_data && !(collection_data instanceof Backbone.Collection) ? this.get_collection(collection_data) : collection_data;
        view = view ? this.view_instances[name] = new view({model: model,collection: collection}) : '';

        if(view_data.options){
            for(var option in view_data.options){
                var value = view_data.options[option];
                view[option] = value;
            }
        }

        this.view_instances[name].on('remove',function(){
            delete this.view_instances[name]
        },this);
        view.name = name;
        return view;
    };

    /**
     * @desc The main function of the helper library, it takes an object with view/collection/model as a key, and an object inside with the name of the type you want, and will return your requested item
     * @param object
     * @returns {{}}
     *
     * @example
     * {
     *	view: {
     *		name: 'view-name',
     *		reset: true, // Delete existing and create new instance
     *		options: {} // Set any number of view parameters like initialize, el, tagName, etc
     *	},
     *	model: {
     *		name: 'model-name',
     *		reset: true, // Delete existing and create new instance
     *		data: {}, // Set model initial data
     *		options: {} // Set any number of model parameters like attributeId, initialize, etc
     *	},
     *	collection: {
     *		name: 'collection-name',
     *		reset: true // Delete existing and create new instance
     *	}
     *}
     */
    BB.get = function(object){
        var item;
        if(object.view){
            item = this.get_view(object.view,object.model,object.collection);
        } else if(object.model){
            item = this.get_model(object.model);
        } else if(object.collection){
            item = this.get_collection(object.collection);
        }
        return item;
    };

    // Upgrading Backbone to fire a remove event when removing a view
    var _super = Backbone.View.prototype.remove;
    Backbone.View.prototype.remove = function(){
        this.trigger('remove');
        return _super.apply(this,arguments);
    };

    return BB;
}));
<|endoftext|>


<commit_before>/*!
 * VisualEditor UserInterface SelectWidget class.
 *
 * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Create an ve.ui.SelectWidget object.
 *
 * @class
 * @abstract
 * @extends ve.ui.Widget
 * @mixin ve.ui.GroupElement
 *
 * @constructor
 * @param {Object} [config] Config options
 */
ve.ui.SelectWidget = function VeUiSelectWidget( config ) {
	// Config intialization
	config = config || {};

	// Parent constructor
	ve.ui.Widget.call( this, config );

	// Mixin constructors
	ve.ui.GroupElement.call( this, this.$, config );

	// Properties
	this.dragging = false;
	this.selecting = null;
	this.hashes = {};

	// Events
	this.$.on( {
		'mousedown': ve.bind( this.onMouseDown, this ),
		'mouseup': ve.bind( this.onMouseUp, this ),
		'mousemove': ve.bind( this.onMouseMove, this ),
		'mouseover': ve.bind( this.onMouseOver, this )
	} );

	// Initialization
	this.$.addClass( 've-ui-selectWidget' );
};

/* Inheritance */

ve.inheritClass( ve.ui.SelectWidget, ve.ui.Widget );

ve.mixinClass( ve.ui.SelectWidget, ve.ui.GroupElement );

/* Events */

/**
 * @event select
 * @param {ve.ui.OptionWidget|null} item Selected item or null if no item is selected
 */

/* Static Properties */

ve.ui.SelectWidget.static.tagName = 'ul';

/* Methods */

/**
 * Handle mouse down events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse down event
 */
ve.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
	var item;

	if ( !this.disabled && e.which === 1 ) {
		item = this.getTargetItem( e );
		if ( item && item.isSelectable() ) {
			this.selectItem( item, true );
			this.selecting = item;
			$( this.$$.context ).one( 'mouseup', ve.bind( this.onMouseUp, this ) );
		}
	}
	return false;
};

/**
 * Handle mouse up events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse down event
 */
ve.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
	if ( !this.disabled && e.which === 1 && this.selecting ) {
		this.selectItem( this.selecting );
		this.selecting = null;
	}
	return false;
};

/**
 * Handle mouse up events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse down event
 */
ve.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
	var item;

	if ( !this.disabled && e.which === 1 ) {
		item = this.getTargetItem( e );
		if ( item && item !== this.selecting && item.isSelectable() ) {
			this.selectItem( item, true );
			this.selecting = item;
		}
	}
	return false;
};

/**
 * Handle mouse over events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse over event
 */
ve.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
	var item;

	if ( !this.disabled ) {
		item = this.getTargetItem( e );
		if ( item && item.isHighlightable() ) {
			this.highlightItem( item );
		}
	}
	return false;
};

/**
 * Get the closest item to a jQuery.Event.
 *
 * @method
 * @private
 * @param {jQuery.Event} e
 * @returns {ve.ui.OptionWidget|null} Outline item widget, `null` if none was found
 */
ve.ui.SelectWidget.prototype.getTargetItem = function ( e ) {
	var $item = $( e.target ).closest( '.ve-ui-optionWidget' );
	if ( $item.length ) {
		return $item.data( 've-ui-optionWidget' );
	}
	return null;
};

/**
 * Get selected item.
 *
 * @method
 * @returns {ve.ui.OptionWidget|null} Selected item, `null` if no item is selected
 */
ve.ui.SelectWidget.prototype.getSelectedItem = function () {
	var i, len;

	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i].isSelected() ) {
			return this.items[i];
		}
	}
	return null;
};

/**
 * Get highlighted item.
 *
 * @method
 * @returns {ve.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
 */
ve.ui.SelectWidget.prototype.getHighlightedItem = function () {
	var i, len;

	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i].isHighlighted() ) {
			return this.items[i];
		}
	}
	return null;
};

/**
 * Get an existing item with equivilant data.
 *
 * @method
 * @param {Object} data Item data to search for
 * @returns {ve.ui.OptionWidget|null} Item with equivilent value, `null` if none exists
 */
ve.ui.SelectWidget.prototype.getItemFromData = function ( data ) {
	var hash = ve.getHash( data );

	if ( hash in this.hashes ) {
		return this.hashes[hash];
	}

	return null;
};

/**
 * Highlight an item.
 *
 * Highlighting is mutually exclusive.
 *
 * @method
 * @param {ve.ui.OptionWidget} [item] Item to highlight, omit to deselect all
 * @param {boolean} [silent=false] Update UI only, do not emit `highlight` event
 * @chainable
 */
ve.ui.SelectWidget.prototype.highlightItem = function ( item, silent ) {
	var i, len;

	item = this.getItemFromData( item && item.getData() );
	if ( item ) {
		item.setHighlighted( true );
	}
	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i] !== item ) {
			this.items[i].setHighlighted( false );
		}
	}
	if ( !silent ) {
		this.emit( 'highlight', item );
	}

	return this;
};

/**
 * Select an item.
 *
 * @method
 * @param {ve.ui.OptionWidget} [item] Item to select, omit to deselect all
 * @param {boolean} [silent=false] Update UI only, do not emit `select` event
 * @chainable
 */
ve.ui.SelectWidget.prototype.selectItem = function ( item, silent ) {
	var i, len;

	item = this.getItemFromData( item && item.getData() );
	if ( item ) {
		item.setSelected( true );
	}
	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i] !== item ) {
			this.items[i].setSelected( false );
		}
	}
	if ( !silent ) {
		this.emit( 'select', item );
	}

	return this;
};

/**
 * Get an item relative to another one.
 *
 * @method
 * @param {ve.ui.OptionWidget} item Item to start at
 * @param {number} direction Direction to move in
 * @returns {ve.ui.OptionWidget|null} Item at position, `null` if there are no items in the menu
 */
ve.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction ) {
	var index = this.items.indexOf( item ),
		i = direction > 0 ?
			// Default to 0 instead of -1, if nothing is selected let's start at the beginning
			Math.max( 0, index + direction ) :
			// Default to n-1 instead of -1, if nothing is selected let's start at the end
			Math.min( index + direction, this.items.length - 1 ),
		len = this.items.length,
		inc = direction > 0 ? 1 : -1,
		stopAt = i;
	// Iterate to the next item in the sequence
	while ( i <= len ) {
		item = this.items[i];
		if ( item instanceof ve.ui.OptionWidget && item.isSelectable() ) {
			return item;
		}
		// Wrap around
		i = ( i + inc + len ) % len;
		if ( i === stopAt ) {
			// We've looped around, I guess we're all alone
			return item;
		}
	}
	return null;
};

/**
 * Selects the next item in the menu.
 *
 * @method
 * @param {number} index Item index
 * @returns {ve.ui.OptionWidget|null} Item, `null` if there's not an item at the `index`
 */
ve.ui.SelectWidget.prototype.getClosestSelectableItem = function ( index ) {
	var item,
		i = 0,
		len = this.items.length,
		at = 0;
	while ( i < len ) {
		item = this.items[i];
		if ( item instanceof ve.ui.OptionWidget && item.isSelectable() ) {
			if ( at === index ) {
				return item;
			}
			at++;
		}
		i++;
	}
	return null;
};

/**
 * Add items.
 *
 * Adding an existing item (by value) will move it.
 *
 * @method
 * @param {ve.ui.OptionWidget[]} items Items to add
 * @chainable
 */
ve.ui.SelectWidget.prototype.addItems = function ( items ) {
	var i, len, item, hash;

	for ( i = 0, len = items.length; i < len; i++ ) {
		item = items[i];
		hash = ve.getHash( item.getData() );
		if ( hash in this.hashes ) {
			// Use existing item with the same value
			items[i] = this.hashes[hash];
		} else {
			// Add new item
			this.hashes[hash] = item;
		}
	}
	ve.ui.GroupElement.prototype.addItems.call( this, items );

	return this;
};

/**
 * Remove items.
 *
 * Items will be detached, not removed, so they can be used later.
 *
 * @method
 * @param {ve.ui.OptionWidget[]} items Items to remove
 * @chainable
 */
ve.ui.SelectWidget.prototype.removeItems = function ( items ) {
	var i, len, item, hash;

	for ( i = 0, len = items.length; i < len; i++ ) {
		item = items[i];
		hash = ve.getHash( item.getData() );
		if ( hash in this.hashes ) {
			// Remove existing item
			delete this.hashes[hash];
		}
	}
	ve.ui.GroupElement.prototype.removeItems.call( this, items );

	return this;
};

/**
 * Clear all items.
 *
 * Items will be detached, not removed, so they can be used later.
 *
 * @method
 * @chainable
 */
ve.ui.SelectWidget.prototype.clearItems = function () {
	// Clear all items
	this.hashes = {};
	ve.ui.GroupElement.prototype.clearItems.call( this );

	return this;
};
<commit_msg>ve.ui.SelectWidget: Keep track of mouse down<commit_after>/*!
 * VisualEditor UserInterface SelectWidget class.
 *
 * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Create an ve.ui.SelectWidget object.
 *
 * @class
 * @abstract
 * @extends ve.ui.Widget
 * @mixin ve.ui.GroupElement
 *
 * @constructor
 * @param {Object} [config] Config options
 */
ve.ui.SelectWidget = function VeUiSelectWidget( config ) {
	// Config intialization
	config = config || {};

	// Parent constructor
	ve.ui.Widget.call( this, config );

	// Mixin constructors
	ve.ui.GroupElement.call( this, this.$, config );

	// Properties
	this.pressed = false;
	this.selecting = null;
	this.hashes = {};

	// Events
	this.$.on( {
		'mousedown': ve.bind( this.onMouseDown, this ),
		'mouseup': ve.bind( this.onMouseUp, this ),
		'mousemove': ve.bind( this.onMouseMove, this ),
		'mouseover': ve.bind( this.onMouseOver, this )
	} );

	// Initialization
	this.$.addClass( 've-ui-selectWidget' );
};

/* Inheritance */

ve.inheritClass( ve.ui.SelectWidget, ve.ui.Widget );

ve.mixinClass( ve.ui.SelectWidget, ve.ui.GroupElement );

/* Events */

/**
 * @event select
 * @param {ve.ui.OptionWidget|null} item Selected item or null if no item is selected
 */

/* Static Properties */

ve.ui.SelectWidget.static.tagName = 'ul';

/* Methods */

/**
 * Handle mouse down events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse down event
 */
ve.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
	var item;

	if ( !this.disabled && e.which === 1 ) {
		this.pressed = true;
		item = this.getTargetItem( e );
		if ( item && item.isSelectable() ) {
			this.selectItem( item, true );
			this.selecting = item;
			$( this.$$.context ).one( 'mouseup', ve.bind( this.onMouseUp, this ) );
		}
	}
	return false;
};

/**
 * Handle mouse up events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse down event
 */
ve.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
	this.pressed = false;
	if ( !this.disabled && e.which === 1 && this.selecting ) {
		this.selectItem( this.selecting );
		this.selecting = null;
	}
	return false;
};

/**
 * Handle mouse up events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse down event
 */
ve.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
	var item;

	if ( !this.disabled && this.pressed ) {
		item = this.getTargetItem( e );
		if ( item && item !== this.selecting && item.isSelectable() ) {
			this.selectItem( item, true );
			this.selecting = item;
		}
	}
	return false;
};

/**
 * Handle mouse over events.
 *
 * @method
 * @private
 * @param {jQuery.Event} e Mouse over event
 */
ve.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
	var item;

	if ( !this.disabled ) {
		item = this.getTargetItem( e );
		if ( item && item.isHighlightable() ) {
			this.highlightItem( item );
		}
	}
	return false;
};

/**
 * Get the closest item to a jQuery.Event.
 *
 * @method
 * @private
 * @param {jQuery.Event} e
 * @returns {ve.ui.OptionWidget|null} Outline item widget, `null` if none was found
 */
ve.ui.SelectWidget.prototype.getTargetItem = function ( e ) {
	var $item = $( e.target ).closest( '.ve-ui-optionWidget' );
	if ( $item.length ) {
		return $item.data( 've-ui-optionWidget' );
	}
	return null;
};

/**
 * Get selected item.
 *
 * @method
 * @returns {ve.ui.OptionWidget|null} Selected item, `null` if no item is selected
 */
ve.ui.SelectWidget.prototype.getSelectedItem = function () {
	var i, len;

	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i].isSelected() ) {
			return this.items[i];
		}
	}
	return null;
};

/**
 * Get highlighted item.
 *
 * @method
 * @returns {ve.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
 */
ve.ui.SelectWidget.prototype.getHighlightedItem = function () {
	var i, len;

	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i].isHighlighted() ) {
			return this.items[i];
		}
	}
	return null;
};

/**
 * Get an existing item with equivilant data.
 *
 * @method
 * @param {Object} data Item data to search for
 * @returns {ve.ui.OptionWidget|null} Item with equivilent value, `null` if none exists
 */
ve.ui.SelectWidget.prototype.getItemFromData = function ( data ) {
	var hash = ve.getHash( data );

	if ( hash in this.hashes ) {
		return this.hashes[hash];
	}

	return null;
};

/**
 * Highlight an item.
 *
 * Highlighting is mutually exclusive.
 *
 * @method
 * @param {ve.ui.OptionWidget} [item] Item to highlight, omit to deselect all
 * @param {boolean} [silent=false] Update UI only, do not emit `highlight` event
 * @chainable
 */
ve.ui.SelectWidget.prototype.highlightItem = function ( item, silent ) {
	var i, len;

	item = this.getItemFromData( item && item.getData() );
	if ( item ) {
		item.setHighlighted( true );
	}
	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i] !== item ) {
			this.items[i].setHighlighted( false );
		}
	}
	if ( !silent ) {
		this.emit( 'highlight', item );
	}

	return this;
};

/**
 * Select an item.
 *
 * @method
 * @param {ve.ui.OptionWidget} [item] Item to select, omit to deselect all
 * @param {boolean} [silent=false] Update UI only, do not emit `select` event
 * @chainable
 */
ve.ui.SelectWidget.prototype.selectItem = function ( item, silent ) {
	var i, len;

	item = this.getItemFromData( item && item.getData() );
	if ( item ) {
		item.setSelected( true );
	}
	for ( i = 0, len = this.items.length; i < len; i++ ) {
		if ( this.items[i] !== item ) {
			this.items[i].setSelected( false );
		}
	}
	if ( !silent ) {
		this.emit( 'select', item );
	}

	return this;
};

/**
 * Get an item relative to another one.
 *
 * @method
 * @param {ve.ui.OptionWidget} item Item to start at
 * @param {number} direction Direction to move in
 * @returns {ve.ui.OptionWidget|null} Item at position, `null` if there are no items in the menu
 */
ve.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction ) {
	var index = this.items.indexOf( item ),
		i = direction > 0 ?
			// Default to 0 instead of -1, if nothing is selected let's start at the beginning
			Math.max( 0, index + direction ) :
			// Default to n-1 instead of -1, if nothing is selected let's start at the end
			Math.min( index + direction, this.items.length - 1 ),
		len = this.items.length,
		inc = direction > 0 ? 1 : -1,
		stopAt = i;
	// Iterate to the next item in the sequence
	while ( i <= len ) {
		item = this.items[i];
		if ( item instanceof ve.ui.OptionWidget && item.isSelectable() ) {
			return item;
		}
		// Wrap around
		i = ( i + inc + len ) % len;
		if ( i === stopAt ) {
			// We've looped around, I guess we're all alone
			return item;
		}
	}
	return null;
};

/**
 * Selects the next item in the menu.
 *
 * @method
 * @param {number} index Item index
 * @returns {ve.ui.OptionWidget|null} Item, `null` if there's not an item at the `index`
 */
ve.ui.SelectWidget.prototype.getClosestSelectableItem = function ( index ) {
	var item,
		i = 0,
		len = this.items.length,
		at = 0;
	while ( i < len ) {
		item = this.items[i];
		if ( item instanceof ve.ui.OptionWidget && item.isSelectable() ) {
			if ( at === index ) {
				return item;
			}
			at++;
		}
		i++;
	}
	return null;
};

/**
 * Add items.
 *
 * Adding an existing item (by value) will move it.
 *
 * @method
 * @param {ve.ui.OptionWidget[]} items Items to add
 * @chainable
 */
ve.ui.SelectWidget.prototype.addItems = function ( items ) {
	var i, len, item, hash;

	for ( i = 0, len = items.length; i < len; i++ ) {
		item = items[i];
		hash = ve.getHash( item.getData() );
		if ( hash in this.hashes ) {
			// Use existing item with the same value
			items[i] = this.hashes[hash];
		} else {
			// Add new item
			this.hashes[hash] = item;
		}
	}
	ve.ui.GroupElement.prototype.addItems.call( this, items );

	return this;
};

/**
 * Remove items.
 *
 * Items will be detached, not removed, so they can be used later.
 *
 * @method
 * @param {ve.ui.OptionWidget[]} items Items to remove
 * @chainable
 */
ve.ui.SelectWidget.prototype.removeItems = function ( items ) {
	var i, len, item, hash;

	for ( i = 0, len = items.length; i < len; i++ ) {
		item = items[i];
		hash = ve.getHash( item.getData() );
		if ( hash in this.hashes ) {
			// Remove existing item
			delete this.hashes[hash];
		}
	}
	ve.ui.GroupElement.prototype.removeItems.call( this, items );

	return this;
};

/**
 * Clear all items.
 *
 * Items will be detached, not removed, so they can be used later.
 *
 * @method
 * @chainable
 */
ve.ui.SelectWidget.prototype.clearItems = function () {
	// Clear all items
	this.hashes = {};
	ve.ui.GroupElement.prototype.clearItems.call( this );

	return this;
};
<|endoftext|>


<commit_before>$jit.Icicle = new Class({
  Implements: [ Loader, Extras, Layouts.Icicle ],

  layout: {
    orientation: "h",
    vertical: function(){
      return this.orientation == "v";
    },
    horizontal: function(){
      return this.orientation == "h";
    },
    change: function(){
      this.orientation = this.vertical()? "h" : "v";
    }
  },

  initialize: function(controller) {
    var config = {
      animate: false,
      orientation: "h",
      titleHeight: 13,
      offset: 2,
      levelsToShow: Number.MAX_VALUE,
      constrained: false,
      withLabels: true,
      clearCanvas: true,
      Node: {
        type: 'rectangle',
        overridable: true
      },
      Edge: {
        type: 'none'
      },
      Label: {
        type: 'Native'
      },
      duration: 700,
      fps: 25
    };

    var opts = Options("Canvas", "Node", "Edge", "Fx", "Tips", "NodeStyles",
                       "Events", "Navigation", "Controller", "Label");
    this.controller = this.config = $.merge(opts, config, controller);
    this.layout.orientation = this.config.orientation;

    var canvasConfig = this.config;
    if (canvasConfig.useCanvas) {
      this.canvas = canvasConfig.useCanvas;
      this.config.labelContainer = this.canvas.id + '-label';
    } else {
      this.canvas = new Canvas(this, canvasConfig);
      this.config.labelContainer = canvasConfig.injectInto + '-label';
    }

    this.graphOptions = {
      'complex': true,
      'Node': {
        'selected': false,
        'exist': true,
        'drawn': true
      }
    };

    this.graph = new Graph(
      this.graphOptions, this.config.Node, this.config.Edge, this.config.Label);

    this.labels = new $jit.Icicle.Label[this.config.Label.type](this);
    this.fx = new $jit.Icicle.Plot(this);
    this.op = new $jit.Icicle.Op(this);
    this.group = new $jit.Icicle.Group(this);
    this.clickedNode = null;

    this.initializeExtras();
  },

  refresh: function(){
    this.compute();
    this.plot();
  },

  plot: function(){
    this.fx.plot(this.config);
  },

  leaf: function(n){
    return Graph.Util.getSubnodes(n, [1, 1], "ignore").length == 0;
  },

  enter: function (node) {
    if (this.busy) return;
    this.busy = true;

    var that = this,
        config = this.config,
        clickedNode = node,
        previousClickedNode = this.clickedNode;

    var callback = {
      onComplete: function() {
        //compute positions of newly inserted nodes
        if(config.request) that.compute();
        if(config.animate) {
          //fade nodes
          that.graph.nodeList.setDataset(['current', 'end'], {
            'alpha': [1, 0]
          });
          Graph.Util.eachSubgraph(node, function(n) {
            n.setData('alpha', 1, 'end');
          }, "ignore");
          that.fx.animate({
            duration: 500,
            modes:['node-property:alpha'],
            onComplete: function() {
              //compute end positions
              that.clickedNode = clickedNode;
              that.compute('end');
              //animate positions
              that.clickedNode = previousClickedNode;
              that.fx.animate({
                modes:['linear', 'node-property:width:height'],
                duration: 1000,
                onComplete: function() {
                  that.busy = false;
                  that.clickedNode = clickedNode;
                }
              });
            }
          });
        } else {
          that.clickedNode = node;
          that.refresh();
          that.busy = false;
        }
      }
    };

    if(config.request) {
      this.requestNodes(clickedNode, callback);
    } else {
      callback.onComplete();
    }
  },

  out: function(){
    if(this.busy) return;
    this.busy = true;
    this.events.hoveredNode = false;
    var that = this,
        GUtil = Graph.Util,
        config = this.config,
        graph = this.graph,
        parents = GUtil.getParents(graph.getNode(this.clickedNode && this.clickedNode.id || this.root)),
        parent = parents[0],
        clickedNode = parent,
        previousClickedNode = this.clickedNode;

    //if no parents return
    if(!parent) {
      this.busy = false;
      return;
    }
    //final plot callback
    callback = {
      onComplete: function() {
        that.clickedNode = parent;
        if(config.request) {
          that.requestNodes(parent, {
            onComplete: function() {
              that.compute();
              that.plot();
              that.busy = false;
            }
          });
        } else {
          that.compute();
          that.plot();
          that.busy = false;
        }
      }
    };
    //animate node positions
    if(config.animate) {
      this.clickedNode = clickedNode;
      this.compute('end');
      //animate the visible subtree only
      this.clickedNode = previousClickedNode;
      this.fx.animate({
        modes:['linear', 'node-property:width:height'],
        duration: 1000,
        onComplete: function() {
          //animate the parent subtree
          that.clickedNode = clickedNode;
          //change nodes alpha
          graph.nodeList.setDataset(['current', 'end'], {
            'alpha': [0, 1]
          });
          GUtil.eachSubgraph(previousClickedNode, function(node) {
            node.setData('alpha', 1);
          }, "ignore");
          that.fx.animate({
            duration: 500,
            modes:['node-property:alpha'],
            onComplete: function() {
              callback.onComplete();
            }
          });
        }
      });
    } else {
      callback.onComplete();
    }
  },
  requestNodes: function(node, onComplete){
    var handler = $.merge(this.controller, onComplete), lev = this.config.levelsToShow;
    if (handler.request) {
      var leaves = [], d = node._depth;
      Graph.Util.eachLevel(node, 0, lev, function(n){
        if (n.drawn && !Graph.Util.anySubnode(n)) {
          leaves.push(n);
          n._level = lev - (n._depth - d);
        }
      });
      this.group.requestNodes(leaves, handler);
    } else {
      handler.onComplete();
    }
  },
});

$jit.Icicle.Op = new Class({
  Implements: Graph.Op,

  initialize: function(viz) {
    this.viz = viz;
  }
});

/*
 * Performs operations on group of nodes.
 */
$jit.Icicle.Group = new Class({

  initialize: function(viz){
    this.viz = viz;
    this.canvas = viz.canvas;
    this.config = viz.config;
  },

  /*
   * Calls the request method on the controller to request a subtree for each node.
   */
  requestNodes: function(nodes, controller){
    var counter = 0, len = nodes.length, nodeSelected = {};
    var complete = function(){
      controller.onComplete();
    };
    var viz = this.viz;
    if (len == 0)
      complete();
    for(var i = 0; i < len; i++) {
      nodeSelected[nodes[i].id] = nodes[i];
      controller.request(nodes[i].id, nodes[i]._level, {
        onComplete: function(nodeId, data){
          if (data && data.children) {
            data.id = nodeId;
            viz.op.sum(data, {
              type: 'nothing'
            });
          }
          if (++counter == len) {
            Graph.Util.computeLevels(viz.graph, viz.root, 0);
            complete();
          }
        }
      });
    }
  }
});

$jit.Icicle.Plot = new Class({
  Implements: Graph.Plot,

  initialize: function(viz){
    this.viz = viz;
    this.config = viz.config;
    this.node = this.config.Node;
    this.edge = this.config.Edge;
    this.animation = new Animation;
    this.nodeTypes = new $jit.Icicle.Plot.NodeTypes;
    this.edgeTypes = new $jit.Icicle.Plot.EdgeTypes;
    this.labels = viz.labels;
  },

  plot: function(opt, animating){
    var viz = this.viz, graph = viz.graph;
    var root = graph.getNode(viz.clickedNode && viz.clickedNode.id || viz.root);
    var initialDepth = root._depth;
    viz.canvas.clear();
    this.plotTree(root, $.merge(opt, {
      'withLabels': true,
      'hideLabels': !!animating,
      'plotSubtree': function(root, node) {
        return (node._depth - initialDepth < viz.config.levelsToShow);
      }
    }), animating);
  }
});

/*
 * Object: Icicle.Label
 *
 * Label interface implementation for the Icicle
 *
 * See Also:
 *
 * <Graph.Label>, <ST.Label.HTML>, <RGraph.Label.SVG>
 *
 */
$jit.Icicle.Label = {};

$jit.Icicle.Label.Native = new Class({
  Implements: Graph.Label.Native,

  renderLabel: function(canvas, node, controller) {
    var ctx = canvas.getCtx();

    // Guess as much as possible if the label will fit in the node
    if(controller.orientation == 'h' && node.getData("height") < node.getLabelData("size") * 1.5 ||
       controller.orientation == 'v' && node.getData("width") < ctx.measureText(node.name).width)
      return;

    var pos = node.pos.getc(true);

    ctx.fillText(node.name,
                 pos.x + node.getData("width") / 2,
                 pos.y + node.getData("height") / 2);
  },
});

/*
 * Class: Icicle.Label.SVG
 *
 * Implements labels using SVG (currently not supported in IE).
 *
 * Extends:
 *
 * <Graph.Label.SVG>
 *
 * See also:
 *
 * <Hypertree.Label>, <ST.Label>, <Hypertree>, <RGraph>, <ST>, <Graph>.
 *
 */
$jit.Icicle.Label.SVG = new Class( {
  Implements: Graph.Label.SVG,

  initialize: function(viz){
    this.viz = viz;
  },

  /*
   * Method: placeLabel
   *
   * Overrides abstract method placeLabel in <Graph.Plot>.
   *
   * Parameters:
   *
   * tag - A DOM label element.
   * node - A <Graph.Node>.
   * controller - A configuration/controller object passed to the visualization.
   */
  placeLabel: function(tag, node, controller){
    var pos = node.pos.getc(true), canvas = this.viz.canvas;
    var radius = canvas.getSize();
    var labelPos = {
      x: Math.round(pos.x + radius.width / 2),
      y: Math.round(pos.y + radius.height / 2)
    };
    tag.setAttribute('x', labelPos.x);
    tag.setAttribute('y', labelPos.y);

    controller.onPlaceLabel(tag, node);
  }
});

/*
 * Class: Icicle.Label.HTML
 *
 * Implements labels using plain old HTML.
 *
 * Extends:
 *
 * <Icicle.Label.DOM>, <Graph.Label.HTML>
 *
 * See also:
 *
 * <Hypertree.Label>, <ST.Label>, <Hypertree>, <RGraph>, <ST>, <Graph>.
 *
*/
$jit.Icicle.Label.HTML = new Class( {
  Implements: Graph.Label.HTML,

  initialize: function(viz){
    this.viz = viz;
  },

  /*
   * Method: placeLabel
   *
   * Overrides abstract method placeLabel in <Graph.Plot>.
   *
   * Parameters:
   *
   * tag - A DOM label element.
   * node - A <Graph.Node>.
   * controller - A configuration/controller object passed to the visualization.
   */
  placeLabel: function(tag, node, controller){
    var pos = node.pos.getc(true), canvas = this.viz.canvas;
    var radius = canvas.getSize();
    var labelPos = {
      x: Math.round(pos.x + radius.width / 2),
      y: Math.round(pos.y + radius.height / 2)
    };

    var style = tag.style;
    style.left = labelPos.x + 'px';
    style.top = labelPos.y + 'px';
    style.display = '';

    controller.onPlaceLabel(tag, node);
  }
});

/*
 * Class: Icicle.Plot.NodeTypes
 *
 * Here are implemented all kinds of node rendering functions.
 * Rendering functions implemented are 'none' and 'rectangle'
 *
 * You can add new Node types by implementing a new method in this class
 *
 * Example:
 *
 * (start code js)
 *   Icicle.Plot.NodeTypes.implement({
 *     'newnodetypename': function(node, canvas) {
 *       //Render my node here.
 *     }
 *   });
 * (end code)
 */
$jit.Icicle.Plot.NodeTypes = new Class( {
  'none': {
    'render': $.empty
  },

  'rectangle': {
    'render': function(node, canvas) {
      var offset = this.viz.config.offset;
      var width = node.getData('width');
      var height = node.getData('height');
      var pos = node.pos.getc(true);
      var posx = pos.x + offset / 2, posy = pos.y + offset / 2;
      var ctx = canvas.getCtx();

      var startColor = [15, 100, 100]; // HSV
      var color = startColor.slice(0);

      color[0] = (color[0] + 30 * node._depth) % 360;
      color[1] = ((color[1] - (1 - node.getData('height') / this.viz.canvas.getSize().height)*100/4) + 100) % 101;

      ctx.fillStyle = $.hsvToHex(color);
      ctx.fillRect(posx, posy, Math.max(0, width - offset), Math.max(0, height - offset));
      color[2] = 40;
      ctx.strokeStyle = $.hsvToHex(color);
      ctx.strokeRect(pos.x, pos.y, width, height);
    },

    'contains': function(node, pos) {
      if(this.viz.clickedNode && !$jit.Graph.Util.isDescendantOf(node, this.viz.clickedNode.id)) return false;
      var npos = node.pos.getc(true),
          width = node.getData('width'),
          height = node.getData('height');
      return this.nodeHelper.rectangle.contains({x: npos.x + width/2, y: npos.y + height/2}, pos, width, height);
    }
  }
});

$jit.Icicle.Plot.EdgeTypes = new Class( {
  'none': $.empty
});

<commit_msg>Don't hide labels while animating<commit_after>$jit.Icicle = new Class({
  Implements: [ Loader, Extras, Layouts.Icicle ],

  layout: {
    orientation: "h",
    vertical: function(){
      return this.orientation == "v";
    },
    horizontal: function(){
      return this.orientation == "h";
    },
    change: function(){
      this.orientation = this.vertical()? "h" : "v";
    }
  },

  initialize: function(controller) {
    var config = {
      animate: false,
      orientation: "h",
      titleHeight: 13,
      offset: 2,
      levelsToShow: Number.MAX_VALUE,
      constrained: false,
      withLabels: true,
      clearCanvas: true,
      Node: {
        type: 'rectangle',
        overridable: true
      },
      Edge: {
        type: 'none'
      },
      Label: {
        type: 'Native'
      },
      duration: 700,
      fps: 25
    };

    var opts = Options("Canvas", "Node", "Edge", "Fx", "Tips", "NodeStyles",
                       "Events", "Navigation", "Controller", "Label");
    this.controller = this.config = $.merge(opts, config, controller);
    this.layout.orientation = this.config.orientation;

    var canvasConfig = this.config;
    if (canvasConfig.useCanvas) {
      this.canvas = canvasConfig.useCanvas;
      this.config.labelContainer = this.canvas.id + '-label';
    } else {
      this.canvas = new Canvas(this, canvasConfig);
      this.config.labelContainer = canvasConfig.injectInto + '-label';
    }

    this.graphOptions = {
      'complex': true,
      'Node': {
        'selected': false,
        'exist': true,
        'drawn': true
      }
    };

    this.graph = new Graph(
      this.graphOptions, this.config.Node, this.config.Edge, this.config.Label);

    this.labels = new $jit.Icicle.Label[this.config.Label.type](this);
    this.fx = new $jit.Icicle.Plot(this);
    this.op = new $jit.Icicle.Op(this);
    this.group = new $jit.Icicle.Group(this);
    this.clickedNode = null;

    this.initializeExtras();
  },

  refresh: function(){
    this.compute();
    this.plot();
  },

  plot: function(){
    this.fx.plot(this.config);
  },

  leaf: function(n){
    return Graph.Util.getSubnodes(n, [1, 1], "ignore").length == 0;
  },

  enter: function (node) {
    if (this.busy) return;
    this.busy = true;

    var that = this,
        config = this.config,
        clickedNode = node,
        previousClickedNode = this.clickedNode;

    var callback = {
      onComplete: function() {
        //compute positions of newly inserted nodes
        if(config.request) that.compute();
        if(config.animate) {
          //fade nodes
          that.graph.nodeList.setDataset(['current', 'end'], {
            'alpha': [1, 0]
          });
          Graph.Util.eachSubgraph(node, function(n) {
            n.setData('alpha', 1, 'end');
          }, "ignore");
          that.fx.animate({
            duration: 500,
            modes:['node-property:alpha'],
            onComplete: function() {
              //compute end positions
              that.clickedNode = clickedNode;
              that.compute('end');
              //animate positions
              that.clickedNode = previousClickedNode;
              that.fx.animate({
                modes:['linear', 'node-property:width:height'],
                duration: 1000,
                onComplete: function() {
                  that.busy = false;
                  that.clickedNode = clickedNode;
                }
              });
            }
          });
        } else {
          that.clickedNode = node;
          that.refresh();
          that.busy = false;
        }
      }
    };

    if(config.request) {
      this.requestNodes(clickedNode, callback);
    } else {
      callback.onComplete();
    }
  },

  out: function(){
    if(this.busy) return;
    this.busy = true;
    this.events.hoveredNode = false;
    var that = this,
        GUtil = Graph.Util,
        config = this.config,
        graph = this.graph,
        parents = GUtil.getParents(graph.getNode(this.clickedNode && this.clickedNode.id || this.root)),
        parent = parents[0],
        clickedNode = parent,
        previousClickedNode = this.clickedNode;

    //if no parents return
    if(!parent) {
      this.busy = false;
      return;
    }
    //final plot callback
    callback = {
      onComplete: function() {
        that.clickedNode = parent;
        if(config.request) {
          that.requestNodes(parent, {
            onComplete: function() {
              that.compute();
              that.plot();
              that.busy = false;
            }
          });
        } else {
          that.compute();
          that.plot();
          that.busy = false;
        }
      }
    };
    //animate node positions
    if(config.animate) {
      this.clickedNode = clickedNode;
      this.compute('end');
      //animate the visible subtree only
      this.clickedNode = previousClickedNode;
      this.fx.animate({
        modes:['linear', 'node-property:width:height'],
        duration: 1000,
        onComplete: function() {
          //animate the parent subtree
          that.clickedNode = clickedNode;
          //change nodes alpha
          graph.nodeList.setDataset(['current', 'end'], {
            'alpha': [0, 1]
          });
          GUtil.eachSubgraph(previousClickedNode, function(node) {
            node.setData('alpha', 1);
          }, "ignore");
          that.fx.animate({
            duration: 500,
            modes:['node-property:alpha'],
            onComplete: function() {
              callback.onComplete();
            }
          });
        }
      });
    } else {
      callback.onComplete();
    }
  },
  requestNodes: function(node, onComplete){
    var handler = $.merge(this.controller, onComplete), lev = this.config.levelsToShow;
    if (handler.request) {
      var leaves = [], d = node._depth;
      Graph.Util.eachLevel(node, 0, lev, function(n){
        if (n.drawn && !Graph.Util.anySubnode(n)) {
          leaves.push(n);
          n._level = lev - (n._depth - d);
        }
      });
      this.group.requestNodes(leaves, handler);
    } else {
      handler.onComplete();
    }
  },
});

$jit.Icicle.Op = new Class({
  Implements: Graph.Op,

  initialize: function(viz) {
    this.viz = viz;
  }
});

/*
 * Performs operations on group of nodes.
 */
$jit.Icicle.Group = new Class({

  initialize: function(viz){
    this.viz = viz;
    this.canvas = viz.canvas;
    this.config = viz.config;
  },

  /*
   * Calls the request method on the controller to request a subtree for each node.
   */
  requestNodes: function(nodes, controller){
    var counter = 0, len = nodes.length, nodeSelected = {};
    var complete = function(){
      controller.onComplete();
    };
    var viz = this.viz;
    if (len == 0)
      complete();
    for(var i = 0; i < len; i++) {
      nodeSelected[nodes[i].id] = nodes[i];
      controller.request(nodes[i].id, nodes[i]._level, {
        onComplete: function(nodeId, data){
          if (data && data.children) {
            data.id = nodeId;
            viz.op.sum(data, {
              type: 'nothing'
            });
          }
          if (++counter == len) {
            Graph.Util.computeLevels(viz.graph, viz.root, 0);
            complete();
          }
        }
      });
    }
  }
});

$jit.Icicle.Plot = new Class({
  Implements: Graph.Plot,

  initialize: function(viz){
    this.viz = viz;
    this.config = viz.config;
    this.node = this.config.Node;
    this.edge = this.config.Edge;
    this.animation = new Animation;
    this.nodeTypes = new $jit.Icicle.Plot.NodeTypes;
    this.edgeTypes = new $jit.Icicle.Plot.EdgeTypes;
    this.labels = viz.labels;
  },

  plot: function(opt, animating){
    var viz = this.viz, graph = viz.graph;
    var root = graph.getNode(viz.clickedNode && viz.clickedNode.id || viz.root);
    var initialDepth = root._depth;
    viz.canvas.clear();
    this.plotTree(root, $.merge(opt, {
      'withLabels': true,
      'hideLabels': false,
      'plotSubtree': function(root, node) {
        return (node._depth - initialDepth < viz.config.levelsToShow);
      }
    }), animating);
  }
});

/*
 * Object: Icicle.Label
 *
 * Label interface implementation for the Icicle
 *
 * See Also:
 *
 * <Graph.Label>, <ST.Label.HTML>, <RGraph.Label.SVG>
 *
 */
$jit.Icicle.Label = {};

$jit.Icicle.Label.Native = new Class({
  Implements: Graph.Label.Native,

  renderLabel: function(canvas, node, controller) {
    var ctx = canvas.getCtx();

    // Guess as much as possible if the label will fit in the node
    if(controller.orientation == 'h' && node.getData("height") < node.getLabelData("size") * 1.5 ||
       controller.orientation == 'v' && node.getData("width") < ctx.measureText(node.name).width)
      return;

    var pos = node.pos.getc(true);

    ctx.fillText(node.name,
                 pos.x + node.getData("width") / 2,
                 pos.y + node.getData("height") / 2);
  },
});

/*
 * Class: Icicle.Label.SVG
 *
 * Implements labels using SVG (currently not supported in IE).
 *
 * Extends:
 *
 * <Graph.Label.SVG>
 *
 * See also:
 *
 * <Hypertree.Label>, <ST.Label>, <Hypertree>, <RGraph>, <ST>, <Graph>.
 *
 */
$jit.Icicle.Label.SVG = new Class( {
  Implements: Graph.Label.SVG,

  initialize: function(viz){
    this.viz = viz;
  },

  /*
   * Method: placeLabel
   *
   * Overrides abstract method placeLabel in <Graph.Plot>.
   *
   * Parameters:
   *
   * tag - A DOM label element.
   * node - A <Graph.Node>.
   * controller - A configuration/controller object passed to the visualization.
   */
  placeLabel: function(tag, node, controller){
    var pos = node.pos.getc(true), canvas = this.viz.canvas;
    var radius = canvas.getSize();
    var labelPos = {
      x: Math.round(pos.x + radius.width / 2),
      y: Math.round(pos.y + radius.height / 2)
    };
    tag.setAttribute('x', labelPos.x);
    tag.setAttribute('y', labelPos.y);

    controller.onPlaceLabel(tag, node);
  }
});

/*
 * Class: Icicle.Label.HTML
 *
 * Implements labels using plain old HTML.
 *
 * Extends:
 *
 * <Icicle.Label.DOM>, <Graph.Label.HTML>
 *
 * See also:
 *
 * <Hypertree.Label>, <ST.Label>, <Hypertree>, <RGraph>, <ST>, <Graph>.
 *
*/
$jit.Icicle.Label.HTML = new Class( {
  Implements: Graph.Label.HTML,

  initialize: function(viz){
    this.viz = viz;
  },

  /*
   * Method: placeLabel
   *
   * Overrides abstract method placeLabel in <Graph.Plot>.
   *
   * Parameters:
   *
   * tag - A DOM label element.
   * node - A <Graph.Node>.
   * controller - A configuration/controller object passed to the visualization.
   */
  placeLabel: function(tag, node, controller){
    var pos = node.pos.getc(true), canvas = this.viz.canvas;
    var radius = canvas.getSize();
    var labelPos = {
      x: Math.round(pos.x + radius.width / 2),
      y: Math.round(pos.y + radius.height / 2)
    };

    var style = tag.style;
    style.left = labelPos.x + 'px';
    style.top = labelPos.y + 'px';
    style.display = '';

    controller.onPlaceLabel(tag, node);
  }
});

/*
 * Class: Icicle.Plot.NodeTypes
 *
 * Here are implemented all kinds of node rendering functions.
 * Rendering functions implemented are 'none' and 'rectangle'
 *
 * You can add new Node types by implementing a new method in this class
 *
 * Example:
 *
 * (start code js)
 *   Icicle.Plot.NodeTypes.implement({
 *     'newnodetypename': function(node, canvas) {
 *       //Render my node here.
 *     }
 *   });
 * (end code)
 */
$jit.Icicle.Plot.NodeTypes = new Class( {
  'none': {
    'render': $.empty
  },

  'rectangle': {
    'render': function(node, canvas) {
      var offset = this.viz.config.offset;
      var width = node.getData('width');
      var height = node.getData('height');
      var pos = node.pos.getc(true);
      var posx = pos.x + offset / 2, posy = pos.y + offset / 2;
      var ctx = canvas.getCtx();

      var startColor = [15, 100, 100]; // HSV
      var color = startColor.slice(0);

      color[0] = (color[0] + 30 * node._depth) % 360;
      color[1] = ((color[1] - (1 - node.getData('height') / this.viz.canvas.getSize().height)*100/4) + 100) % 101;

      ctx.fillStyle = $.hsvToHex(color);
      ctx.fillRect(posx, posy, Math.max(0, width - offset), Math.max(0, height - offset));
      color[2] = 40;
      ctx.strokeStyle = $.hsvToHex(color);
      ctx.strokeRect(pos.x, pos.y, width, height);
    },

    'contains': function(node, pos) {
      if(this.viz.clickedNode && !$jit.Graph.Util.isDescendantOf(node, this.viz.clickedNode.id)) return false;
      var npos = node.pos.getc(true),
          width = node.getData('width'),
          height = node.getData('height');
      return this.nodeHelper.rectangle.contains({x: npos.x + width/2, y: npos.y + height/2}, pos, width, height);
    }
  }
});

$jit.Icicle.Plot.EdgeTypes = new Class( {
  'none': $.empty
});

<|endoftext|>


<commit_before>/*
 * This file is part of Search NEU and licensed under AGPL3.
 * See the license file in the root folder for details.
 */

import fs from 'fs-extra';

import DataLib from '../common/classModels/DataLib';

import classesScrapers from './scrapers/classes/main';

import macros from './macros';
import database from './database';
import Keys from '../common/Keys';
import ellucianCatalogParser from './scrapers/classes/parsers/ellucianCatalogParser';
import notifyer from './notifyer';

class Updater {
  // Don't call this directly, call .create instead.
  constructor(dataLib) {
    this.dataLib = dataLib;

    // 5 min if prod, 30 sec if dev.
    // In dev the cache will be used so we are not actually hitting NEU's servers anyway.
    const intervalTime = macros.PROD ? 300000 : 30000;

    setInterval(this.onInterval.bind(this), intervalTime);
  }

  static create(dataLib) {
    if (!dataLib) {
      macros.error('Invalid dataLib', dataLib);
      return null;
    }

    return new this(dataLib);
  }

  // This runs every couple of minutes and checks to see if any seats opened (or anything else changed) in any of the classes that people are watching
  // The steps of this process:
  // Fetch the user data from the database.
  // List the classes and sections that people are watching
  //   - This data is stored as hashes (Keys...getHash()) in the user DB
  // Access the data stored in RAM in this Node.js process to get the full data about these classes and sections
  //   - This data is passed in through the dataLib argument
  //   - This same instance of the data is also passed into the search class so it can access this same data
  // Access the URLs from these objects and use them to scrape the latest data about these classes
  // Compare with the existing data
  // Notify users about any changes
  // Update the local data about the changes
  async onInterval() {
    const startTime = Date.now();

    let users = await database.get('users');
    if (!users) {
      return;
    }

    users = Object.values(users);

    let classHashes = [];
    let sectionHashes = [];

    const sectionHashToUsers = {};
    const classHashToUsers = {};

    for (const user of users) {
      classHashes = user.watchingClasses.concat(classHashes);
      sectionHashes = user.watchingSections.concat(sectionHashes);

      for (const classHash of user.watchingClasses) {
        if (!classHashToUsers[classHash]) {
          classHashToUsers[classHash] = [];
        }

        classHashToUsers[classHash].push(user.facebookMessengerId);
      }

      for (const sectionHash of user.watchingSections) {
        if (!sectionHashToUsers[sectionHash]) {
          sectionHashToUsers[sectionHash] = [];
        }

        sectionHashToUsers[sectionHash].push(user.facebookMessengerId);
      }
    }

    const sectionHashMap = {};

    for (const sectionHash of sectionHashes) {
      const section = this.dataLib.getSectionServerDataFromHash(sectionHash);

      sectionHashMap[sectionHash] = section;
    }

    // Get the data for these hashes
    const classes = [];

    for (const classHash of classHashes) {
      const aClass = this.dataLib.getClassServerDataFromHash(classHash);

      if (!aClass) {
        macros.warn('Unable to fetch class for hash!', classHash);
        continue;
      }

      classes.push(aClass);

      if (aClass.crns) {
        for (const crn of aClass.crns) {
          const sectionHash = Keys.create({
            host: aClass.host,
            termId: aClass.termId,
            subject: aClass.subject,
            classId: aClass.classId,
            crn: crn,
          }).getHash();

          // Remove this one from the hash map
          sectionHashMap[sectionHash] = false;
        }
      }
    }

    // Find the sections that are still around
    for (const sectionHash of Object.keys(sectionHashMap)) {
      // If it was set to false, ignore it
      if (!sectionHashMap[sectionHash]) {
        continue;
      }
      macros.error('Section', sectionHash, "is being watched but it's class is not being watched?", Object.keys(sectionHashMap));
    }

    // Scrape the latest data
    const promises = classes.map((aClass) => {
      return ellucianCatalogParser.main(aClass.prettyUrl).then((newClass) => {
        // Copy over some fields that are not scraped from this scraper.
        newClass.value.host = aClass.host;
        newClass.value.termId = aClass.termId;
        newClass.value.subject = aClass.subject;

        return newClass;
      });
    });

    const allParsersOutput = await Promise.all(promises);

    const rootNode = {
      type: 'ignore',
      deps: allParsersOutput,
      value: {},
    };

    // Because ellucianCatalogParser returns a list of classes, instead of a singular class, we need to run it on all of them
    const output = classesScrapers.restructureData(rootNode);

    if (!output.sections) {
      output.sections = [];
    }

    if (!output.classes) {
      output.classes = [];
    }

    // Keep track of which terms have classes that we are updating.
    const updatingTerms = {};
    for (const aClass of classes) {
      updatingTerms[aClass.termId] = true;
    }

    for (const termId of Object.keys(updatingTerms)) {
      // Copy over every class we didn't just update from the old data.
      const oldClasses = this.dataLib.getClassesInTerm(termId);

      for (const aClass of oldClasses) {
        const hash = Keys.create(aClass).getHash();

        // TODO: Change this from classHashes to a hash of the output classes after the re-factor away classUid
        if (!classHashes.includes(hash)) {
          output.classes.push(aClass);
        }
      }

      const oldSections = this.dataLib.getSectionsInTerm(termId);

      for (const section of oldSections) {
        const hash = Keys.create(section).getHash();

        // TODO: Change this from classHashes to a hash of the output classes after the re-factor away classUid
        if (!sectionHashes.includes(hash)) {
          output.sections.push(section);
        }
      }
    }

    classesScrapers.runProcessors(output);

    // Keep track of which messages to send which users.
    // The key is the facebookMessengerId and the value is a list of messages.
    const userToMessageMap = {};

    for (const aNewClass of output.classes) {
      const hash = Keys.create(aNewClass).getHash();

      const oldClass = this.dataLib.getClassServerDataFromHash(hash);

      // Count how many sections are present in the new but not in the old.
      let count = 0;
      if (aNewClass.crns) {
        for (const crn of aNewClass.crns) {
          if (!oldClass.crns.includes(crn)) {
            count++;
          }
        }
      }

      let message = '';
      const classCode = `${aNewClass.subject} ${aNewClass.classId}`;

      if (count === 1) {
        message = `A section was added to ${classCode}!`;
      } else if (count > 1) {
        message = `${count} sections were added to ${classCode}!`;
      }

      if (message) {
        // If there is no space between the classId and the exclamation mark
        // Facebook Messenger on mobile will include the exclamation mark in the hyperlink
        // Oddly enough, Facebook messenger on desktop will not include the exclamation mark in the URL.
        message += ` Check it out at https://searchneu.com/${aNewClass.subject}${aNewClass.classId} !`;

        const user = classHashToUsers[hash];

        if (!userToMessageMap[user]) {
          userToMessageMap[user] = [];
        }

        userToMessageMap[user].push(message);
      }
    }

    if (output.sections) {
      for (const newSection of output.sections) {
        const hash = Keys.create(newSection).getHash();

        const oldSection = this.dataLib.getSectionServerDataFromHash(hash);

        // This should never run.
        // The user should not be able to sign up for a section that didn't exist when they were signing up.
        if (!oldSection) {
          macros.error('Section was added?', hash);
          continue;
        }

        let message;

        if (newSection.seatsRemaining > 0 && oldSection.seatsRemaining <= 0) {
          // See above comment about space before the exclamation mark.
          message = `A seat opened up in ${newSection.subject} ${newSection.classId} (CRN: ${newSection.crn}). Check it out at https://searchneu.com/${newSection.subject}${newSection.classId} !`;
        } else if (newSection.waitRemaining > 0 && oldSection.waitRemaining <= 0) {
          message = `A waitlist seat opened up in ${newSection.subject} ${newSection.classId} (CRN: ${newSection.crn}). Check it out at https://searchneu.com/${newSection.subject}${newSection.classId} !`;
        }

        if (message) {
          const user = sectionHashToUsers[hash];
          if (!userToMessageMap[user]) {
            userToMessageMap[user] = [];
          }

          userToMessageMap[user].push(message);
        }
      }
    }

    // Loop through the messages and send them.
    for (const fbUserId of Object.keys(userToMessageMap)) {
      for (const message of userToMessageMap[fbUserId]) {
        notifyer.sendFBNotification(fbUserId, message);
      }
    }

    // Update dataLib with the updated termDump
    // If we ever move to a real database, we would want to change this so it only updates the classes that the scrapers found.
    for (const aClass of output.classes) {
      this.dataLib.setClass(aClass);
    }

    for (const section of output.sections) {
      this.dataLib.setSection(section);
    }

    macros.log('Done running updater onInterval. It took', Date.now() - startTime, 'ms.');
  }
}

async function getFrontendData(path) {
  const body = await fs.readFile(path);
  return JSON.parse(body);
}

async function test() {
  const termDumpPromise = getFrontendData('./public/data/getTermDump/neu.edu/201810.json');

  const spring2018DataPromise = getFrontendData('./public/data/getTermDump/neu.edu/201830.json');

  const fallData = await termDumpPromise;

  const springData = await spring2018DataPromise;

  const dataLib = DataLib.loadData({
    201810: fallData,
    201830: springData,
  });

  const instance = Updater.create(dataLib);

  instance.onInterval();
}

if (require.main === module) {
  test();
}

export default Updater;
<commit_msg>Added notifications for unsubscribing<commit_after>/*
 * This file is part of Search NEU and licensed under AGPL3.
 * See the license file in the root folder for details.
 */

import fs from 'fs-extra';

import DataLib from '../common/classModels/DataLib';

import classesScrapers from './scrapers/classes/main';

import macros from './macros';
import database from './database';
import Keys from '../common/Keys';
import ellucianCatalogParser from './scrapers/classes/parsers/ellucianCatalogParser';
import notifyer from './notifyer';

class Updater {
  // Don't call this directly, call .create instead.
  constructor(dataLib) {
    this.dataLib = dataLib;

    // 5 min if prod, 30 sec if dev.
    // In dev the cache will be used so we are not actually hitting NEU's servers anyway.
    const intervalTime = macros.PROD ? 300000 : 30000;

    setInterval(this.onInterval.bind(this), intervalTime);
  }

  static create(dataLib) {
    if (!dataLib) {
      macros.error('Invalid dataLib', dataLib);
      return null;
    }

    return new this(dataLib);
  }

  // This runs every couple of minutes and checks to see if any seats opened (or anything else changed) in any of the classes that people are watching
  // The steps of this process:
  // Fetch the user data from the database.
  // List the classes and sections that people are watching
  //   - This data is stored as hashes (Keys...getHash()) in the user DB
  // Access the data stored in RAM in this Node.js process to get the full data about these classes and sections
  //   - This data is passed in through the dataLib argument
  //   - This same instance of the data is also passed into the search class so it can access this same data
  // Access the URLs from these objects and use them to scrape the latest data about these classes
  // Compare with the existing data
  // Notify users about any changes
  // Update the local data about the changes
  async onInterval() {
    const startTime = Date.now();

    let users = await database.get('users');
    if (!users) {
      return;
    }

    users = Object.values(users);

    let classHashes = [];
    let sectionHashes = [];

    const sectionHashToUsers = {};
    const classHashToUsers = {};

    for (const user of users) {
      classHashes = user.watchingClasses.concat(classHashes);
      sectionHashes = user.watchingSections.concat(sectionHashes);

      for (const classHash of user.watchingClasses) {
        if (!classHashToUsers[classHash]) {
          classHashToUsers[classHash] = [];
        }

        classHashToUsers[classHash].push(user.facebookMessengerId);
      }

      for (const sectionHash of user.watchingSections) {
        if (!sectionHashToUsers[sectionHash]) {
          sectionHashToUsers[sectionHash] = [];
        }

        sectionHashToUsers[sectionHash].push(user.facebookMessengerId);
      }
    }

    const sectionHashMap = {};

    for (const sectionHash of sectionHashes) {
      const section = this.dataLib.getSectionServerDataFromHash(sectionHash);

      sectionHashMap[sectionHash] = section;
    }

    // Get the data for these hashes
    const classes = [];

    for (const classHash of classHashes) {
      const aClass = this.dataLib.getClassServerDataFromHash(classHash);

      if (!aClass) {
        macros.warn('Unable to fetch class for hash!', classHash);
        continue;
      }

      classes.push(aClass);

      if (aClass.crns) {
        for (const crn of aClass.crns) {
          const sectionHash = Keys.create({
            host: aClass.host,
            termId: aClass.termId,
            subject: aClass.subject,
            classId: aClass.classId,
            crn: crn,
          }).getHash();

          // Remove this one from the hash map
          sectionHashMap[sectionHash] = false;
        }
      }
    }

    // Find the sections that are still around
    for (const sectionHash of Object.keys(sectionHashMap)) {
      // If it was set to false, ignore it
      if (!sectionHashMap[sectionHash]) {
        continue;
      }
      macros.error('Section', sectionHash, "is being watched but it's class is not being watched?", Object.keys(sectionHashMap));
    }

    // Scrape the latest data
    const promises = classes.map((aClass) => {
      return ellucianCatalogParser.main(aClass.prettyUrl).then((newClass) => {
        // Copy over some fields that are not scraped from this scraper.
        newClass.value.host = aClass.host;
        newClass.value.termId = aClass.termId;
        newClass.value.subject = aClass.subject;

        return newClass;
      });
    });

    const allParsersOutput = await Promise.all(promises);

    const rootNode = {
      type: 'ignore',
      deps: allParsersOutput,
      value: {},
    };

    // Because ellucianCatalogParser returns a list of classes, instead of a singular class, we need to run it on all of them
    const output = classesScrapers.restructureData(rootNode);

    if (!output.sections) {
      output.sections = [];
    }

    if (!output.classes) {
      output.classes = [];
    }

    // Keep track of which terms have classes that we are updating.
    const updatingTerms = {};
    for (const aClass of classes) {
      updatingTerms[aClass.termId] = true;
    }

    for (const termId of Object.keys(updatingTerms)) {
      // Copy over every class we didn't just update from the old data.
      const oldClasses = this.dataLib.getClassesInTerm(termId);

      for (const aClass of oldClasses) {
        const hash = Keys.create(aClass).getHash();

        // TODO: Change this from classHashes to a hash of the output classes after the re-factor away classUid
        if (!classHashes.includes(hash)) {
          output.classes.push(aClass);
        }
      }

      const oldSections = this.dataLib.getSectionsInTerm(termId);

      for (const section of oldSections) {
        const hash = Keys.create(section).getHash();

        // TODO: Change this from classHashes to a hash of the output classes after the re-factor away classUid
        if (!sectionHashes.includes(hash)) {
          output.sections.push(section);
        }
      }
    }

    classesScrapers.runProcessors(output);

    // Keep track of which messages to send which users.
    // The key is the facebookMessengerId and the value is a list of messages.
    const userToMessageMap = {};

    for (const aNewClass of output.classes) {
      const hash = Keys.create(aNewClass).getHash();

      const oldClass = this.dataLib.getClassServerDataFromHash(hash);

      // Count how many sections are present in the new but not in the old.
      let count = 0;
      if (aNewClass.crns) {
        for (const crn of aNewClass.crns) {
          if (!oldClass.crns.includes(crn)) {
            count++;
          }
        }
      }

      let message = '';
      const classCode = `${aNewClass.subject} ${aNewClass.classId}`;

      if (count === 1) {
        message = `A section was added to ${classCode}!`;
      } else if (count > 1) {
        message = `${count} sections were added to ${classCode}!`;
      }

      if (message) {
        // If there is no space between the classId and the exclamation mark
        // Facebook Messenger on mobile will include the exclamation mark in the hyperlink
        // Oddly enough, Facebook messenger on desktop will not include the exclamation mark in the URL.
        message += ` Check it out at https://searchneu.com/${aNewClass.subject}${aNewClass.classId} !`;

        const user = classHashToUsers[hash];

        if (!userToMessageMap[user]) {
          userToMessageMap[user] = [];
        }

        userToMessageMap[user].push(message);
      }
    }

    if (output.sections) {
      for (const newSection of output.sections) {
        const hash = Keys.create(newSection).getHash();

        const oldSection = this.dataLib.getSectionServerDataFromHash(hash);

        // This should never run.
        // The user should not be able to sign up for a section that didn't exist when they were signing up.
        if (!oldSection) {
          macros.error('Section was added?', hash);
          continue;
        }

        let message;

        if (newSection.seatsRemaining > 0 && oldSection.seatsRemaining <= 0) {
          // See above comment about space before the exclamation mark.
          message = `A seat opened up in ${newSection.subject} ${newSection.classId} (CRN: ${newSection.crn}). Check it out at https://searchneu.com/${newSection.subject}${newSection.classId} !`;
        } else if (newSection.waitRemaining > 0 && oldSection.waitRemaining <= 0) {
          message = `A waitlist seat opened up in ${newSection.subject} ${newSection.classId} (CRN: ${newSection.crn}). Check it out at https://searchneu.com/${newSection.subject}${newSection.classId} !`;
        }

        if (message) {
          const user = sectionHashToUsers[hash];
          if (!userToMessageMap[user]) {
            userToMessageMap[user] = [];
          }

          userToMessageMap[user].push(message);
        }
      }
    }

    // Loop through the messages and send them.
    for (const fbUserId of Object.keys(userToMessageMap)) {
      for (const message of userToMessageMap[fbUserId]) {
        notifyer.sendFBNotification(fbUserId, message);
      }
      notifyer.sendFBNotification(fbUserId, 'Reply with "unsubscribe" to unsubscribe from notifications.');
    }

    // Update dataLib with the updated termDump
    // If we ever move to a real database, we would want to change this so it only updates the classes that the scrapers found.
    for (const aClass of output.classes) {
      this.dataLib.setClass(aClass);
    }

    for (const section of output.sections) {
      this.dataLib.setSection(section);
    }

    macros.log('Done running updater onInterval. It took', Date.now() - startTime, 'ms.');
  }
}

async function getFrontendData(path) {
  const body = await fs.readFile(path);
  return JSON.parse(body);
}

async function test() {
  const termDumpPromise = getFrontendData('./public/data/getTermDump/neu.edu/201810.json');

  const spring2018DataPromise = getFrontendData('./public/data/getTermDump/neu.edu/201830.json');

  const fallData = await termDumpPromise;

  const springData = await spring2018DataPromise;

  const dataLib = DataLib.loadData({
    201810: fallData,
    201830: springData,
  });

  const instance = Updater.create(dataLib);

  instance.onInterval();
}

if (require.main === module) {
  test();
}

export default Updater;
<|endoftext|>


<commit_before>import React from 'react';

import Select from './Select';
import stripDiacritics from './utils/stripDiacritics';

let requestId = 0;

function initCache (cache) {
	if (cache && typeof cache !== 'object') {
		cache = {};
	}
	return cache ? cache : null;
}

function updateCache (cache, input, data) {
	if (!cache) return;
	cache[input] = data;
}

function getFromCache (cache, input) {
	if (!cache) return;
	for (let i = input.length; i >= 0; --i) {
		let cacheKey = input.slice(0, i);
		if (cache[cacheKey] && (input === cacheKey || cache[cacheKey].complete)) {
			return cache[cacheKey];
		}
	}
}

function thenPromise (promise, callback) {
	if (!promise || typeof promise.then !== 'function') return;
	return promise.then((data) => {
		callback(null, data);
	}, (err) => {
		callback(err);
	});
}

const stringOrNode = React.PropTypes.oneOfType([
	React.PropTypes.string,
	React.PropTypes.node
]);

const Async = React.createClass({
	propTypes: {
		cache: React.PropTypes.any,                     // object to use to cache results, can be null to disable cache
		ignoreAccents: React.PropTypes.bool,            // whether to strip diacritics when filtering (shared with Select)
		ignoreCase: React.PropTypes.bool,               // whether to perform case-insensitive filtering (shared with Select)
		isLoading: React.PropTypes.bool,                // overrides the isLoading state when set to true
		loadOptions: React.PropTypes.func.isRequired,   // function to call to load options asynchronously
		loadingPlaceholder: React.PropTypes.string,     // replaces the placeholder while options are loading
		minimumInput: React.PropTypes.number,           // the minimum number of characters that trigger loadOptions
		noResultsText: stringOrNode,                    // placeholder displayed when there are no matching search results (shared with Select)
		onInputChange: React.PropTypes.func,            // onInputChange handler: function (inputValue) {}
		placeholder: stringOrNode,                      // field placeholder, displayed when there's no value (shared with Select)
		searchPromptText: stringOrNode,       // label to prompt for search input
		searchingText: React.PropTypes.string,          // message to display while options are loading
	},
	getDefaultProps () {
		return {
			cache: true,
			ignoreAccents: true,
			ignoreCase: true,
			loadingPlaceholder: 'Loading...',
			minimumInput: 0,
			searchingText: 'Searching...',
			searchPromptText: 'Type to search',
		};
	},
	getInitialState () {
		return {
			cache: initCache(this.props.cache),
			isLoading: false,
			options: [],
		};
	},
	componentWillMount () {
		this._lastInput = '';
	},
	componentDidMount () {
		this.loadOptions('');
	},
	componentWillReceiveProps (nextProps) {
		if (nextProps.cache !== this.props.cache) {
			this.setState({
				cache: initCache(nextProps.cache),
			});
		}
	},
	focus () {
		this.refs.select.focus();
	},
	resetState () {
		this._currentRequestId = -1;
		this.setState({
			isLoading: false,
			options: [],
		});
	},
	getResponseHandler (input) {
		let _requestId = this._currentRequestId = requestId++;
		return (err, data) => {
			if (err) throw err;
			if (!this.isMounted()) return;
			updateCache(this.state.cache, input, data);
			if (_requestId !== this._currentRequestId) return;
			this.setState({
				isLoading: false,
				options: data && data.options || [],
			});
		};
	},
	loadOptions (input) {
		if (this.props.onInputChange) {
			let nextState = this.props.onInputChange(input);
			// Note: != used deliberately here to catch undefined and null
			if (nextState != null) {
				input = '' + nextState;
			}
		}
		if (this.props.ignoreAccents) input = stripDiacritics(input);
		if (this.props.ignoreCase) input = input.toLowerCase();

		this._lastInput = input;
		if (input.length < this.props.minimumInput) {
			return this.resetState();
		}
		let cacheResult = getFromCache(this.state.cache, input);
		if (cacheResult) {
			return this.setState({
				options: cacheResult.options,
			});
		}
		this.setState({
			isLoading: true,
		});
		let responseHandler = this.getResponseHandler(input);
		return thenPromise(this.props.loadOptions(input, responseHandler), responseHandler);
	},
	render () {
		let { noResultsText } = this.props;
		let { isLoading, options } = this.state;
		if (this.props.isLoading) isLoading = true;
		let placeholder = isLoading ? this.props.loadingPlaceholder : this.props.placeholder;
		if (isLoading) {
			noResultsText = this.props.searchingText;
		} else if (!options.length && this._lastInput.length < this.props.minimumInput) {
			noResultsText = this.props.searchPromptText;
		}
		return (
			<Select
				{...this.props}
				ref="select"
				isLoading={isLoading}
				noResultsText={noResultsText}
				onInputChange={this.loadOptions}
				options={options}
				placeholder={placeholder}
				/>
		);
	}
});

module.exports = Async;
<commit_msg>Resolves loadOptions promise to return input<commit_after>import React from 'react';

import Select from './Select';
import stripDiacritics from './utils/stripDiacritics';

let requestId = 0;

function initCache (cache) {
	if (cache && typeof cache !== 'object') {
		cache = {};
	}
	return cache ? cache : null;
}

function updateCache (cache, input, data) {
	if (!cache) return;
	cache[input] = data;
}

function getFromCache (cache, input) {
	if (!cache) return;
	for (let i = input.length; i >= 0; --i) {
		let cacheKey = input.slice(0, i);
		if (cache[cacheKey] && (input === cacheKey || cache[cacheKey].complete)) {
			return cache[cacheKey];
		}
	}
}

function thenPromise (promise, callback) {
	if (!promise || typeof promise.then !== 'function') return;
	return promise.then((data) => {
		callback(null, data);
	}, (err) => {
		callback(err);
	});
}

const stringOrNode = React.PropTypes.oneOfType([
	React.PropTypes.string,
	React.PropTypes.node
]);

const Async = React.createClass({
	propTypes: {
		cache: React.PropTypes.any,                     // object to use to cache results, can be null to disable cache
		ignoreAccents: React.PropTypes.bool,            // whether to strip diacritics when filtering (shared with Select)
		ignoreCase: React.PropTypes.bool,               // whether to perform case-insensitive filtering (shared with Select)
		isLoading: React.PropTypes.bool,                // overrides the isLoading state when set to true
		loadOptions: React.PropTypes.func.isRequired,   // function to call to load options asynchronously
		loadingPlaceholder: React.PropTypes.string,     // replaces the placeholder while options are loading
		minimumInput: React.PropTypes.number,           // the minimum number of characters that trigger loadOptions
		noResultsText: stringOrNode,                    // placeholder displayed when there are no matching search results (shared with Select)
		onInputChange: React.PropTypes.func,            // onInputChange handler: function (inputValue) {}
		placeholder: stringOrNode,                      // field placeholder, displayed when there's no value (shared with Select)
		searchPromptText: stringOrNode,       // label to prompt for search input
		searchingText: React.PropTypes.string,          // message to display while options are loading
	},
	getDefaultProps () {
		return {
			cache: true,
			ignoreAccents: true,
			ignoreCase: true,
			loadingPlaceholder: 'Loading...',
			minimumInput: 0,
			searchingText: 'Searching...',
			searchPromptText: 'Type to search',
		};
	},
	getInitialState () {
		return {
			cache: initCache(this.props.cache),
			isLoading: false,
			options: [],
		};
	},
	componentWillMount () {
		this._lastInput = '';
	},
	componentDidMount () {
		this.loadOptions('');
	},
	componentWillReceiveProps (nextProps) {
		if (nextProps.cache !== this.props.cache) {
			this.setState({
				cache: initCache(nextProps.cache),
			});
		}
	},
	focus () {
		this.refs.select.focus();
	},
	resetState () {
		this._currentRequestId = -1;
		this.setState({
			isLoading: false,
			options: [],
		});
	},
	getResponseHandler (input) {
		let _requestId = this._currentRequestId = requestId++;
		return (err, data) => {
			if (err) throw err;
			if (!this.isMounted()) return;
			updateCache(this.state.cache, input, data);
			if (_requestId !== this._currentRequestId) return;
			this.setState({
				isLoading: false,
				options: data && data.options || [],
			});
		};
	},
	loadOptions (input) {
		if (this.props.onInputChange) {
			let nextState = this.props.onInputChange(input);
			// Note: != used deliberately here to catch undefined and null
			if (nextState != null) {
				input = '' + nextState;
			}
		}
		if (this.props.ignoreAccents) input = stripDiacritics(input);
		if (this.props.ignoreCase) input = input.toLowerCase();

		this._lastInput = input;
		if (input.length < this.props.minimumInput) {
			return this.resetState();
		}
		let cacheResult = getFromCache(this.state.cache, input);
		if (cacheResult) {
			return this.setState({
				options: cacheResult.options,
			});
		}
		this.setState({
			isLoading: true,
		});
		let responseHandler = this.getResponseHandler(input);
		let inputPromise = thenPromise(this.props.loadOptions(input, responseHandler), responseHandler);
		return inputPromise ? inputPromise.then(() => {
			return input;
		}) : inputPromise;
	},
	render () {
		let { noResultsText } = this.props;
		let { isLoading, options } = this.state;
		if (this.props.isLoading) isLoading = true;
		let placeholder = isLoading ? this.props.loadingPlaceholder : this.props.placeholder;
		if (isLoading) {
			noResultsText = this.props.searchingText;
		} else if (!options.length && this._lastInput.length < this.props.minimumInput) {
			noResultsText = this.props.searchPromptText;
		}
		return (
			<Select
				{...this.props}
				ref="select"
				isLoading={isLoading}
				noResultsText={noResultsText}
				onInputChange={this.loadOptions}
				options={options}
				placeholder={placeholder}
				/>
		);
	}
});

module.exports = Async;
<|endoftext|>