UNPKG

webgme

Version:

Web-based Generic Modeling Environment

1,358 lines (1,172 loc) 4.62 MB
/*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