Henry Oswald 11 лет назад
Родитель
Сommit
8f1d09beea

+ 2 - 1
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 = {}

+ 6 - 1
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

+ 3 - 0
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"
 ], () ->

+ 206 - 0
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.
+ *
+ *
+ * <div doc-module-components="ngCookies"></div>
+ *
+ * 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);

+ 274 - 0
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);
+
+
+

+ 213 - 0
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;t<arguments.length;t++){var n=arguments[t];if(void 0!==n&&null!==n){n=Object(n);for(var o=Object.keys(Object(n)),a=0,c=o.length;c>a;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;
+    };
+})();