diff --git a/README-roundcube.md b/README-roundcube.md index 048c87a..6f2dfdf 100644 --- a/README-roundcube.md +++ b/README-roundcube.md @@ -1,143 +1,144 @@ # Integrating into Kolab ## Configuration Set the environment variables thusly: 1. Set `AUTH` to `"ldap"` 2. Set `STORAGE` to `"chwala"` 3. Set `CHWALA_SERVER` to Chwala's file API endpoint for `GET`ing and `PUT`ing documents. 4. Change `LDAP_` entries to match the Kolab Configuration ## Server API ### Auth `POST /auth/local` with the payload ```json { "email":"some@user.com", "password":"their_password" } ``` will give you this response: ```json { "token": "" } ``` Make all further requests with the header `Authorization: Bearer `. ### Documents 1. `GET /api/documents/` returns an array of documents that have been loaded in Manticore and have been created/edited by the requesting user. 2. `GET /api/documents/:id` returns a document with the given ID. 3. `POST /api/documents/` is a request containing three fields: `id`, `title` which can be the filename, and `access` which is an _array_ of access control entries as shown in the example document. Manticore will fetch the actual file from Chwala using the provided `id`. Normally you should initialize the access array with an entry containing the creator of the session even if no one else is collaborating. 4. `PUT /api/documents/:id` lets you overwrite a document with a new one, while keeping the ID. This request can have an empty body, Manticore will take care of fetching the file like above. 5. `GET /api/documents/:id/access` returns the access control array for a document. 5. `PUT /api/documents/:id/access` lets you update the access policy. 5. `DELETE /api/documents/:uuid` deletes the document from Manticore and ends it's corresponding session. A document is of the form: ```json { "_id": "", "title": "Project Report", "created": "2015-10-16T14:30:43.651Z", "date": "", "creator": { "name": "Administrator", "email": "admin@admin.com", }, "editors": [{ "name": "Administrator", "email": "admin@admin.com", }], "access": [{ "identity": "admin@admin.com", "permission": "write" }, { "identity": "test@user.com", "permission": "read" }, { "identity": "another@user.com", "permission": "deny" }], "live": true } ``` An access array is of the form: ```json "access": [{ "identity": "admin@admin.com", "permission": "write" }, { "identity": "test@user.com", "permission": "read" }, { "identity": "another@user.com", "permission": "deny" }], ``` ##Client API ### Open When a logged-in roundcube uesr tries to open a file, Chwala should immediately equip the Manticore auth token for that user, and open up an `iframe` pointed to this location: `http://manticore_server:port/document/:document_id/:auth_token` which will contain a ready-to-use collaborative session. ### Control When embedded in Roundcube, Manticore does not draw it's own toolbar, but provides a cross-iframe API instead that lets you do the same things that the Manticore toolbar can. For controlling Manticore from Roundcube's js, the `postMessage` API's usage can be demonstrated with this example: ```js var domain = "http://manticore_server:port", manticore = iframeElement.contentWindow; manticore.postMessage({ id: 1234, name: "actionExport", format: "pdf" }, domain); window.addEventListener("message", function (event) { console.log(event.data); /* Looks like * { id: 1234, successful: true } */ }); ``` Since `postMessage` was not designed for remote function calls, the `id` property is useful for knowing which outgoing message (here, `"actionExport"`) the incoming message is a response to. Roundcube can trivially add a callback abstraction on top of this. #### Methods The following methods are available, prefixed with `get`, `set`, and `action`: | Method | Response | |--------|----------| |`{name: "getTitle"}`|`{value: "Current Title"}`| |`{name: "setTitle", value: "New Title"}`|`{successful: true}`| |`{name: "getExportFormats"}`|`{value: [{ format: "odt", label: "OpenDocument Text (.odt)" }]}`| |`{name: "actionExport", value: "pdf"}`|`{successful: true}`| |`{name: "actionSave"}`|`{successful: true}`| |`{name: "getMembers"}`|`{value: [ {memberId: "user_234", fullName: "John Doe", color: "#ffee00", email: "john@doe.org"} ]}`| #### Events The following events are available, suffixed with `Event`. They come with no `id` field. 1. `{name: "ready"}` fires when all iframe API methods are ready for use. 2. `{name: "titleChanged", value: "New Title"}` 3. `{name: "memberAdded", memberId: "user_234", fullName: "John Doe", color: "#ffee00", email: "john@doe.org"}` 4. `{name: "memberRemoved", memberId: "user_234"}` +5. `{name: "documentChanged" }` is useful for managing the state of a "save" button. diff --git a/client/components/saveButton/saveButton.controller.js b/client/components/saveButton/saveButton.controller.js index 55defce..3c16b2b 100644 --- a/client/components/saveButton/saveButton.controller.js +++ b/client/components/saveButton/saveButton.controller.js @@ -1,51 +1,62 @@ 'use strict'; angular.module('manticoreApp') .controller('SaveButtonCtrl', function ($scope, $timeout) { $scope.label = 'Save'; $scope.isSaving = false; + $scope.isModified = false; /** * @param {Function=} cb Optional callback that provides success value (true/false) */ $scope.save = function (cb) { $scope.label = 'Saving'; $scope.isSaving = true; var socket = $scope.editor.clientAdaptor.getSocket(); socket.emit('save', function (err) { $timeout(function () { if (err) { $scope.label = 'Error while saving'; if (cb) { cb(false); } } else { $scope.label = 'Saved just now'; + $scope.isModified = false; if (cb) { cb(true); } } }); $timeout(function () { $scope.label = 'Save'; $scope.isSaving = false; }, 1000); }); }; function iframeActionSave(event) { $scope.save(function (successful) { event.source.postMessage({ id: event.data.id, successful: successful }, event.origin); }); } + function handleDocumentChanged() { + $timeout(function () { + $scope.isModified = true; + $scope.editor.broadcastIframeEvent({ name: 'documentChanged' }); + }); + } + $scope.$watch('joined', function (online) { if (online === undefined) { return; } if (online) { $scope.editor.addIframeEventListener('actionSave', iframeActionSave); + $scope.operationRouter.subscribe('documentChanged', handleDocumentChanged); } else { $scope.editor.removeIframeEventListener('actionSave', iframeActionSave); + $scope.operationRouter.unsubscribe('documentChanged', handleDocumentChanged); } }); }); diff --git a/client/components/saveButton/saveButton.jade b/client/components/saveButton/saveButton.jade index d4c3aa6..ec800f2 100644 --- a/client/components/saveButton/saveButton.jade +++ b/client/components/saveButton/saveButton.jade @@ -1,4 +1,4 @@ div.save-button - button.btn.btn-primary(type='button' ng-click='save()' ng-disabled='isSaving') + button.btn.btn-primary(type='button' ng-click='save()' ng-disabled='isSaving || !isModified') i.fa(ng-class='isSaving ? "fa-spin fa-circle-o-notch": "fa-cloud-upload"') | {{label}} diff --git a/client/components/wodo/adaptor.service.js b/client/components/wodo/adaptor.service.js index 127f345..1ea0a82 100644 --- a/client/components/wodo/adaptor.service.js +++ b/client/components/wodo/adaptor.service.js @@ -1,329 +1,335 @@ /*jslint unparam: true*/ /*global runtime, core, ops, io*/ 'use strict'; angular.module('manticoreApp') .factory('Adaptor', function () { var OperationRouter = function (socket, odfContainer, errorCb) { var EVENT_BEFORESAVETOFILE = 'beforeSaveToFile', EVENT_SAVEDTOFILE = 'savedToFile', EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED = 'hasLocalUnsyncedOperationsChanged', EVENT_HASSESSIONHOSTCONNECTIONCHANGED = 'hasSessionHostConnectionChanged', EVENT_MEMBERADDED = 'memberAdded', EVENT_MEMBERCHANGED = 'memberChanged', EVENT_MEMBERREMOVED = 'memberRemoved', + EVENT_DOCUMENTCHANGED = 'documentChanged', eventNotifier = new core.EventNotifier([ EVENT_BEFORESAVETOFILE, EVENT_SAVEDTOFILE, EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, EVENT_HASSESSIONHOSTCONNECTIONCHANGED, EVENT_MEMBERADDED, EVENT_MEMBERCHANGED, EVENT_MEMBERREMOVED, + EVENT_DOCUMENTCHANGED = 'documentChanged', ops.OperationRouter.signalProcessingBatchStart, ops.OperationRouter.signalProcessingBatchEnd ]), operationFactory, playbackFunction, lastServerSyncHeadId = 0, sendClientOpspecsLock = false, sendClientOpspecsTask, hasSessionHostConnection = true, unplayedServerOpSpecQueue = [], unsyncedClientOpSpecQueue = [], operationTransformer = new ops.OperationTransformer(), /**@const*/sendClientOpspecsDelay = 300, members = []; function playbackOpspecs(opspecs) { var op, i; if (!opspecs.length) { return; } eventNotifier.emit(ops.OperationRouter.signalProcessingBatchStart, {}); for (i = 0; i < opspecs.length; i += 1) { op = operationFactory.create(opspecs[i]); if (op !== null) { if (!playbackFunction(op)) { eventNotifier.emit(ops.OperationRouter.signalProcessingBatchEnd, {}); errorCb('opExecutionFailure'); return; } else { var spec = op.spec(); if (spec.optype === 'AddMember') { var data = { memberId: spec.memberid, fullName: spec.setProperties.fullName, email: spec.setProperties.email, color: spec.setProperties.color }; members.push(data); eventNotifier.emit(EVENT_MEMBERADDED, data); } else if (spec.optype === 'RemoveMember') { _.remove(members, function (member) { return member.memberId === spec.memberid; }); eventNotifier.emit(EVENT_MEMBERREMOVED, { memberId: spec.memberid }); } + + if (op.isEdit) { + eventNotifier.emit(EVENT_DOCUMENTCHANGED); + } } } else { eventNotifier.emit(ops.OperationRouter.signalProcessingBatchEnd, {}); errorCb('Unknown opspec: ' + runtime.toJson(opspecs[i])); return; } } eventNotifier.emit(ops.OperationRouter.signalProcessingBatchEnd, {}); } function handleNewServerOpsWithUnsyncedClientOps(serverOps) { var transformResult = operationTransformer.transform(unsyncedClientOpSpecQueue, serverOps); if (!transformResult) { errorCb('Has unresolvable conflict.'); return false; } unsyncedClientOpSpecQueue = transformResult.opSpecsA; unplayedServerOpSpecQueue = unplayedServerOpSpecQueue.concat(transformResult.opSpecsB); return true; } function handleNewClientOpsWithUnplayedServerOps(clientOps) { var transformResult = operationTransformer.transform(clientOps, unplayedServerOpSpecQueue); if (!transformResult) { errorCb('Has unresolvable conflict.'); return false; } unsyncedClientOpSpecQueue = unsyncedClientOpSpecQueue.concat(transformResult.opSpecsA); unplayedServerOpSpecQueue = transformResult.opSpecsB; return true; } function receiveServerOpspecs(headId, serverOpspecs) { if (unsyncedClientOpSpecQueue.length > 0) { handleNewServerOpsWithUnsyncedClientOps(serverOpspecs); // could happen that ops from server make client ops obsolete if (unsyncedClientOpSpecQueue.length === 0) { eventNotifier.emit(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, false); } } else { // apply directly playbackOpspecs(serverOpspecs); } lastServerSyncHeadId = headId; } function sendClientOpspecs() { var originalUnsyncedLength = unsyncedClientOpSpecQueue.length; if (originalUnsyncedLength) { sendClientOpspecsLock = true; socket.emit('commit_ops', { head: lastServerSyncHeadId, ops: unsyncedClientOpSpecQueue }, function (response) { if (response.conflict === true) { sendClientOpspecs(); } else { lastServerSyncHeadId = response.head; // on success no other server ops should have sneaked in meanwhile, so no need to check // got no other client ops meanwhile? if (unsyncedClientOpSpecQueue.length === originalUnsyncedLength) { unsyncedClientOpSpecQueue.length = 0; // finally apply all server ops collected while waiting for sync playbackOpspecs(unplayedServerOpSpecQueue); unplayedServerOpSpecQueue.length = 0; eventNotifier.emit(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, false); sendClientOpspecsLock = false; } else { // send off the new client ops directly unsyncedClientOpSpecQueue.splice(0, originalUnsyncedLength); sendClientOpspecs(); } } }); } } this.setOperationFactory = function (f) { operationFactory = f; }; this.setPlaybackFunction = function (f) { playbackFunction = f; }; this.push = function (operations) { var clientOpspecs = [], now = Date.now(), hasLocalUnsyncedOpsBefore = (unsyncedClientOpSpecQueue.length !== 0), hasLocalUnsyncedOpsNow; operations.forEach(function(op) { var opspec = op.spec(); opspec.timestamp = now; clientOpspecs.push(opspec); }); playbackOpspecs(clientOpspecs); if (unplayedServerOpSpecQueue.length > 0) { handleNewClientOpsWithUnplayedServerOps(clientOpspecs); } else { unsyncedClientOpSpecQueue = unsyncedClientOpSpecQueue.concat(clientOpspecs); } hasLocalUnsyncedOpsNow = (unsyncedClientOpSpecQueue.length !== 0); if (hasLocalUnsyncedOpsNow !== hasLocalUnsyncedOpsBefore) { eventNotifier.emit(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, hasLocalUnsyncedOpsNow); } sendClientOpspecsTask.trigger(); }; this.requestReplay = function (cb) { var cbOnce = function () { eventNotifier.unsubscribe(ops.OperationRouter.signalProcessingBatchEnd, cbOnce); cb(); }; // hack: relies on at least addmember op being added for ourselves and being executed eventNotifier.subscribe(ops.OperationRouter.signalProcessingBatchEnd, cbOnce); socket.emit('replay', {}); }; this.close = function (cb) { cb(); }; this.subscribe = function (eventId, cb) { eventNotifier.subscribe(eventId, cb); }; this.unsubscribe = function (eventId, cb) { eventNotifier.unsubscribe(eventId, cb); }; this.hasLocalUnsyncedOps = function () { return unsyncedClientOpSpecQueue.length !== 0; }; this.hasSessionHostConnection = function () { return hasSessionHostConnection; }; this.getMembers = function () { return members; }; function init() { var replayed = false, followupHead, followupOps = []; sendClientOpspecsTask = core.Task.createTimeoutTask(function () { if (!sendClientOpspecsLock) { sendClientOpspecs(); } }, sendClientOpspecsDelay); socket.on('replay', function (data) { receiveServerOpspecs(data.head, data.ops); replayed = true; if (followupHead && followupHead > data.head) { receiveServerOpspecs(followupHead, followupOps); } }); socket.on('new_ops', function (data) { if (replayed) { receiveServerOpspecs(data.head, data.ops); } else { followupHead = data.head; followupOps = followupOps.concat(data.ops); } }); } init(); }; var ClientAdaptor = function (documentId, authToken, connectedCb, kickedCb, disconnectedCb) { var self = this, memberId, genesisUrl, socket, router; this.getMemberId = function () { return memberId; }; this.getGenesisUrl = function () { return genesisUrl; }; this.createOperationRouter = function (odfContainer, errorCb) { runtime.assert(Boolean(memberId), 'You must be connected to a session before creating an operation router'); router = new OperationRouter(socket, odfContainer, errorCb); return router; }; this.getOperationRouter = function () { return router; }; this.joinSession = function (cb) { socket.on('join_success', function handleJoinSuccess(data) { socket.removeListener('join_success', handleJoinSuccess); memberId = data.memberId; genesisUrl = data.genesisUrl; cb(memberId, data.permission); }); socket.emit('join', { documentId: documentId }); }; this.leaveSession = function (cb) { socket.emit('leave', {}, cb); socket.removeAllListeners(); }; this.getSocket = function () { return socket; }; this.destroy = function () { socket.disconnect(); }; function init() { socket = io({ query: 'token=' + authToken, forceNew: true }); socket.on('connect', connectedCb); socket.on('kick', kickedCb); socket.on('disconnect', disconnectedCb); } init(); }; return ClientAdaptor; }); diff --git a/client/components/wodo/editor.controller.js b/client/components/wodo/editor.controller.js index 9560f0e..1e90882 100644 --- a/client/components/wodo/editor.controller.js +++ b/client/components/wodo/editor.controller.js @@ -1,195 +1,197 @@ 'use strict'; /*global Wodo*/ angular.module('manticoreApp') .controller('WodoCtrl', function ($scope, Auth, Adaptor) { var editorInstance, operationRouter, clientAdaptor, editorOptions = { collabEditingEnabled: true, unstableFeaturesEnabled: true, imageEditingEnabled: false, hyperlinkEditingEnabled: false }, onConnectCalled = false, listeners = {}, allowedOrigin; function addIframeEventListener(name, callback) { if (!listeners[name]) { listeners[name] = []; } listeners[name].push(callback); } function removeIframeEventListener(name, callback) { if (!listeners[name]) { return; } var index = listeners[name].indexOf(callback); if (index !== -1) { listeners[name].splice(index, 1); } } function broadcastIframeEvent(data) { if (allowedOrigin) { window.parent.postMessage(data, allowedOrigin); } } function setupCrossWindowMessaging() { var embedderHost = $scope.$root.config.embedderHost; if (embedderHost) { var temp = document.createElement('a'); temp.href = embedderHost; allowedOrigin = temp.protocol + '//' + temp.host; window.addEventListener('message', function (event) { if (event.origin !== allowedOrigin || !event.data.name) { return; } console.log('Received message from Embedder: ' + event.data.name); var subscribers = listeners[event.data.name]; if (subscribers && subscribers.length) { for(var i = 0; i < subscribers.length; i += 1) { subscribers[i](event); } } }); } } - function setupMemberAPI() { + function setupIframeAPI() { function handleMemberAdded(data) { $scope.editor.broadcastIframeEvent(_.merge({ name: 'memberAdded' }, data)); } function handleMemberRemoved(data) { $scope.editor.broadcastIframeEvent(_.merge({ name: 'memberRemoved' }, data)); } function getMembers(event) { event.source.postMessage({ id: event.data.id, value: operationRouter.getMembers() }, event.origin); } $scope.$watch('joined', function (online) { if (online === undefined) { return; } if (online) { + // Members operationRouter.subscribe('memberAdded', handleMemberAdded); operationRouter.subscribe('memberRemoved', handleMemberRemoved); $scope.editor.addIframeEventListener('getMembers', getMembers); } else { operationRouter.unsubscribe('memberAdded', handleMemberAdded); operationRouter.unsubscribe('memberRemoved', handleMemberRemoved); $scope.editor.removeIframeEventListener('getMembers', getMembers); } }); } function closeEditing() { editorInstance.leaveSession(function () { $scope.$apply(function () { $scope.joined = false; }); clientAdaptor.leaveSession(function () { console.log('Closed editing, left session.'); }); }); } function handleEditingError(error) { alert('Something went wrong!\n' + error); console.log(error); closeEditing(); } function openEditor(permission) { setupCrossWindowMessaging(); if (permission === 'write') { editorOptions.allFeaturesEnabled = true; editorOptions.reviewModeEnabled = false; } else { editorOptions.reviewModeEnabled = true; } Wodo.createCollabTextEditor('wodoContainer', editorOptions, function (err, editor) { editorInstance = editor; $scope.editor = editor; $scope.editor.addIframeEventListener = addIframeEventListener; $scope.editor.removeIframeEventListener = removeIframeEventListener; $scope.editor.broadcastIframeEvent = broadcastIframeEvent; editorInstance.clientAdaptor = clientAdaptor; editorInstance.addEventListener(Wodo.EVENT_UNKNOWNERROR, handleEditingError); editorInstance.joinSession(clientAdaptor, function () { operationRouter = clientAdaptor.getOperationRouter(); - setupMemberAPI(); + $scope.operationRouter = operationRouter; + setupIframeAPI(); $scope.$apply(function () { $scope.joined = true; }); $scope.editor.broadcastIframeEvent({ name: 'ready' }); }); }); } function boot() { clientAdaptor = new Adaptor( $scope.document._id, Auth.getToken(), function onConnect() { console.log('onConnect'); if (onConnectCalled) { console.log('Reconnecting not yet supported'); return; } onConnectCalled = true; clientAdaptor.joinSession(function (memberId, permission) { if (!memberId) { console.log('Could not join; memberId not received'); } else { console.log('Joined with memberId ' + memberId); openEditor(permission); } }); }, function onKick() { console.log('onKick'); closeEditing(); }, function onDisconnect() { console.log('onDisconnect'); } ); } function destroy (cb) { if (editorInstance) { closeEditing(); editorInstance.destroy(cb); } else { if (clientAdaptor) { clientAdaptor.leaveSession(); clientAdaptor.destroy(); cb(); } } } this.boot = boot; this.destroy = destroy; });