From 8f1d09beeafa952bfcb97149b61dd8598023600e Mon Sep 17 00:00:00 2001 From: Henry Oswald Date: Tue, 27 Oct 2015 10:58:27 +0000 Subject: [PATCH] added six pack to web --- services/web/app/views/layout.jade | 3 +- services/web/public/coffee/base.coffee | 7 +- services/web/public/coffee/libs.coffee | 3 + .../web/public/js/libs/angular-cookies.js | 206 +++++++++++++ .../web/public/js/libs/angular-sixpack.js | 274 ++++++++++++++++++ services/web/public/js/libs/sixpack.js | 213 ++++++++++++++ 6 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 services/web/public/js/libs/angular-cookies.js create mode 100644 services/web/public/js/libs/angular-sixpack.js create mode 100644 services/web/public/js/libs/sixpack.js diff --git a/services/web/app/views/layout.jade b/services/web/app/views/layout.jade index 589c33f241..b0b6e09163 100644 --- a/services/web/app/views/layout.jade +++ b/services/web/app/views/layout.jade @@ -56,7 +56,8 @@ html(itemscope, itemtype='http://schema.org/Product') script. window.sharelatex = { siteUrl: '#{settings.siteUrl}', - jsPath: '#{jsPath}' + jsPath: '#{jsPath}', + sixpackDomain: '#{settings.sixpackDomain}' }; window.systemMessages = !{JSON.stringify(systemMessages).replace(/\//g, '\\/')}; window.ab = {} diff --git a/services/web/public/coffee/base.coffee b/services/web/public/coffee/base.coffee index a58fb02f31..565f28a075 100644 --- a/services/web/public/coffee/base.coffee +++ b/services/web/public/coffee/base.coffee @@ -13,8 +13,13 @@ define [ "underscore" "ngSanitize" "ipCookie" + "mvdSixpack" "ErrorCatcher" "localStorage" - ]) + ]).config (sixpackProvider)-> + sixpackProvider.setOptions({ + debug: true + baseUrl: window.sharelatex.sixpackDomain + }) return App diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libs.coffee index 99c8603ebc..807d6018b7 100644 --- a/services/web/public/coffee/libs.coffee +++ b/services/web/public/coffee/libs.coffee @@ -9,5 +9,8 @@ define [ "libs/fineuploader" "libs/angular-sanitize-1.2.17" "libs/angular-cookie" + "libs/angular-cookies" "libs/passfield" + "libs/sixpack" + "libs/angular-sixpack" ], () -> diff --git a/services/web/public/js/libs/angular-cookies.js b/services/web/public/js/libs/angular-cookies.js new file mode 100644 index 0000000000..2c90af0c58 --- /dev/null +++ b/services/web/public/js/libs/angular-cookies.js @@ -0,0 +1,206 @@ +/** + * @license AngularJS v1.3.15 + * (c) 2010-2014 Google, Inc. http://angularjs.org + * License: MIT + */ +(function(window, angular, undefined) {'use strict'; + +/** + * @ngdoc module + * @name ngCookies + * @description + * + * # ngCookies + * + * The `ngCookies` module provides a convenient wrapper for reading and writing browser cookies. + * + * + *
+ * + * See {@link ngCookies.$cookies `$cookies`} and + * {@link ngCookies.$cookieStore `$cookieStore`} for usage. + */ + + +angular.module('ngCookies', ['ng']). + /** + * @ngdoc service + * @name $cookies + * + * @description + * Provides read/write access to browser's cookies. + * + * Only a simple Object is exposed and by adding or removing properties to/from this object, new + * cookies are created/deleted at the end of current $eval. + * The object's properties can only be strings. + * + * Requires the {@link ngCookies `ngCookies`} module to be installed. + * + * @example + * + * ```js + * angular.module('cookiesExample', ['ngCookies']) + * .controller('ExampleController', ['$cookies', function($cookies) { + * // Retrieving a cookie + * var favoriteCookie = $cookies.myFavorite; + * // Setting a cookie + * $cookies.myFavorite = 'oatmeal'; + * }]); + * ``` + */ + factory('$cookies', ['$rootScope', '$browser', function($rootScope, $browser) { + var cookies = {}, + lastCookies = {}, + lastBrowserCookies, + runEval = false, + copy = angular.copy, + isUndefined = angular.isUndefined; + + //creates a poller fn that copies all cookies from the $browser to service & inits the service + $browser.addPollFn(function() { + var currentCookies = $browser.cookies(); + if (lastBrowserCookies != currentCookies) { //relies on browser.cookies() impl + lastBrowserCookies = currentCookies; + copy(currentCookies, lastCookies); + copy(currentCookies, cookies); + if (runEval) $rootScope.$apply(); + } + })(); + + runEval = true; + + //at the end of each eval, push cookies + //TODO: this should happen before the "delayed" watches fire, because if some cookies are not + // strings or browser refuses to store some cookies, we update the model in the push fn. + $rootScope.$watch(push); + + return cookies; + + + /** + * Pushes all the cookies from the service to the browser and verifies if all cookies were + * stored. + */ + function push() { + var name, + value, + browserCookies, + updated; + + //delete any cookies deleted in $cookies + for (name in lastCookies) { + if (isUndefined(cookies[name])) { + $browser.cookies(name, undefined); + } + } + + //update all cookies updated in $cookies + for (name in cookies) { + value = cookies[name]; + if (!angular.isString(value)) { + value = '' + value; + cookies[name] = value; + } + if (value !== lastCookies[name]) { + $browser.cookies(name, value); + updated = true; + } + } + + //verify what was actually stored + if (updated) { + updated = false; + browserCookies = $browser.cookies(); + + for (name in cookies) { + if (cookies[name] !== browserCookies[name]) { + //delete or reset all cookies that the browser dropped from $cookies + if (isUndefined(browserCookies[name])) { + delete cookies[name]; + } else { + cookies[name] = browserCookies[name]; + } + updated = true; + } + } + } + } + }]). + + + /** + * @ngdoc service + * @name $cookieStore + * @requires $cookies + * + * @description + * Provides a key-value (string-object) storage, that is backed by session cookies. + * Objects put or retrieved from this storage are automatically serialized or + * deserialized by angular's toJson/fromJson. + * + * Requires the {@link ngCookies `ngCookies`} module to be installed. + * + * @example + * + * ```js + * angular.module('cookieStoreExample', ['ngCookies']) + * .controller('ExampleController', ['$cookieStore', function($cookieStore) { + * // Put cookie + * $cookieStore.put('myFavorite','oatmeal'); + * // Get cookie + * var favoriteCookie = $cookieStore.get('myFavorite'); + * // Removing a cookie + * $cookieStore.remove('myFavorite'); + * }]); + * ``` + */ + factory('$cookieStore', ['$cookies', function($cookies) { + + return { + /** + * @ngdoc method + * @name $cookieStore#get + * + * @description + * Returns the value of given cookie key + * + * @param {string} key Id to use for lookup. + * @returns {Object} Deserialized cookie value. + */ + get: function(key) { + var value = $cookies[key]; + return value ? angular.fromJson(value) : value; + }, + + /** + * @ngdoc method + * @name $cookieStore#put + * + * @description + * Sets a value for given cookie key + * + * @param {string} key Id for the `value`. + * @param {Object} value Value to be stored. + */ + put: function(key, value) { + $cookies[key] = angular.toJson(value); + }, + + /** + * @ngdoc method + * @name $cookieStore#remove + * + * @description + * Remove given cookie + * + * @param {string} key Id of the key-value pair to delete. + */ + remove: function(key) { + delete $cookies[key]; + } + }; + + }]); + + +})(window, window.angular); \ No newline at end of file diff --git a/services/web/public/js/libs/angular-sixpack.js b/services/web/public/js/libs/angular-sixpack.js new file mode 100644 index 0000000000..17406ec8d5 --- /dev/null +++ b/services/web/public/js/libs/angular-sixpack.js @@ -0,0 +1,274 @@ +(function(angular) { + // Just in case... + if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (searchElement, fromIndex) { + if ( this === undefined || this === null ) { + throw new TypeError( '"this" is null or not defined' ); + } + + var length = this.length >>> 0; // Hack to convert object.length to a UInt32 + + fromIndex = +fromIndex || 0; + + if (Math.abs(fromIndex) === Infinity) { + fromIndex = 0; + } + + if (fromIndex < 0) { + fromIndex += length; + if (fromIndex < 0) { + fromIndex = 0; + } + } + + for (;fromIndex < length; fromIndex++) { + if (this[fromIndex] === searchElement) { + return fromIndex; + } + } + + return -1; + }; + } + angular.module('mvdSixpack', ['ngCookies']) + .provider('sixpack', function() { + var $body + , _tests = [] + , _choices = {} + , _opts = { + baseUrl: '', + debug: false, + } + , sp = window.sixpack; + + this.setOptions = function (options) { + angular.extend(_opts, options || {}); + } + + this.$get = ['$cookies','$timeout', '$log', function($cookies, $timeout, $log) { + var _cookiePrefix = 'sixpack-' + , _session + , _clientId; + + var _getOrInitSession = function () { + if (!_session) { + if (_clientId = $cookies[_cookiePrefix + 'clientId']) { + _session = new sp.Session({client_id:_clientId, base_url:_opts.baseUrl}); + } else { + _session = new sp.Session({client_id:undefined, base_url:_opts.baseUrl}); + $cookies[_cookiePrefix + 'clientId'] = _clientId = _session.client_id; + } + if (_opts.debug) { + $log.debug('[sixpack] Initialized session with clientId', _clientId, 'and base url', _opts.baseUrl); + }; + }; + return _session; + } + + var methods = { + participate : function (testName, variations, callback) { + if (_tests.indexOf(testName) < 0) { + _tests.push(testName); + } else if (angular.isDefined(_choices[testName])) { + var res = _choices[testName]; + if (res === false) { + // Still loading + $timeout(function () { + methods.participate(testName, variations, callback); + }, 50); + return; + }; + if (_opts.debug) { + $log.info('[sixpack] Using already chosen variation for test', testName, res); + }; + $timeout(function () { + callback(res.alternative.name, res); + }); + return; + } + _choices[testName] = false; + var session = _getOrInitSession(); + if (_opts.debug) { + $log.info('[sixpack] Getting choice for', testName, 'out of', variations); + }; + session.participate(testName, variations, function (err, res) { + if (err) { + if (_opts.debug) { + $log.warn('[sixpack] Received error', err); + }; + $timeout(function () { + callback(false); + }); + delete _choices[testName]; + return; + }; + _choices[testName] = res; + var choice = res.alternative.name; + if (_opts.debug) { + $log.info('[sixpack] Alternative chosen:', choice); + $log.debug('[sixpack] Full response', res); + }; + if (!$body) { + $body = angular.element(document).find('body'); + }; + $body.addClass('sixpack-'+testName+' sixpack-'+testName+'-'+choice); + $timeout(function() { + callback(choice, res); + }); + }); + }, + // Register a 'conversion'. If no testName, will call for all active tests + // Takes an optional callback that receives the raw response from sixpack (or undefined on error) + convert : function (testName, callback) { + var session = _getOrInitSession(); + if (!testName) { + if (_opts.debug) { + $log.info("[sixpack] Recording conversion for all tests", _tests); + }; + for (var i = 0, ii = _tests.length; i < ii; i++) { + var test = _tests[i] + , results = []; + session.convert(test, function (err, res) { + results.push(res); + if (err && _opts.debug) { + $log.warn("[sixpack] Error recording conversion for", test, err); + }; + if (results.length == ii) { + if (_opts.debug) { + $log.debug('[sixpack] All results:', results); + }; + if (callback) { + $timeout(function () { + callback(results); + }); + } + }; + }); + } + } else { + if (_opts.debug) { + $log.info("[sixpack] Recording conversion for", testName); + }; + session.convert(testName, function (err, res) { + if (err && _opts.debug) { + $log.warn('[sixpack] Error recording conversion:', err); + } else if (_opts.debug) { + $log.debug('[sixpack] Conversion result:', res); + }; + if (callback) { + $timeout(function () { + callback(res); + }); + } + }); + } + } + } + + return methods; + }]; + }) + .directive('sixpackSwitch', ['sixpack', function(sixpack) { + return { + controller : ['$element', function($element) { + var ctrl = this + , _testName + // Map of variation names to transclude fns + , _variations = {}; + + var _processChoice = function (choice) { + // Triggered if for some reason we get an error from sixpack, + // or optionally if a user is excluded from this test via configuration + if (!choice) { + _setContent(_variations['default']); + } else { + _setContent(_variations[choice]); + } + } + + var _setContent = function (fn) { + if (!fn) { + return; + }; + fn(function(clone) { + $element.replaceWith(clone); + }); + } + + // Pseudo-shim for '.keys' method + // Additionally, if obj has a 'default' property, sets that as the first element + // so sixpack will use it as the control + var _keys = function (obj) { + var keys = [] + , prop; + for (prop in obj) { + if (!obj.hasOwnProperty(prop)) { + continue; + }; + if (prop == 'default') { + keys.unshift(prop); + } else { + keys.push(prop); + } + } + return keys; + } + + ctrl.registerSwitch = function (name) { + _testName = name; + sixpack.participate(_testName, _keys(_variations), _processChoice); + } + + ctrl.registerVariation = function (variation, fn) { + _variations[variation] = fn; + } + + return ctrl; + }], + require: 'sixpackSwitch', + link : function ($scope, $element, $attrs, ctrl) { + ctrl.registerSwitch($attrs.sixpackSwitch); + } + } + }]) + // Register a variation for a test + .directive('sixpackWhen', ['$log', function ($log) { + return { + require: '^sixpackSwitch', + transclude: 'element', + link: function($scope, $element, $attrs, ctrl, transcludeFn) { + if ($attrs.sixpackWhen) { + ctrl.registerVariation($attrs.sixpackWhen, transcludeFn); + } else { + $log.debug('[sixpack] When directive initialized without a name, ignoring'); + } + } + } + }]) + // Register the 'default view, registered as the control variation, and + // always used if sixpack errors out or if user is excluded via configuration + .directive('sixpackDefault', function () { + return { + require: '^sixpackSwitch', + transclude: 'element', + link: function($scope, $element, $attrs, ctrl, transcludeFn) { + ctrl.registerVariation('default', transcludeFn); + } + } + }) + .directive('sixpackConvert', ['sixpack', function (sixpack) { + return { + link : function ($scope, $element, $attrs) { + var test = $attrs.sixpackConvert || undefined + , eventType = $attrs.on || 'click'; + + $element.on(eventType, function () { + sixpack.convert(test); + }); + } + } + }]); +})(window.angular, window.sixpack); + + + diff --git a/services/web/public/js/libs/sixpack.js b/services/web/public/js/libs/sixpack.js new file mode 100644 index 0000000000..5344615c30 --- /dev/null +++ b/services/web/public/js/libs/sixpack.js @@ -0,0 +1,213 @@ +(function () { + // Object.assign polyfill from https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill + Object.assign||Object.defineProperty(Object,"assign",{enumerable:!1,configurable:!0,writable:!0,value:function(e){"use strict";if(void 0===e||null===e)throw new TypeError("Cannot convert first argument to object");for(var r=Object(e),t=1;ta;a++){var i=o[a],b=Object.getOwnPropertyDescriptor(n,i);void 0!==b&&b.enumerable&&(r[i]=n[i])}}}return r}}); + + var sixpack = {base_url: "http://localhost:5000", ip_address: null, user_agent: null, timeout: 1000}; + + // check for node module loader + var on_node = false; + if (typeof module !== "undefined" && typeof require !== "undefined") { + on_node = true; + module.exports = sixpack; + } else { + window["sixpack"] = sixpack; + } + + sixpack.generate_client_id = function () { + // from http://stackoverflow.com/questions/105034 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); + }; + + sixpack.Session = function (options) { + Object.assign(this, sixpack, options); + console.log("creating new session", options) + if (!this.client_id) { + this.client_id = this.generate_client_id(); + } + if (!on_node) { + this.user_agent = this.user_agent || (window && window.navigator && window.navigator.userAgent); + } + }; + + sixpack.Session.prototype = { + participate: function(experiment_name, alternatives, traffic_fraction, force, callback) { + console.log("processing new participate", this.base_url) + if (typeof traffic_fraction === "function") { + callback = traffic_fraction; + traffic_fraction = null; + force = null; + } + else if (typeof traffic_fraction === "string") { + callback = force; + force = traffic_fraction; + traffic_fraction = null; + } + if (typeof force === "function") { + callback = force; + force = null; + } + + if (!(/^[a-z0-9][a-z0-9\-_ ]*$/).test(experiment_name)) { + return callback(new Error("Bad experiment_name")); + } + + if (alternatives.length < 2) { + return callback(new Error("Must specify at least 2 alternatives")); + } + + for (var i = 0; i < alternatives.length; i += 1) { + if (!(/^[a-z0-9][a-z0-9\-_ ]*$/).test(alternatives[i])) { + return callback(new Error("Bad alternative name: " + alternatives[i])); + } + } + var params = {client_id: this.client_id, + experiment: experiment_name, + alternatives: alternatives}; + if (!on_node && force == null) { + var regex = new RegExp("[\\?&]sixpack-force-" + experiment_name + "=([^&#]*)"); + var results = regex.exec(window.location.search); + if(results != null) { + force = decodeURIComponent(results[1].replace(/\+/g, " ")); + } + } + if (traffic_fraction !== null && !isNaN(traffic_fraction)) { + params.traffic_fraction = traffic_fraction; + } + if (force != null && _in_array(alternatives, force)) { + return callback(null, {"status": "ok", "alternative": {"name": force}, "experiment": {"version": 0, "name": experiment_name}, "client_id": this.client_id}); + } + if (this.ip_address) { + params.ip_address = this.ip_address; + } + if (this.user_agent) { + params.user_agent = this.user_agent; + } + console.log(this.base_url, "participate") + return _request(this.base_url + "/participate", params, this.timeout, function(err, res) { + if (err) { + res = {status: "failed", + error: err, + alternative: {name: alternatives[0]}}; + } + return callback(null, res); + }); + }, + convert: function(experiment_name, kpi, callback) { + if (typeof kpi === 'function') { + callback = kpi; + kpi = null; + } + if (!(/^[a-z0-9][a-z0-9\-_ ]*$/).test(experiment_name)) { + return callback(new Error("Bad experiment_name")); + } + + var params = {client_id: this.client_id, + experiment: experiment_name}; + if (this.ip_address) { + params.ip_address = this.ip_address; + } + if (this.user_agent) { + params.user_agent = this.user_agent; + } + if (kpi) { + params.kpi = kpi; + } + return _request(this.base_url + "/convert", params, this.timeout, function(err, res) { + console.log(res) + if (err) { + res = {status: "failed", + error: err,}; + } + return callback(null, res); + }); + } + }; + + var counter = 0; + + var _request = function(uri, params, timeout, callback) { + var timed_out = false; + var timeout_handle = setTimeout(function () { + timed_out = true; + return callback(new Error("request timed out")); + }, timeout); + + if (!on_node) { + var cb = "callback" + (++counter); + params.callback = "sixpack." + cb + sixpack[cb] = function (res) { + if (!timed_out) { + clearTimeout(timeout_handle); + return callback(null, res); + } + } + } + var url = _request_uri(uri, params); + console.log(url) + if (!on_node) { + script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = url; + script.async = true; + document.body.appendChild(script); + } else { + var http = require('http'); + var req = http.get(url, function(res) { + var body = ""; + res.on('data', function(chunk) { + return body += chunk; + }); + return res.on('end', function() { + var data; + if (res.statusCode == 500) { + data = {status: "failed", response: body}; + } else { + data = JSON.parse(body); + } + if (!timed_out) { + clearTimeout(timeout_handle); + return callback(null, data); + } + }); + }); + req.on('error', function(err) { + if (!timed_out) { + clearTimeout(timeout_handle); + return callback(err); + } + }); + } + }; + + var _request_uri = function(endpoint, params) { + var query_string = []; + var e = encodeURIComponent; + for (var key in params) { + if (params.hasOwnProperty(key)) { + var vals = params[key]; + if (Object.prototype.toString.call(vals) !== '[object Array]') { + vals = [vals]; + } + for (var i = 0; i < vals.length; i += 1) { + query_string.push(e(key) + '=' + e(vals[i])); + } + } + } + if (query_string.length) { + endpoint += '?' + query_string.join('&'); + } + return endpoint; + }; + + var _in_array = function(a, v) { + for(var i = 0; i < a.length; i++) { + if(a[i] === v) { + return true; + } + } + return false; + }; +})(); \ No newline at end of file