webgme
Version:
Web-based Generic Modeling Environment
1,358 lines (1,172 loc) • 4.62 MB
JavaScript
/*globals require, WebGMEGlobal, $, DEBUG, angular*/
/*jshint browser:true*/
require(
[
'jquery',
'jquery-ui',
'jquery-ui-iPad',
'js/jquery.WebGME',
'bootstrap',
'bootstrap-notify',
'underscore',
'backbone',
'js/WebGME',
'js/util',
'text!gmeConfig.json',
'text!package.json',
'js/logger',
'superagent',
'q',
'ravenjs',
'common/storage/util',
'angular',
'angular-ui-bootstrap',
'isis-ui-components',
'isis-ui-components-templates'
],
function (jQuery, jQueryUi, jQueryUiiPad, jqueryWebGME, bootstrap, bootstrapNotify, underscore,
backbone, webGME, util, gmeConfigJson, packageJson, Logger, superagent, Q, Raven, StorageUtil) {
'use strict';
var gmeConfig = JSON.parse(gmeConfigJson),
webgmeEnginePackage = JSON.parse(packageJson),
log = Logger.create('gme:main', gmeConfig.client.log),
domDeferred = Q.defer(),
defaultRavenOpts;
WebGMEGlobal.gmeConfig = gmeConfig;
WebGMEGlobal.version = gmeConfig.client.appVersion;
WebGMEGlobal.webgmeVersion = webgmeEnginePackage.version;
defaultRavenOpts = {release: WebGMEGlobal.version};
if (gmeConfig.client.errorReporting && gmeConfig.client.errorReporting.enable === true) {
Raven.config(
gmeConfig.client.errorReporting.DSN,
gmeConfig.client.errorReporting.ravenOptions || defaultRavenOpts
).install();
}
// Set the referrer in the session store (if not already set and we are the top window)
if (window.top === window && typeof window.sessionStorage.getItem('originalReferrer') !== 'string') {
window.sessionStorage.setItem('originalReferrer', document.referrer);
}
// domDeferred will be resolved (with gmeApp) when the dom is ready (i.e. $ function invoked).
$(function () {
var d,
keys,
i,
gmeApp;
if (gmeConfig.debug) {
DEBUG = gmeConfig.debug;
}
log.debug('domReady, got gmeConfig');
//#2 check URL
d = util.getURLParameterByName('debug').toLowerCase();
if (d === 'true') {
DEBUG = true;
} else if (d === 'false') {
DEBUG = false;
}
// attach external libraries to extlib/*
keys = Object.keys(gmeConfig.requirejsPaths);
for (i = 0; i < keys.length; i += 1) {
// assume this is a relative path from the current working directory
gmeConfig.requirejsPaths[keys[i]] = 'extlib/' + gmeConfig.requirejsPaths[keys[i]];
log.debug('Requirejs path resolved: ', keys[i], gmeConfig.requirejsPaths[keys[i]]);
}
// update client config to route the external lib requests
require.config({
paths: gmeConfig.requirejsPaths
});
// Extended disable function
jQuery.fn.extend({
disable: function (state) {
return this.each(function () {
var $this = $(this);
if ($this.is('input, button')) {
this.disabled = state;
} else {
$this.toggleClass('disabled', state);
}
});
}
});
// Initialize Angular. For this time no better place.
// has to be initialized as early as possible
gmeApp = angular.module(
'gmeApp', [
//'ngRoute',
//'routeStyles',
'ui.bootstrap',
'isis.ui.components',
//'gme.ui.projectsDialog',
'gme.ui.headerPanel'
]).config(['$locationProvider', function ($locationProvider) {
$locationProvider.html5Mode({
enabled: true,
requireBase: false // https://github.com/angular/angular.js/issues/8934
});
}]);
domDeferred.resolve(gmeApp);
});
function populateAvailableExtensionPoints(callback) {
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
function requestExtensionPoint(name) {
var deferred = Q.defer();
log.debug('requestExtensionPoint', name);
superagent.get('api/' + name)
.end(function (err, res) {
var keyName = 'all' + capitalizeFirstLetter(name);
if (res.status === 200) {
WebGMEGlobal[keyName] = res.body;
log.debug('/api/' + name, WebGMEGlobal[keyName]);
deferred.resolve();
} else {
log.error('/api/' + name + 'failed');
WebGMEGlobal[keyName] = [];
deferred.reject(err);
}
});
return deferred.promise;
}
function requestPluginMetadata() {
var deferred = Q.defer();
superagent.get('api/plugins/metadata')
.end(function (err, res) {
if (res.status === 200) {
WebGMEGlobal.allPlugins = Object.keys(res.body);
WebGMEGlobal.allPluginsMetadata = res.body;
deferred.resolve();
} else {
log.error('/api/' + name + 'failed');
WebGMEGlobal.allPlugins = [];
WebGMEGlobal.allPluginsMetadata = {};
deferred.reject(err);
}
});
return deferred.promise;
}
return Q.all([
requestExtensionPoint('visualizers'),
requestPluginMetadata(),
requestExtensionPoint('decorators'),
requestExtensionPoint('seeds'),
requestExtensionPoint('addOns')
]).nodeify(callback);
}
function populateUserInfo(callback) {
var userInfo,
userDeferred = Q.defer();
function checkIfAdminInOrg(userId, orgId) {
var deferred = Q.defer();
superagent.get('api/orgs/' + orgId)
.end(function (err, res) {
if (res.status === 200) {
if (res.body.admins.indexOf(userId) > -1) {
userInfo.adminOrgs.push(res.body);
}
} else {
log.error('failed getting org info', err);
}
deferred.resolve();
});
return deferred.promise;
}
superagent.get('api/user')
.end(function (err, res) {
if (res.status === 200) {
userInfo = res.body || {_id: 'N/A', orgs: []};
userInfo.adminOrgs = [];
Q.allSettled(userInfo.orgs.map(function (orgId) {
return checkIfAdminInOrg(userInfo._id, orgId);
}))
.then(function () {
WebGMEGlobal.userInfo = userInfo;
userDeferred.resolve(userInfo);
})
.catch(userDeferred.reject);
} else {
userDeferred.reject(err);
}
});
return userDeferred.promise.nodeify(callback);
}
function getDefaultComponentSettings(callback) {
var deferred = Q.defer();
superagent.get('api/componentSettings')
.end(function (err, res) {
if (res.status === 200) {
WebGMEGlobal.componentSettings = res.body;
} else {
log.warn('Could not obtain any default component settings (./config/components.json');
WebGMEGlobal.componentSettings = {};
}
deferred.resolve();
});
return deferred.promise.nodeify(callback);
}
function loadExtraCssFiles(callback) {
var deferred = Q.defer();
if (gmeConfig.visualization.extraCss.length > 0) {
require(gmeConfig.visualization.extraCss.map(function (cssFile) {
return 'css!' + cssFile;
}),
deferred.resolve,
deferred.reject
);
} else {
deferred.resolve();
}
return deferred.promise.nodeify(callback);
}
function loadDisplayNames(callback) {
var deferred = Q.defer();
superagent.get('api/users')
.query({displayName: true})
.end(function (err, res) {
if (res.status === 200) {
WebGMEGlobal._displayNames = {};
res.body.forEach(function (userData) {
WebGMEGlobal._displayNames[userData._id] = userData.displayName;
});
WebGMEGlobal.getUserDisplayName = function (userId) {
return WebGMEGlobal._displayNames[userId] || userId;
};
WebGMEGlobal.getProjectDisplayedNameFromProjectId = function (projectId) {
return WebGMEGlobal.getUserDisplayName(StorageUtil.getOwnerFromProjectId(projectId)) +
' ' + StorageUtil.CONSTANTS.PROJECT_DISPLAYED_NAME_SEP + ' ' +
StorageUtil.getProjectNameFromProjectId(projectId);
};
} else {
log.error('Unable to get display name list for users!');
WebGMEGlobal.getUserDisplayName = function (userId) {
return userId;
};
WebGMEGlobal.getProjectDisplayedNameFromProjectId =
StorageUtil.getProjectDisplayedNameFromProjectId;
}
deferred.resolve();
});
return deferred.promise.nodeify(callback);
}
Q.all([
domDeferred.promise,
loadExtraCssFiles(),
populateAvailableExtensionPoints(),
populateUserInfo(),
getDefaultComponentSettings(),
loadDisplayNames()
])
.then(function (result) {
var gmeApp = result[0];
webGME.start(function (client) {
gmeApp.value('gmeClient', client);
angular.bootstrap(document, ['gmeApp']);
});
})
.catch(function (err) {
log.error('Error at start up', err);
throw err;
});
}
);
/*globals define, $*/
/*jshint browser: true*/
/**
* WebGME jquery extension
*
* @author rkereskenyi / https://github.com/rkereskenyi
*/
define('js/jquery.WebGME',['jquery'], function () {
'use strict';
$.fn.extend({
editOnDblClick: function (params) {
this.each(function () {
$(this).on('dblclick.editOnDblClick', null, function (event) {
$(this).editInPlace(params);
event.stopPropagation();
event.preventDefault();
});
});
}
});
$.fn.extend({
editInPlace: function (params) {
var editClass = params && params.class || null,
extraCss = params && params.css || {},//Extra optional css styling
onChangeFn = params && params.onChange || null,
onFinishFn = params && params.onFinish || null,
enableEmpty = params && params.enableEmpty || false,
minHeight = 16,
fontSizeAdjust = 5;
function editInPlace(el) {
var w = el.width(),
h = el.height(),
originalValue,
inputCtrl,
keys,
i;
//check if already in edit mode
//if so, select the content
if (el.data('already-editing') === true) {
el.find('input').select().focus();
return;
}
//not editing yet, turn to edit mode now
el.data('already-editing', true);
//save old content
originalValue = el.text();
if (params && params.value) {
originalValue = params.value;
}
//create edit control
inputCtrl = $('<input/>', {
type: 'text',
value: originalValue
});
//add custom edit class
if (editClass && editClass !== '') {
inputCtrl.addClass(editClass);
}
//set css properties to fix Bootstrap's modification
h = Math.max(h, minHeight);
inputCtrl.outerWidth(w).outerHeight(h);
inputCtrl.css({
'box-sizing': 'border-box',
'margin': '0px',
'line-height': h + 'px',
'font-size': h - fontSizeAdjust
});
//add any custom css specified
keys = Object.keys(extraCss);
for (i = keys.length - 1; i >= 0; i--) {
inputCtrl.css(keys[i], extraCss[keys[i]]);
}
el.html(inputCtrl);
//set font size accordingly
//TODO: multiple line editor not handled correctly
/*h = inputCtrl.height();*/
//inputCtrl.css({'font-size': originalFontSize});
//finally put the control in focus
inputCtrl.focus();
inputCtrl.on('click', function (event) {
event.stopPropagation();
});
//hook up event handlers to 'save' and 'cancel'
inputCtrl.keydown(
function (event) {
switch (event.which) {
case 27: // [esc]
// discard changes on [esc]
inputCtrl.val(originalValue);
event.preventDefault();
event.stopPropagation();
$(this).blur();
break;
case 13: // [enter]
// simulate blur to accept new value
event.preventDefault();
event.stopPropagation();
$(this).blur();
break;
case 46:// DEL
//don't need to handle it specially but need to prevent propagation
event.stopPropagation();
break;
default:
break;
}
}
).blur(function (/*event*/) {
var newValue = inputCtrl.val();
//revert edit mode, when user leaves <input>
if (newValue === '' && enableEmpty === false) {
newValue = originalValue;
}
el.html('').text(newValue);
el.removeData('already-editing');
if (newValue !== originalValue) {
if (onChangeFn) {
onChangeFn.call(el, originalValue, newValue);
}
}
if (onFinishFn) {
onFinishFn.call(el);
}
});
}
this.each(function () {
editInPlace($(this));
});
}
});
// Canvas drawing extension
if (!!document.createElement('canvas').getContext) {
$.extend(window.CanvasRenderingContext2D.prototype, {
ellipse: function (aX, aY, r1, r2, fillIt) {
var aWidth,
aHeight,
hB,
vB,
eX,
eY,
mX,
mY;
aX = aX - r1;
aY = aY - r2;
aWidth = r1 * 2;
aHeight = r2 * 2;
hB = (aWidth / 2) * 0.5522848;
vB = (aHeight / 2) * 0.5522848;
eX = aX + aWidth;
eY = aY + aHeight;
mX = aX + aWidth / 2;
mY = aY + aHeight / 2;
this.beginPath();
this.moveTo(aX, mY);
this.bezierCurveTo(aX, mY - vB, mX - hB, aY, mX, aY);
this.bezierCurveTo(mX + hB, aY, eX, mY - vB, eX, mY);
this.bezierCurveTo(eX, mY + vB, mX + hB, eY, mX, eY);
this.bezierCurveTo(mX - hB, eY, aX, mY + vB, aX, mY);
this.closePath();
if (fillIt) {
this.fill();
}
this.stroke();
},
circle: function (aX, aY, aDiameter, fillIt) {
this.ellipse(aX, aY, aDiameter, aDiameter, fillIt);
}
});
}
/*
*
* Getting textwidth
*
*/
$.fn.extend({
textWidth: function () {
var htmlOrg,
htmlCalc,
width;
htmlOrg = $(this).html();
htmlCalc = '<span>' + htmlOrg + '</span>';
$(this).html(htmlCalc);
width = $(this).find('span:first').width();
$(this).html(htmlOrg);
return width;
}
});
$.fn.extend({
groupedAlphabetTabs: function (params) {
var defaultParams = {'groups': ['A - E', 'F - J', 'K - O', 'P - T', 'U - Z']},
opts = {},
ulBase = $('<ul class="nav nav-tabs"></ul>'),
liBase = $('<li class=""><a href="#" data-toggle="tab"></a></li>'),
hasActive = false,
ul,
li,
i,
start,
end;
$.extend(opts, defaultParams, params);
ul = ulBase.clone();
if (opts.extraTabs) {
opts.extraTabs.forEach(function (tabInfo) {
li = liBase.clone();
if (tabInfo.active === true) {
li.addClass('active');
hasActive = true;
}
li.find('a').text(tabInfo.title);
li.data('filter', tabInfo.data);
ul.append(li);
});
}
li = liBase.clone();
if (hasActive === false) {
li.addClass('active');
}
li.find('a').text('ALL');
ul.append(li);
for (i = 0; i < opts.groups.length; i += 1) {
start = opts.groups[i].split('-')[0].trim();
end = opts.groups[i].split('-')[1].trim();
li = liBase.clone();
li.find('a').text(opts.groups[i]);
li.data('filter', [start, end]);
ul.append(li);
}
this.each(function () {
$(this).append(ul.clone(true));
$(this).on('click.groupedAlphabetTabs', 'li', function (event) {
var filter = $(this).data('filter');
$(this).parent().find('li').each(function () {
$(this).removeClass('active');
});
$(this).addClass('active');
if (params && params.onClick) {
params.onClick(filter);
}
event.preventDefault();
});
});
}
});
});
/*globals define, debug*/
/*eslint-env node*/
/*eslint no-console: 0*/
/**
* @author pmeijer / https://github.com/pmeijer
*/
define('client/logger',['debug'], function (_debug) {
'use strict';
// Separate namespaces using ',' a leading '-' will disable the namespace.
// Each part takes a regex.
// ex: localStorage.debug = '*,-socket\.io*,-engine\.io*'
// will log all but socket.io and engine.io
function createLogger(name, options) {
var log = typeof debug === 'undefined' ? _debug(name) : debug(name),
level,
levels = {
silly: 0,
input: 1,
verbose: 2,
prompt: 3,
debug: 4,
info: 5,
data: 6,
help: 7,
warn: 8,
error: 9
};
if (!options) {
throw new Error('options required in logger');
}
if (Object.hasOwn(options, 'level') === false) {
throw new Error('options.level required in logger');
}
level = levels[options.level];
if (typeof level === 'undefined') {
level = levels.info;
}
log.debug = function () {
if (log.enabled && level <= levels.debug) {
if (console.debug) {
log.log = console.debug.bind(console);
} else {
log.log = console.log.bind(console);
}
log.apply(this, arguments);
}
};
log.info = function () {
if (log.enabled && level <= levels.info) {
log.log = console.info.bind(console);
log.apply(this, arguments);
}
};
log.warn = function () {
if (log.enabled && level <= levels.warn) {
log.log = console.warn.bind(console);
log.apply(this, arguments);
}
};
log.error = function () {
if (log.enabled && level <= levels.error) {
log.log = console.error.bind(console);
log.apply(this, arguments);
} else {
console.error.apply(console, arguments);
}
};
log.fork = function (forkName, useForkName) {
forkName = useForkName ? forkName : name + ':' + forkName;
return createLogger(forkName, options);
};
log.forkWithOptions = function (_name, _options) {
return createLogger(_name, _options);
};
return log;
}
function createWithGmeConfig(name, gmeConfig) {
return createLogger(name, gmeConfig.client.log);
}
return {
create: createLogger,
createWithGmeConfig: createWithGmeConfig
};
});
/*globals define*/
/*eslint-env node, browser*/
/**
* @author pmeijer / https://github.com/pmeijer
* @module Storage
*/
/**
* @typedef {string} CommitHash - Unique SHA-1 hash for commit object.
* @example
* '#5496cf226542fcceccf89056f0d27564abc88c99'
*/
/**
* @typedef {object} CommitResult
* @prop {module:Storage~CommitHash} hash - The commitHash for the commit.
* @prop {string} status - 'SYNCED', 'FORKED', 'CANCELED', undefined
*
* @example
* {
* status: 'SYNCED',
* hash: '#someHash'
* }
* @example
* {
* hash: '<hash from makeCommit with no branch provided>'
* }
*/
/**
* @typedef {object} CommitObject
* @prop {module:Storage~CommitHash} _id - Hash of the commit object, a.k.a commitHash.
* @prop {module:Core~ObjectHash} root - Hash of the associated root object, a.k.a. rootHash.
* @prop {module:Storage~CommitHash[]} parents - Commits from where this commit evolved.
* @prop {number} time - When the commit object was created Date.now().
* @prop {string} message - Commit message.
* @prop {string[]} updater - Commit message.
* @prop {string} type - 'commit'
*
* @example
* {
* _id: '#5496cf226542fcceccf89056f0d27564abc88c99',
* root: '#04009ecd1e68117cd3e9d39c87aadd9ed1ee5cb3',
* parents: ['#87d9fd309ec6a5d84776d7731ce1f1ab2790aac2']
* updater: ['guest'],
* time: 1430169614741,
* message: "createChildren({\"/1008889918/1998840078\":\"/1182870936/737997118/1736829087/1966323860\"})",
* type: 'commit'
* }
*/
/**
* @typedef {object} PatchObject
* @prop {module:Core~ObjectHash} _id - Hash of the expected result object.
* @prop {module:Core~ObjectHash} base - Hash of the base object where the patch should be applied.
* @prop {string} type - 'patch'.
* @prop {object} patch - The patch instructions (based on [RFC6902]{@link http://tools.ietf.org/html/rfc6902}).
*
* @example
* {
* _id: '#5496cf226542fcceccf89056f0d27564abc88c99',
* base: '#04009ecd1e68117cd3e9d39c87aadd9ed1ee5cb3',
* type: 'patch',
* patch: [{op: 'add', path: '/atr/new', value: 'value'}]
* }
*/
(function (factory) {
if (typeof define === 'function' && define.amd) {
define('common/storage/constants',[], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory();
}
}(function () {
'use strict';
return {
//Version
VERSION: '1.2.0',
// Database related
MONGO_ID: '_id',
COMMIT_TYPE: 'commit',
OVERLAY_SHARD_TYPE: 'shard',
PROJECT_INFO_KEYS: [
'createdAt',
'creator',
'viewedAt',
'viewer',
'modifiedAt',
'modifier',
'kind',
'description',
'icon'
],
EMPTY_PROJECT_DATA: 'empty',
PROJECT_ID_SEP: '+',
PROJECT_DISPLAYED_NAME_SEP: '/',
// Socket IO
DATABASE_ROOM: 'database',
ROOM_DIVIDER: '%',
NETWORK_STATUS_CHANGED: 'NETWORK_STATUS_CHANGED',
CONNECTED: 'CONNECTED',
DISCONNECTED: 'DISCONNECTED',
RECONNECTED: 'RECONNECTED',
INCOMPATIBLE_CONNECTION: 'INCOMPATIBLE_CONNECTION',
CONNECTION_ERROR: 'CONNECTION_ERROR',
JWT_ABOUT_TO_EXPIRE: 'JWT_ABOUT_TO_EXPIRE',
JWT_EXPIRED: 'JWT_EXPIRED',
RECONNECTING: 'RECONNECTING', // Internal storage state where the websocket connection has been established,
// but work is still be done to join branch and document rooms correctly.
// Branch commit status - this is the status returned after setting the hash of a branch
SYNCED: 'SYNCED', // The commitData was inserted in the database and the branchHash updated.
FORKED: 'FORKED', // The commitData was inserted in the database, but the branchHash NOT updated.
CANCELED: 'CANCELED', // The commitData was never inserted to the database.
MERGED: 'MERGED', // The commit was initially forked, but successfully merged.
BRANCH_STATUS: {
SYNC: 'SYNC',
AHEAD_SYNC: 'AHEAD_SYNC',
AHEAD_NOT_SYNC: 'AHEAD_NOT_SYNC',
PULLING: 'PULLING',
MERGING: 'MERGING',
ERROR: 'ERROR'
},
// Events
PROJECT_DELETED: 'PROJECT_DELETED',
PROJECT_CREATED: 'PROJECT_CREATED',
BRANCH_DELETED: 'BRANCH_DELETED',
BRANCH_CREATED: 'BRANCH_CREATED',
BRANCH_HASH_UPDATED: 'BRANCH_HASH_UPDATED',
TAG_DELETED: 'TAG_DELETED',
TAG_CREATED: 'TAG_CREATED',
COMMIT: 'COMMIT',
BRANCH_UPDATED: 'BRANCH_UPDATED',
BRANCH_JOINED: 'BRANCH_JOINED',
BRANCH_LEFT: 'BRANCH_LEFT',
NOTIFICATION: 'NOTIFICATION',
DOCUMENT_OPERATION: 'DOCUMENT_OPERATION',
DOCUMENT_SELECTION: 'DOCUMENT_SELECTION',
// Types of notifications
BRANCH_ROOM_SOCKETS: 'BRANCH_ROOM_SOCKETS',
PLUGIN_NOTIFICATION: 'PLUGIN_NOTIFICATION',
ADD_ON_NOTIFICATION: 'ADD_ON_NOTIFICATION',
CLIENT_STATE_NOTIFICATION: 'CLIENT_STATE_NOTIFICATION',
// Additional sub types for plugin notification
PLUGIN_NOTIFICATION_TYPE: {
INITIATED: 'INITIATED',
ABORT: 'ABORT',
MESSAGE: 'MESSAGE'
},
WEBSOCKET_ROUTER_ROOM_ID_PREFIX: 'wsr-room-',
WEBSOCKET_ROUTER_MESSAGE_TYPES: {
CONNECT: 'wsm-connect',
DISCONNECT: 'wsm-disconnect',
MESSAGE: 'wsm-message'
}
};
}));
/*globals define*/
/*eslint-env node, browser*/
/*eslint no-bitwise: 0*/
/**
* @author kecso / https://github.com/kecso
*/
define('common/util/guid',[],function () {
'use strict';
var guid = function () {
var s4 = function () {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
};
//return GUID
return s4() + [s4(), s4(), s4(), s4(), s4()].join('-') + s4() + s4();
};
return guid;
});
/*globals define*/
/*eslint-env node, browser*/
/**
* @author rkereskenyi / https://github.com/rkereskenyi
*/
define('common/EventDispatcher',[], function () {
'use strict';
var EventDispatcher = function () {
this._eventList = {};
};
EventDispatcher.prototype = {
_eventList: null,
_getEvent: function (eventName, create) {
// Check if Array of Event Handlers has been created
if (!this._eventList[eventName]) {
// Check if the calling method wants to create the Array
// if not created. This reduces unneeded memory usage.
if (!create) {
return null;
}
// Create the Array of Event Handlers
this._eventList[eventName] = [];
// new Array
}
// return the Array of Event Handlers already added
return this._eventList[eventName];
},
addEventListener: function (eventName, handler) {
// Get the Array of Event Handlers
var evt = this._getEvent(eventName, true);
// Add the new Event Handler to the Array
evt.push(handler);
},
removeEventListener: function (eventName, handler) {
// Get the Array of Event Handlers
var evt = this._getEvent(eventName);
if (!evt) {
return;
}
// Helper Method - an Array.indexOf equivalent
var getArrayIndex = function (array, item) {
for (var i = 0; i < array.length; i++) {
if (array[i] === item) {
return i;
}
}
return -1;
};
// Get the Array index of the Event Handler
var index = getArrayIndex(evt, handler);
if (index > -1) {
// Remove Event Handler from Array
evt.splice(index, 1);
}
},
removeAllEventListeners: function (eventName) {
// Get the Array of Event Handlers
var evt = this._getEvent(eventName);
if (!evt) {
return;
}
evt.splice(0, evt.length);
},
dispatchEvent: function (eventName, eventArgs) {
// Get a function that will call all the Event Handlers internally
var handler = this._getEventHandler(eventName);
if (handler) {
// call the handler function
// Pass in "sender" and "eventArgs" parameters
handler(this, eventArgs);
}
},
clearAllEvents: function () {
this._eventList = {};
},
_getEventHandler: function (eventName) {
// Get Event Handler Array for this Event
var evt = this._getEvent(eventName, false);
if (!evt || evt.length === 0) {
return null;
}
// Create the Handler method that will use currying to
// call all the Events Handlers internally
var h = function (sender, args) {
for (var i = 0; i < evt.length; i++) {
evt[i](sender, args);
}
};
// Return this new Handler method
return h;
}
};
return EventDispatcher;
});
/*globals define*/
/*eslint-env node, browser*/
/**
* Provides watching-functionality of the database and specific projects.
* Keeps a state of the registered watchers.
*
* @author pmeijer / https://github.com/pmeijer
*/
define('common/storage/storageclasses/watchers',[
'q',
'webgme-ot',
'common/storage/constants',
'common/util/guid',
'common/EventDispatcher'
], function (Q, ot, CONSTANTS, GUID, EventDispatcher) {
'use strict';
function StorageWatcher(webSocket, logger, gmeConfig) {
EventDispatcher.call(this);
// watcher counters determining when to join/leave a room on the sever
this.watchers = {
sessionId: GUID(), // Need at reconnect since socket.id changes.
database: 0,
projects: {},
documents: {}
};
this.webSocket = webSocket;
this.logger = this.logger || logger.fork('storage');
this.gmeConfig = gmeConfig;
this.logger.debug('StorageWatcher ctor');
this.connected = false;
}
// Inherit from the EventDispatcher
StorageWatcher.prototype = Object.create(EventDispatcher.prototype);
StorageWatcher.prototype.constructor = StorageWatcher;
function _splitDocId(docId) {
var pieces = docId.split(CONSTANTS.ROOM_DIVIDER);
return {
projectId: pieces[0],
branchName: pieces[1],
nodeId: pieces[2],
attrName: pieces[3]
};
}
StorageWatcher.prototype.watchDatabase = function (eventHandler, callback) {
this.logger.debug('watchDatabase - handler added');
this.webSocket.addEventListener(CONSTANTS.PROJECT_DELETED, eventHandler);
this.webSocket.addEventListener(CONSTANTS.PROJECT_CREATED, eventHandler);
this.watchers.database += 1;
this.logger.debug('Nbr of database watchers:', this.watchers.database);
if (this.watchers.database === 1) {
this.logger.debug('First watcher will enter database room.');
return this.webSocket.watchDatabase({join: true}).nodeify(callback);
} else {
return Q().nodeify(callback);
}
};
StorageWatcher.prototype.unwatchDatabase = function (eventHandler, callback) {
var deferred = Q.defer();
this.logger.debug('unwatchDatabase - handler will be removed');
this.logger.debug('Nbr of database watchers (before removal):', this.watchers.database);
this.webSocket.removeEventListener(CONSTANTS.PROJECT_DELETED, eventHandler);
this.webSocket.removeEventListener(CONSTANTS.PROJECT_CREATED, eventHandler);
this.watchers.database -= 1;
if (this.watchers.database === 0) {
this.logger.debug('No more watchers will exit database room.');
if (this.connected) {
this.webSocket.watchDatabase({join: false})
.then(deferred.resolve)
.catch(deferred.reject);
} else {
deferred.resolve();
}
} else if (this.watchers.database < 0) {
this.logger.error('Number of database watchers became negative!');
deferred.reject(new Error('Number of database watchers became negative!'));
} else {
deferred.resolve();
}
return deferred.promise.nodeify(callback);
};
StorageWatcher.prototype.watchProject = function (projectId, eventHandler, callback) {
this.logger.debug('watchProject - handler added for project', projectId);
this.webSocket.addEventListener(CONSTANTS.BRANCH_DELETED + projectId, eventHandler);
this.webSocket.addEventListener(CONSTANTS.BRANCH_CREATED + projectId, eventHandler);
this.webSocket.addEventListener(CONSTANTS.BRANCH_HASH_UPDATED + projectId, eventHandler);
this.watchers.projects[projectId] = Object.hasOwn(this.watchers.projects, projectId) ?
this.watchers.projects[projectId] + 1 : 1;
this.logger.debug('Nbr of watchers for project:', projectId, this.watchers.projects[projectId]);
if (this.watchers.projects[projectId] === 1) {
this.logger.debug('First watcher will enter project room:', projectId);
this.webSocket.watchProject({projectId: projectId, join: true})
.nodeify(callback);
} else {
return Q().nodeify(callback);
}
};
StorageWatcher.prototype.unwatchProject = function (projectId, eventHandler, callback) {
var deferred = Q.defer();
this.logger.debug('unwatchProject - handler will be removed', projectId);
this.logger.debug('Nbr of database watchers (before removal):', projectId,
this.watchers.projects[projectId]);
this.webSocket.removeEventListener(CONSTANTS.BRANCH_DELETED + projectId, eventHandler);
this.webSocket.removeEventListener(CONSTANTS.BRANCH_CREATED + projectId, eventHandler);
this.webSocket.removeEventListener(CONSTANTS.BRANCH_HASH_UPDATED + projectId, eventHandler);
this.watchers.projects[projectId] = Object.hasOwn(this.watchers.projects, projectId) ?
this.watchers.projects[projectId] - 1 : -1;
if (this.watchers.projects[projectId] === 0) {
this.logger.debug('No more watchers will exit project room:', projectId);
delete this.watchers.projects[projectId];
if (this.connected) {
this.webSocket.watchProject({projectId: projectId, join: false})
.then(deferred.resolve)
.catch(deferred.reject);
} else {
deferred.resolve();
}
} else if (this.watchers.projects[projectId] < 0) {
this.logger.error('Number of project watchers became negative!:', projectId);
deferred.reject(new Error('Number of project watchers became negative!'));
} else {
deferred.resolve();
}
return deferred.promise.nodeify(callback);
};
/**
* Start watching the document at the provided context.
* @param {object} data
* @param {string} data.projectId
* @param {string} data.branchName
* @param {string} data.nodeId
* @param {string} data.attrName
* @param {string} data.attrValue - If the first client entering the document the value will be used
* @param {function} atOperation - Triggered when other clients made changes
* @param {ot.Operation} atOperation.operation - Triggered when other clients' operations were applied
* @param {function} atSelection - Triggered when other clients send their selection info
* @param {object} atSelection.data
* @param {ot.Selection | null} atSelection.data.selection - null is passed when other client leaves
* @param {string} atSelection.data.userId - name/id of other user
* @param {string} atSelection.data.socketId - unique id of other user
* @param {function} [callback]
* @param {Error | null} callback.err - If failed to watch the document
* @param {object} callback.data
* @param {string} callback.data.docId - Id of document
* @param {string} callback.data.document - Current document on server
* @param {number} callback.data.revision - Revision at server when connecting
* @param {object} callback.data.users - Users that were connected when connecting
* @returns {Promise}
*/
StorageWatcher.prototype.watchDocument = function (data, atOperation, atSelection, callback) {
var self = this,
docUpdateEventName = this.webSocket.getDocumentUpdatedEventName(data),
docSelectionEventName = this.webSocket.getDocumentSelectionEventName(data),
docId = docUpdateEventName.substring(CONSTANTS.DOCUMENT_OPERATION.length),
watcherId = GUID();
data = JSON.parse(JSON.stringify(data));
this.logger.debug('watchDocument - handler added for project', data);
this.watchers.documents[docId] = this.watchers.documents[docId] || {};
this.watchers.documents[docId][watcherId] = {
eventHandler: function (_ws, eData) {
var otClient = self.watchers.documents[eData.docId][watcherId].otClient;
self.logger.debug('eventHandler for document', {metadata: eData});
if (eData.watcherId === watcherId) {
self.logger.info('event from same watcher, skipping...');
return;
}
if (eData.operation) {
if (self.reconnecting) {
// We are reconnecting.. Put these on the queue.
self.watchers.documents[docId][watcherId].applyBuffer.push(eData);
} else {
otClient.applyServer(ot.TextOperation.fromJSON(eData.operation));
}
}
if (Object.hasOwn(eData, 'selection') && !self.reconnecting) {
atSelection({
selection: eData.selection ?
otClient.transformSelection(ot.Selection.fromJSON(eData.selection)) : null,
socketId: eData.socketId,
userId: eData.userId
});
}
},
applyBuffer: [],
awaitingAck: null
};
this.webSocket.addEventListener(docUpdateEventName, this.watchers.documents[docId][watcherId].eventHandler);
this.webSocket.addEventListener(docSelectionEventName, this.watchers.documents[docId][watcherId].eventHandler);
data.join = true;
data.sessionId = this.watchers.sessionId;
data.watcherId = watcherId;
return this.webSocket.watchDocument(data)
.then(function (initData) {
self.watchers.documents[initData.docId][watcherId].otClient = new ot.Client(initData.revision);
self.watchers.documents[initData.docId][watcherId].otClient.sendOperation =
function (revision, operation) {
var sendData = {
docId: initData.docId,
projectId: initData.projectId,
branchName: initData.branchName,
revision: revision,
operation: operation,
selection: self.watchers.documents[initData.docId][watcherId].selection,
sessionId: self.watchers.sessionId,
watcherId: watcherId
};
self.watchers.documents[initData.docId][watcherId].awaitingAck = {
revision: revision,
operation: operation
};
self.webSocket.sendDocumentOperation(sendData, function (err) {
if (err) {
self.logger.error('Failed to sendDocument', err);
return;
}
if (Object.hasOwn(self.watchers.documents, initData.docId) &&
Object.hasOwn(self.watchers.documents[initData.docId], watcherId)) {
self.watchers.documents[initData.docId][watcherId].awaitingAck = null;
self.watchers.documents[initData.docId][watcherId].otClient.serverAck(revision);
} else {
self.logger.error(new Error('Received document acknowledgement ' +
'after watcher left document ' + initData.docId));
}
});
};
self.watchers.documents[initData.docId][watcherId].otClient.applyOperation = atOperation;
return initData;
})
.nodeify(callback);
};
/**
* Stop watching the document.
* @param {object} data
* @param {string} data.docId - document id, if not provided projectId, branchName, nodeId, attrName must be.
* @param {string} data.watcherId
* @param {string} [data.projectId]
* @param {string} [data.branchName]
* @param {string} [data.nodeId]
* @param {string} [data.attrName]
* @param {function} [callback]
* @param {Error | null} callback.err - If failed to unwatch the document
* @returns {Promise}
*/
StorageWatcher.prototype.unwatchDocument = function (data, callback) {
var deferred = Q.defer(),
docUpdateEventName = this.webSocket.getDocumentUpdatedEventName(data),
docSelectionEventName = this.webSocket.getDocumentSelectionEventName(data),
pieces;
if (typeof data.docId === 'string') {
pieces = _splitDocId(data.docId);
Object.keys(pieces)
.forEach(function (key) {
data[key] = pieces[key];
});
} else {
data.docId = docUpdateEventName.substring(CONSTANTS.DOCUMENT_OPERATION.length);
}
if (typeof data.watcherId !== 'string') {
deferred.reject(new Error('data.watcherId not provided - use the one given at watchDocument.'));
} else if (Object.hasOwn(this.watchers.documents, data.docId) === false ||
Object.hasOwn(this.watchers.documents[data.docId], data.watcherId) === false) {
deferred.reject(new Error('Document is not being watched ' + data.docId +
' by watcherId [' + data.watcherId + ']'));
} else {
// Remove handler from web-socket module.
this.webSocket.removeEventListener(docUpdateEventName,
this.watchers.documents[data.docId][data.watcherId].eventHandler);
this.webSocket.removeEventListener(docSelectionEventName,
this.watchers.documents[data.docId][data.watcherId].eventHandler);
// "Remove" handlers attached to the otClient.
this.watchers.documents[data.docId][data.watcherId].otClient.sendOperation =
this.watchers.documents[data.docId][data.watcherId].otClient.applyOperation = function () {
};
delete this.watchers.documents[data.docId][data.watcherId];
if (Object.keys(this.watchers.documents[data.docId]).length === 0) {
delete this.watchers.documents[data.docId];
}
// Finally exit socket.io room on server if connected.
if (this.connected) {
data.join = false;
this.webSocket.watchDocument(data)
.then(deferred.resolve)
.catch(deferred.reject);
} else {
deferred.resolve();
}
}
return deferred.promise.nodeify(callback);
};
/**
* Send operation made, and optionally selection, on document at docId.
* @param {object} data
* @param {string} data.docId
* @param {string} data.watcherId
* @param {ot.TextOperation} data.operation
* @param {ot.Selection} [data.selection]
*/
StorageWatcher.prototype.sendDocumentOperation = function (data) {
// TODO: Do we need to add a callback for confirmation here?
if (typeof data.watcherId !== 'string') {
throw new Error('data.watcherId not provided - use the one given at watchDocument.');
} else if (Object.hasOwn(this.watchers.documents, data.docId) &&
Object.hasOwn(this.watchers.documents[data.docId], data.watcherId) &&
this.watchers.documents[data.docId][data.watcherId].otClient instanceof ot.Client) {
this.watchers.documents[data.docId][data.watcherId].selection = data.selection;
this.watchers.documents[data.docId][data.watcherId].otClient.applyClient(data.operation);
} else {
throw new Error('Document not being watched ' + data.docId +
'. (If "watchDocument" was initiated make sure to wait for the callback.)');
}
};
/**
* Send selection on docu