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;