diff --git a/client/app/app.styl b/client/app/app.styl index 1293ce7..42fd458 100644 --- a/client/app/app.styl +++ b/client/app/app.styl @@ -1,61 +1,62 @@ @import "font-awesome/css/font-awesome.css" @import "bootstrap/dist/css/bootstrap.css" // // Bootstrap Fonts // @font-face font-family: 'Glyphicons Halflings' src: url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot') src: url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff') format('woff'), url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../bower_components/bootstrap/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); // // Font Awesome Fonts // @font-face font-family: 'FontAwesome' src: url('../bower_components/font-awesome/fonts/fontawesome-webfont.eot?v=4.1.0') src: url('../bower_components/font-awesome/fonts/fontawesome-webfont.eot?#iefix&v=4.1.0') format('embedded-opentype'), url('../bower_components/font-awesome/fonts/fontawesome-webfont.woff?v=4.1.0') format('woff'), url('../bower_components/font-awesome/fonts/fontawesome-webfont.ttf?v=4.1.0') format('truetype'), url('../bower_components/font-awesome/fonts/fontawesome-webfont.svg?v=4.1.0#fontawesomeregular') format('svg'); font-weight: normal font-style: normal // // App-wide Styles // .browsehappy background #ccc color #000 margin 0.2em 0 padding 0.2em 0 body background-color whitesmoke .text-strong font-weight bold // Component styles are injected through grunt // injector @import 'account/login/login.styl'; @import 'editor/editor.styl'; @import 'main/main.styl'; @import 'templates/templates.styl'; @import 'users/users.styl'; +@import 'createMenu/createMenu.styl'; @import 'documentList/documentList.styl'; @import 'exportButton/exportButton.styl'; @import 'import/import.styl'; @import 'labelEditor/labelEditor.styl'; @import 'modal/modal.styl'; @import 'navbar/navbar.styl'; @import 'titleEditor/titleEditor.styl'; @import 'wodo/editor.styl'; // endinjector diff --git a/client/app/editor/editor.js b/client/app/editor/editor.js index 0feb7e1..2503631 100644 --- a/client/app/editor/editor.js +++ b/client/app/editor/editor.js @@ -1,25 +1,41 @@ 'use strict'; angular.module('manticoreApp') .config(function ($stateProvider) { $stateProvider .state('editor', { - url: '/document/:id', + abstract: true, + url: '/document', reload: true, + template: '' + }) + .state('editor.forDocument', { + url: '/:id', resolve: { socketio: function (angularLoad) { return angularLoad.loadScript('socket.io/socket.io.js'); }, document: function ($stateParams, $http) { return $http.get('/api/documents/' + $stateParams.id) .then(function(response) { return response.data; }); } }, templateUrl: 'app/editor/editor.html', controller: function ($scope, document) { $scope.document = document; } + }) + .state('editor.fromTemplate', { + url: '/:id/new', + resolve: { + document: function ($stateParams, $state, $http) { + return $http.get('/api/documents/fromTemplate/' + $stateParams.id) + .then(function (response) { + $state.go('editor.forDocument', { id: response.data._id }, { location: 'replace' }); + }); + } + } }); }); diff --git a/client/app/templates/templates.controller.js b/client/app/templates/templates.controller.js index 8ba03af..cbe7575 100644 --- a/client/app/templates/templates.controller.js +++ b/client/app/templates/templates.controller.js @@ -1,43 +1,42 @@ 'use strict'; angular.module('manticoreApp') .controller('TemplatesCtrl', function ($scope, $http, Auth, FileUploader) { var uploader = new FileUploader({ url: '/api/templates/upload', headers: { 'Authorization': 'Bearer ' + Auth.getToken() }, removeAfterUpload: true, autoUpload: true, onCompleteAll: function () { // Wait a little before firing this event, as the upload may not // be accessible from MongoDB immediately window.setTimeout(function () { uploader.clearQueue(); refresh(); }, 1000); } }); $scope.uploader = uploader; $scope.update = function (template) { $http.put('/api/templates/' + template._id, template); }; $scope.delete = function(template) { $http.delete('/api/templates/' + template._id); angular.forEach($scope.templates, function(t, i) { if (t === template) { $scope.templates.splice(i, 1); } }); }; - $scope.$watch('templates') function refresh() { $http.get('/api/templates').success(function (templates) { $scope.templates = templates; }); } refresh(); }); diff --git a/client/components/createMenu/createMenu.controller.js b/client/components/createMenu/createMenu.controller.js new file mode 100644 index 0000000..787822c --- /dev/null +++ b/client/components/createMenu/createMenu.controller.js @@ -0,0 +1,11 @@ +'use strict'; + +angular.module('manticoreApp') +.controller('CreateMenuCtrl', function ($scope, $http) { + function refresh() { + $http.get('/api/templates').success(function (templates) { + $scope.templates = templates; + }); + } + refresh(); +}); diff --git a/client/components/createMenu/createMenu.directive.js b/client/components/createMenu/createMenu.directive.js new file mode 100644 index 0000000..6483a67 --- /dev/null +++ b/client/components/createMenu/createMenu.directive.js @@ -0,0 +1,11 @@ +'use strict'; + +angular.module('manticoreApp') + .directive('createMenu', function () { + return { + templateUrl: 'components/createMenu/createMenu.html', + restrict: 'E', + replace: true, + controller: 'CreateMenuCtrl' + }; + }); diff --git a/client/components/createMenu/createMenu.jade b/client/components/createMenu/createMenu.jade new file mode 100644 index 0000000..461d7b9 --- /dev/null +++ b/client/components/createMenu/createMenu.jade @@ -0,0 +1,7 @@ +ul.dropdown-menu.create-menu + li(ng-repeat='template in templates') + a(ui-sref='editor.fromTemplate({ id: template._id })' target='_blank') + span.text-strong {{template.title}} + br + div.text-muted {{template.description}} + diff --git a/client/components/createMenu/createMenu.styl b/client/components/createMenu/createMenu.styl new file mode 100644 index 0000000..c36c1ed --- /dev/null +++ b/client/components/createMenu/createMenu.styl @@ -0,0 +1,4 @@ +.create-menu + width 400px + li > a + white-space normal diff --git a/client/components/documentList/documentList.jade b/client/components/documentList/documentList.jade index 5064bf1..cd5712b 100644 --- a/client/components/documentList/documentList.jade +++ b/client/components/documentList/documentList.jade @@ -1,16 +1,16 @@ .container(ng-switch='documents.length === 0') .no-docs(ng-switch-when='true') p.advice No documents yet. Why not add some? .document-list(ng-switch-when='false') table.table.table-striped(st-table='displayedDocuments' st-safe-src='documents') thead tr th Title th Creator th(st-sort='date' st-sort-default='reverse') Updated tbody tr(ng-repeat='document in displayedDocuments') td.title - a(ui-sref='editor({id: document._id})' target='_blank') {{document.title}} + a(ui-sref='editor.forDocument({id: document._id})' target='_blank') {{document.title}} td {{document.creator.name}} td {{document.date | amCalendar}} diff --git a/client/components/navbar/navbar.jade b/client/components/navbar/navbar.jade index 9db98be..b2404e0 100644 --- a/client/components/navbar/navbar.jade +++ b/client/components/navbar/navbar.jade @@ -1,40 +1,46 @@ div.navbar.navbar-default.navbar-static-top(ng-controller='NavbarCtrl') div.container div.navbar-header button.navbar-toggle(type='button', ng-click='isCollapsed = !isCollapsed') span.sr-only Toggle navigation span.icon-bar span.icon-bar span.icon-bar - a.navbar-brand(href='/') manticore + a.nav-text.navbar-brand(href='/') manticore div#navbar-main.navbar-collapse.collapse(collapse='isCollapsed') ul.nav.navbar-nav li(ng-show='isAdmin()', ng-class='{active: isActive("/users")}') - a(ui-sref='users') Users + a.nav-text(ui-sref='users') Users li(ng-show='isAdmin()', ng-class='{active: isActive("/templates")}') - a(ui-sref='templates') Templates + a.nav-text(ui-sref='templates') Templates - li.dropdown(ng-show='isLoggedIn() && isActive("/")' dropdown auto-close='disabled') - a.dropdown-toggle(href='#' dropdown-toggle role='button') + li.dropdown(ng-if='isLoggedIn() && isActive("/")' dropdown) + a.nav-text.dropdown-toggle(href='#' dropdown-toggle role='button') + | New + span.caret + create-menu + + li.dropdown(ng-if='isLoggedIn() && isActive("/")' dropdown auto-close='disabled') + a.nav-text.dropdown-toggle(href='#' dropdown-toggle role='button') | Import span.caret div.dropdown-menu(ng-include='"components/import/import.html"') ul.nav.navbar-nav.navbar-right li(ng-hide='isLoggedIn()', ng-class='{active: isActive("/signup")}') - a(href='/signup') Sign up + a.nav-text(href='/signup') Sign up li(ng-hide='isLoggedIn()', ng-class='{active: isActive("/login")}') - a(href='/login') Login + a.nav-text(href='/login') Login li(ng-show='isLoggedIn()') - p.navbar-text Hello {{ getCurrentUser().name }} + p.nav-text.navbar-text Hello {{ getCurrentUser().name }} li(ng-show='isLoggedIn()', ng-class='{active: isActive("/settings")}') - a(href='/settings') + a.nav-text(href='/settings') span.glyphicon.glyphicon-cog li(ng-show='isLoggedIn()', ng-class='{active: isActive("/logout")}') - a(href='', ng-click='logout()') Logout + a.nav-text(href='', ng-click='logout()') Logout diff --git a/client/components/navbar/navbar.styl b/client/components/navbar/navbar.styl index 6dd3e38..a3a0bcd 100644 --- a/client/components/navbar/navbar.styl +++ b/client/components/navbar/navbar.styl @@ -1,12 +1,11 @@ .navbar-default background-color maroon box-shadow 0 0px 2px 0px maroon border none .navbar user-select none - & p, & a + & .nav-text color white !important - & li.active, & li.open - a - background-color #4C1313 !important + & li.active > a, & li.dropdown.open > a + background-color #4C1313 !important diff --git a/client/components/wodo/adaptor.service.js b/client/components/wodo/adaptor.service.js index 5684769..af73880 100644 --- a/client/components/wodo/adaptor.service.js +++ b/client/components/wodo/adaptor.service.js @@ -1,283 +1,285 @@ /*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', eventNotifier = new core.EventNotifier([ EVENT_BEFORESAVETOFILE, EVENT_SAVEDTOFILE, EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, EVENT_HASSESSIONHOSTCONNECTIONCHANGED, EVENT_MEMBERADDED, EVENT_MEMBERCHANGED, EVENT_MEMBERREMOVED, ops.OperationRouter.signalProcessingBatchStart, ops.OperationRouter.signalProcessingBatchEnd ]), operationFactory, playbackFunction, lastServerSyncHeadId = 0, sendClientOpspecsLock = false, sendClientOpspecsTask, hasSessionHostConnection = true, unplayedServerOpSpecQueue = [], unsyncedClientOpSpecQueue = [], operationTransformer = new ops.OperationTransformer(), /**@const*/sendClientOpspecsDelay = 300; 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 { 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; }; function init() { sendClientOpspecsTask = core.Task.createTimeoutTask(function () { if (!sendClientOpspecsLock) { sendClientOpspecs(); } }, sendClientOpspecsDelay); socket.on('replay', function (data) { receiveServerOpspecs(data.head, data.ops); socket.on('new_ops', function (data) { receiveServerOpspecs(data.head, data.ops); }); }); } init(); }; - var ClientAdaptor = function (documentId, documentURL, authToken, connectedCb, kickedCb, disconnectedCb) { + var ClientAdaptor = function (documentId, authToken, connectedCb, kickedCb, disconnectedCb) { var self = this, memberId, + documentUrl, socket; this.getMemberId = function () { return memberId; }; this.getGenesisUrl = function () { - return documentURL; + return documentUrl; }; this.createOperationRouter = function (odfContainer, errorCb) { runtime.assert(Boolean(memberId), 'You must be connected to a session before creating an operation router'); return new OperationRouter(socket, odfContainer, errorCb); }; this.joinSession = function (cb) { socket.on('join_success', function handleJoinSuccess(data) { socket.removeListener('join_success', handleJoinSuccess); memberId = data.memberId; + documentUrl = '/api/documents/snapshot/' + data.snapshotId; cb(memberId); }); 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 d508dd2..08ccede 100644 --- a/client/components/wodo/editor.controller.js +++ b/client/components/wodo/editor.controller.js @@ -1,92 +1,91 @@ 'use strict'; /*global Wodo*/ angular.module('manticoreApp') .controller('WodoCtrl', function ($scope, Auth, Adaptor) { var editorInstance, clientAdaptor, editorOptions = { collabEditingEnabled: true, allFeaturesEnabled: true }, onConnectCalled = false; 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() { Wodo.createCollabTextEditor('wodoContainer', editorOptions, function (err, editor) { editorInstance = editor; $scope.editor = editor; editorInstance.addEventListener(Wodo.EVENT_UNKNOWNERROR, handleEditingError); editorInstance.joinSession(clientAdaptor, function () { $scope.$apply(function () { $scope.joined = true; }); }); }); } function boot() { clientAdaptor = new Adaptor( $scope.document._id, - '/api/documents/snapshot/' + _.last($scope.document.chunks), Auth.getToken(), function onConnect() { console.log('onConnect'); if (onConnectCalled) { console.log('Reconnecting not yet supported'); return; } onConnectCalled = true; clientAdaptor.joinSession(function (memberId) { if (!memberId) { console.log('Could not join; memberId not received'); } else { console.log('Joined with memberId ' + memberId); openEditor(); } }); }, 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; }); diff --git a/client/index.html b/client/index.html index 2808f8e..ef0e933 100644 --- a/client/index.html +++ b/client/index.html @@ -1,90 +1,92 @@
+ + diff --git a/server/api/document/document.controller.js b/server/api/document/document.controller.js index 2ac0c58..817e54f 100644 --- a/server/api/document/document.controller.js +++ b/server/api/document/document.controller.js @@ -1,141 +1,171 @@ /** * Using Rails-like standard naming convention for endpoints. * GET /documents -> index * POST /documents -> create * GET /documents/:id -> show * PUT /documents/:id -> update * DELETE /documents/:id -> destroy */ 'use strict'; var _ = require('lodash'); var mongoose = require('mongoose'); var Grid = require('gridfs-stream'); var multer = require('multer'); var DocumentChunk = require('./document.model').DocumentChunk; var Document = require('./document.model').Document; +var Template = require('../template/template.model'); var gfs = Grid(mongoose.connection.db, mongoose.mongo); // Get list of documents exports.index = function(req, res) { Document.find().populate('creator').exec(function (err, documents) { if(err) { return handleError(res, err); } return res.json(200, documents); }); }; // Get a single document exports.show = function(req, res) { Document.findById(req.params.id, function (err, document) { if(err) { return handleError(res, err); } if(!document) { return res.send(404); } return res.json(document); }); }; exports.upload = function (req, res, next) { multer({ upload: null, limits: { fileSize: 1024 * 1024 * 20, // 20 Megabytes files: 5 }, onFileUploadStart: function (file) { - var chunkId = new mongoose.Types.ObjectId(); + var chunkId = new mongoose.Types.ObjectId(), + fileId = new mongoose.Types.ObjectId(); var firstChunk = new DocumentChunk({ - _id: chunkId + _id: chunkId, + fileId: fileId }); var newDocument = new Document({ title: file.originalname, creator: req.user._id, chunks: [chunkId] }); this.upload = gfs.createWriteStream({ - _id: chunkId, + _id: fileId, filename: file.originalname, mode: 'w', chunkSize: 1024 * 4, content_type: file.mimetype, root: 'fs' }); this.upload.on('finish', function () { firstChunk.save(function (err) { if (!err) { newDocument.save(); } }); }); }, onFileUploadData: function (file, data) { this.upload.write(data); }, onFileUploadComplete: function (file) { this.upload.end(); } })(req, res, next); }; exports.acknowledgeUpload = function (req, res) { return res.send(200); }; exports.showSnapshot = function(req, res) { var snapshotId = req.params.id; gfs.findOne({_id: snapshotId}, function (err, file) { if (err) { return handleError(res, err); } if (!file) { return res.send(404); } var download = gfs.createReadStream({ _id: snapshotId }); download.on('error', function (err) { return handleError(res, err); }); res.set('Content-Type', file.contentType); res.attachment(file.filename) download.pipe(res); }); }; // Creates a new document in the DB. exports.create = function(req, res) { Document.create(req.body, function(err, document) { if(err) { return handleError(res, err); } return res.json(201, document); }); }; +exports.createFromTemplate = function (req, res) { + Template.findById(req.params.id, function (err, template) { + if (err) { return handleError(res, err); } + if (!template) { return res.send(404); } + + var chunkId = new mongoose.Types.ObjectId(); + + var firstChunk = new DocumentChunk({ + _id: chunkId, + fileId: template.fileId + }); + var newDocument = new Document({ + title: template.title, + creator: req.user._id, + chunks: [chunkId] + }); + + firstChunk.save(function (err) { + if (!err) { + newDocument.save(function (err) { + return res.json(201, newDocument); + }); + } + }) + }); +}; + // Updates an existing document in the DB. exports.update = function(req, res) { if(req.body._id) { delete req.body._id; } Document.findById(req.params.id, function (err, document) { if (err) { return handleError(res, err); } if(!document) { return res.send(404); } var updated = _.merge(document, req.body); updated.save(function (err) { if (err) { return handleError(res, err); } return res.json(200, document); }); }); }; // Deletes a document from the DB. exports.destroy = function(req, res) { Document.findById(req.params.id, function (err, document) { if(err) { return handleError(res, err); } if(!document) { return res.send(404); } document.remove(function(err) { if(err) { return handleError(res, err); } return res.send(204); }); }); }; function handleError(res, err) { return res.send(500, err); } diff --git a/server/api/document/document.model.js b/server/api/document/document.model.js index 3b7c7a3..7c21ee8 100644 --- a/server/api/document/document.model.js +++ b/server/api/document/document.model.js @@ -1,26 +1,27 @@ 'use strict'; var mongoose = require('mongoose'), Schema = mongoose.Schema; /* * Each Document Chunk has an associated ODF snapshot file within * GridFS of the same ID. */ var DocumentChunk = new Schema({ - operations: { type: Array, default: [] } + operations: { type: Array, default: [] }, + fileId: { type: Schema.Types.ObjectId } }); var DocumentSchema = new Schema({ title: String, created: { type: Date, default: Date.now, required: true }, date: { type: Date, default: Date.now }, creator: { type: Schema.Types.ObjectId, ref: 'User' }, editors: { type: [{type: Schema.Types.ObjectId, ref: 'User'}], default: [] }, chunks: { type: [{type: Schema.Types.ObjectId, ref: 'DocumentChunk'}], required: true } }); module.exports = { DocumentChunk: mongoose.model('DocumentChunk', DocumentChunk), Document: mongoose.model('Document', DocumentSchema) }; diff --git a/server/api/document/index.js b/server/api/document/index.js index 11385e8..2e4c75a 100644 --- a/server/api/document/index.js +++ b/server/api/document/index.js @@ -1,19 +1,20 @@ 'use strict'; var express = require('express'); var controller = require('./document.controller'); var auth = require('../../auth/auth.service'); var router = express.Router(); router.get('/', auth.isAuthenticated(), controller.index); router.get('/:id', auth.isAuthenticated(), controller.show); router.get('/snapshot/:id', controller.showSnapshot) -router.post('/', auth.isAuthenticated(), controller.create); +router.get('/fromTemplate/:id', auth.isAuthenticated(), + controller.createFromTemplate); router.post('/upload', auth.isAuthenticated(), controller.upload, controller.acknowledgeUpload); router.put('/:id', auth.isAuthenticated(), controller.update); router.patch('/:id', auth.isAuthenticated(), controller.update); router.delete('/:id', auth.isAuthenticated(), controller.destroy); module.exports = router; diff --git a/server/api/template/template.controller.js b/server/api/template/template.controller.js index acf336e..35c3480 100644 --- a/server/api/template/template.controller.js +++ b/server/api/template/template.controller.js @@ -1,103 +1,103 @@ 'use strict'; var _ = require('lodash'); var mongoose = require('mongoose'); var Grid = require('gridfs-stream'); var multer = require('multer'); var Template = require('./template.model'); var gfs = Grid(mongoose.connection.db, mongoose.mongo); // Get list of templates exports.index = function(req, res) { Template.find(function (err, templates) { if(err) { return handleError(res, err); } return res.json(200, templates); }); }; // Get a single template exports.show = function(req, res) { Template.findById(req.params.id, function (err, template) { if(err) { return handleError(res, err); } if(!template) { return res.send(404); } return res.json(template); }); }; exports.upload = function (req, res, next) { multer({ upload: null, limits: { fileSize: 1024 * 1024 * 20, // 20 Megabytes }, onFileUploadStart: function (file) { - var templateId = new mongoose.Types.ObjectId(); + var templateFileId = new mongoose.Types.ObjectId(); var newTemplate = new Template({ - _id: templateId, - title: file.originalname + title: file.originalname, + fileId: templateFileId }); this.upload = gfs.createWriteStream({ - _id: templateId, + _id: templateFileId, filename: file.originalname, mode: 'w', chunkSize: 1024 * 4, content_type: file.mimetype, root: 'fs' }); this.upload.on('finish', function () { newTemplate.save(); }); }, onFileUploadData: function (file, data) { this.upload.write(data); }, onFileUploadComplete: function (file) { this.upload.end(); } })(req, res, next); }; exports.acknowledgeUpload = function (req, res) { return res.send(200); }; // Creates a new template in the DB. exports.create = function(req, res) { Template.create(req.body, function(err, template) { if(err) { return handleError(res, err); } return res.json(201, template); }); }; // Updates an existing template in the DB. exports.update = function(req, res) { if(req.body._id) { delete req.body._id; } Template.findById(req.params.id, function (err, template) { if (err) { return handleError(res, err); } if(!template) { return res.send(404); } var updated = _.merge(template, req.body); updated.save(function (err) { if (err) { return handleError(res, err); } return res.json(200, template); }); }); }; // Deletes a template from the DB. exports.destroy = function(req, res) { Template.findById(req.params.id, function (err, template) { if(err) { return handleError(res, err); } if(!template) { return res.send(404); } template.remove(function(err) { if(err) { return handleError(res, err); } return res.send(204); }); }); }; function handleError(res, err) { return res.send(500, err); } diff --git a/server/api/template/template.model.js b/server/api/template/template.model.js index 47ef90d..ec604dd 100644 --- a/server/api/template/template.model.js +++ b/server/api/template/template.model.js @@ -1,11 +1,12 @@ 'use strict'; var mongoose = require('mongoose'), Schema = mongoose.Schema; var TemplateSchema = new Schema({ title: String, - description: String - }); + description: String, + fileId: Schema.Types.ObjectId +}); module.exports = mongoose.model('Template', TemplateSchema); diff --git a/server/components/adaptor/room.js b/server/components/adaptor/room.js index fea8417..47fdfe5 100644 --- a/server/components/adaptor/room.js +++ b/server/components/adaptor/room.js @@ -1,370 +1,370 @@ /* * Copyright (C) 2015 KO GmbH * * @licstart * This file is part of Kotype. * * Kotype is free software: you can redistribute it and/or modify it * under the terms of the GNU Affero General Public License (GNU AGPL) * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. * * Kotype is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Kotype. If not, see . * @licend * * @source: https://github.com/kogmbh/Kotype/ */ var async = require("async"); var _ = require("lodash"); var RColor = require('../colors'); var DocumentChunk = require("../../api/document/document.model").DocumentChunk; var Room = function (doc, objectCache, cb) { var document, chunk, hasCursor = {}, sockets = [], userColorMap = {}, randomColor = new RColor(), serverSeq = 0; function trackTitle(ops) { var newTitle, i; for (i = 0; i < ops.length; i += 1) { if (ops[i].optype === "UpdateMetadata" && ops[i].setProperties["dc:title"] !== undefined) { newTitle = ops[i].setProperties["dc:title"]; } } if (newTitle !== undefined) { if (newTitle.length === 0) { newTitle = "Untitled Document"; } } if (newTitle) { document.title = newTitle; } } function trackEditors() { // TODO: rather track by ops, to decouple from socket implementation sockets.forEach(function (socket) { var _id = socket.user._id; if (document.editors.indexOf(_id) === -1) { document.editors.push(_id); } }); } function trackCursors(ops) { var i; for (i = 0; i < ops.length; i += 1) { if (ops[i].optype === "AddCursor") { hasCursor[ops[i].memberid] = true; } if (ops[i].optype === "RemoveCursor") { hasCursor[ops[i].memberid] = false; } } } function sanitizeDocument() { var ops = chunk.operations, unbalancedCursors = {}, unbalancedMembers = {}, lastAccessDate = document.date, newOps = [], i; for (i = 0; i < ops.length; i += 1) { if (ops[i].optype === "AddCursor") { unbalancedCursors[ops[i].memberid] = true; } else if (ops[i].optype === "RemoveCursor") { unbalancedCursors[ops[i].memberid] = false; } else if (ops[i].optype === "AddMember") { unbalancedMembers[ops[i].memberid] = true; } else if (ops[i].optype === "RemoveMember") { unbalancedMembers[ops[i].memberid] = false; } } Object.keys(unbalancedCursors).forEach(function (memberId) { if (unbalancedCursors[memberId]) { newOps.push({ optype: "RemoveCursor", memberid: memberId, timestamp: lastAccessDate }); } }); Object.keys(unbalancedMembers).forEach(function (memberId) { if (unbalancedMembers[memberId]) { newOps.push({ optype: "RemoveMember", memberid: memberId, timestamp: lastAccessDate }); } }); if (newOps.length) { // Update op stack chunk.operations = chunk.operations.concat(newOps); serverSeq = chunk.operations.length; } } function broadcastMessage(message, data) { sockets.forEach(function (peerSocket) { peerSocket.emit(message, data) }); } function sendOpsToMember(socket, ops) { socket.emit("new_ops", { head: serverSeq, ops: ops }); } function replayOpsToMember(socket) { socket.emit("replay", { head: serverSeq, ops: chunk.operations }); } function broadcastOpsByMember(socket, ops) { if (!ops.length) { return; } sockets.forEach(function (peerSocket) { if (peerSocket !== socket) { sendOpsToMember(peerSocket, ops); } }); } function writeOpsToDocument(ops, cb) { if (!ops.length) { cb(); } trackTitle(ops); trackEditors(); // Update op stack chunk.operations = chunk.operations.concat(ops); serverSeq = chunk.operations.length; // Update modified date document.date = new Date(); cb(); } function addMember(user, cb) { var memberId, op, timestamp = Date.now(), color = userColorMap[user._id]; memberId = user.name + "_" + timestamp.toString(); // Let user colors persist in a Room even after they've // left and joined. if (!color) { userColorMap[user._id] = color = randomColor.get(true, 0.7); } op = { optype: "AddMember", memberid: memberId, timestamp: timestamp, setProperties: { fullName: user.name, color: color } }; writeOpsToDocument([op], function () { cb(memberId, [op]); }); } function removeMember(memberId, cb) { var ops = [], timestamp = Date.now(); if (hasCursor[memberId]) { ops.push({ optype: "RemoveCursor", memberid: memberId, timestamp: timestamp }); } ops.push({ optype: "RemoveMember", memberid: memberId, timestamp: timestamp }); writeOpsToDocument(ops, function () { cb(ops); }); } function getOpsAfter(basedOn) { return chunk.operations.slice(basedOn, serverSeq); } this.socketCount = function () { return sockets.length; }; this.attachSocket = function (socket) { // Add the socket to the room and give the // client it's unique memberId addMember(socket.user, function (memberId, ops) { socket.memberId = memberId; sockets.push(socket); broadcastOpsByMember(socket, ops); socket.emit("join_success", { - memberId: memberId + memberId: memberId, + snapshotId: chunk.fileId }); // Service replay requests socket.on("replay", function () { replayOpsToMember(socket); }); // Store, analyze, and broadcast incoming commits socket.on("commit_ops", function (data, cb) { var clientSeq = data.head, ops = data.ops; if (clientSeq === serverSeq) { writeOpsToDocument(ops, function () { cb({ conflict: false, head: serverSeq }); trackCursors(ops); broadcastOpsByMember(socket, data.ops); }); } else { cb({ conflict: true }); } }); // Service various requests socket.on("access_get", function (data, cb) { cb({ access: document.isPublic ? "public" : "normal" }); }); if (socket.user.identity !== "guest") { socket.on("access_change", function (data) { document.isPublic = data.access === "public"; broadcastMessage("access_changed", { access: data.access === "public" ? "public" : "normal" }); if (data.access !== "public") { sockets.forEach(function (peerSocket) { if (peerSocket.user.identity === "guest") { console.log(peerSocket.user.name); removeSocket(peerSocket); } }); } }); } // Handle dropout events socket.on("leave", function () { removeSocket(socket); }); socket.on("disconnect", function () { removeSocket(socket); }); }); }; function detachSocket(socket, callback) { removeMember(socket.memberId, function (ops) { broadcastOpsByMember(socket, ops); socket.removeAllListeners(); function lastCB() { socket.removeAllListeners(); if (callback) { callback(); } } // If a socket that is already connected is being // removed, this means that this is a deliberate // kicking-out, and not a natural event that could // result in a reconnection later. Therefore, clean // up. if (socket.connected) { console.log(socket.user.name + " is connected, removing"); socket.on('disconnect', lastCB); socket.emit("kick"); socket.emit("disconnect"); } else { console.log(socket.user.name + " is not connected, removing"); lastCB(); } }); } function removeSocket(socket) { var index = sockets.indexOf(socket); detachSocket(socket); if (index !== -1) { sockets.splice(index, 1); } } this.getDocument = function () { return document; }; this.destroy = function (callback) { async.each(sockets, function (socket, cb) { detachSocket(socket, cb); }, function () { sockets.length = 0; callback(); }); }; function init() { // Setup caching document = objectCache.getTrackedObject(doc); DocumentChunk.findById(_.last(document.chunks), function (err, lastChunk) { chunk = objectCache.getTrackedObject(lastChunk); - console.log(chunk); // Sanitize leftovers from previous session, if any sanitizeDocument(); cb(); }); } init(); }; module.exports = Room;