diff --git a/lib/migrations/20160703062241-support-authorship.js b/lib/migrations/20160703062241-support-authorship.js new file mode 100644 index 00000000..239327ec --- /dev/null +++ b/lib/migrations/20160703062241-support-authorship.js @@ -0,0 +1,28 @@ +'use strict'; + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.addColumn('Notes', 'authorship', Sequelize.TEXT); + queryInterface.addColumn('Revisions', 'authorship', Sequelize.TEXT); + queryInterface.createTable('Authors', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + color: Sequelize.STRING, + noteId: Sequelize.UUID, + userId: Sequelize.UUID, + createdAt: Sequelize.DATE, + updatedAt: Sequelize.DATE + }); + return; + }, + + down: function (queryInterface, Sequelize) { + queryInterface.dropTable('Authors'); + queryInterface.removeColumn('Revisions', 'authorship'); + queryInterface.removeColumn('Notes', 'authorship'); + return; + } +}; diff --git a/lib/models/author.js b/lib/models/author.js new file mode 100644 index 00000000..0b0f149d --- /dev/null +++ b/lib/models/author.js @@ -0,0 +1,43 @@ +"use strict"; + +// external modules +var Sequelize = require("sequelize"); + +// core +var logger = require("../logger.js"); + +module.exports = function (sequelize, DataTypes) { + var Author = sequelize.define("Author", { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + color: { + type: DataTypes.STRING + } + }, { + indexes: [ + { + unique: true, + fields: ['noteId', 'userId'] + } + ], + classMethods: { + associate: function (models) { + Author.belongsTo(models.Note, { + foreignKey: "noteId", + as: "note", + constraints: false + }); + Author.belongsTo(models.User, { + foreignKey: "userId", + as: "user", + constraints: false + }); + } + } + }); + + return Author; +}; \ No newline at end of file diff --git a/lib/models/note.js b/lib/models/note.js index f9a8ec61..5ee5c7de 100644 --- a/lib/models/note.js +++ b/lib/models/note.js @@ -51,6 +51,9 @@ module.exports = function (sequelize, DataTypes) { content: { type: DataTypes.TEXT }, + authorship: { + type: DataTypes.TEXT + }, lastchangeAt: { type: DataTypes.DATE }, @@ -74,6 +77,11 @@ module.exports = function (sequelize, DataTypes) { foreignKey: "noteId", constraints: false }); + Note.hasMany(models.Author, { + foreignKey: "noteId", + as: "authors", + constraints: false + }); }, checkFileExist: function (filePath) { try { diff --git a/lib/models/revision.js b/lib/models/revision.js index bb89782f..f525ea55 100644 --- a/lib/models/revision.js +++ b/lib/models/revision.js @@ -30,6 +30,9 @@ module.exports = function (sequelize, DataTypes) { }, length: { type: DataTypes.INTEGER + }, + authorship: { + type: DataTypes.TEXT } }, { classMethods: { diff --git a/lib/ot/editor-socketio-server.js b/lib/ot/editor-socketio-server.js index 9e4ddf96..45ed5036 100755 --- a/lib/ot/editor-socketio-server.js +++ b/lib/ot/editor-socketio-server.js @@ -10,7 +10,7 @@ var util = require('util'); var LZString = require('lz-string'); var logger = require('../logger'); -function EditorSocketIOServer(document, operations, docId, mayWrite) { +function EditorSocketIOServer(document, operations, docId, mayWrite, operationCallback) { EventEmitter.call(this); Server.call(this, document, operations); this.users = {}; @@ -18,6 +18,7 @@ function EditorSocketIOServer(document, operations, docId, mayWrite) { this.mayWrite = mayWrite || function (_, cb) { cb(true); }; + this.operationCallback = operationCallback; } util.inherits(EditorSocketIOServer, Server); @@ -51,6 +52,8 @@ EditorSocketIOServer.prototype.addClient = function (socket) { } try { self.onOperation(socket, revision, operation, selection); + if (typeof self.operationCallback === 'function') + self.operationCallback(socket, operation); } catch (err) { socket.disconnect(true); } diff --git a/lib/realtime.js b/lib/realtime.js index 0e9af740..68089570 100644 --- a/lib/realtime.js +++ b/lib/realtime.js @@ -151,6 +151,7 @@ function finishUpdateNote(note, _note, callback) { var values = { title: title, content: body, + authorship: LZString.compressToBase64(JSON.stringify(note.authorship)), lastchangeuserId: note.lastchangeuser, lastchangeAt: Date.now() }; @@ -404,6 +405,13 @@ function startConnection(socket) { }, { model: models.User, as: "lastchangeuser" + }, { + model: models.Author, + as: "authors", + include: [{ + model: models.User, + as: "user" + }] }]; models.Note.findOne({ @@ -424,7 +432,19 @@ function startConnection(socket) { var body = LZString.decompressFromBase64(note.content); var createtime = note.createdAt; var updatetime = note.lastchangeAt; - var server = new ot.EditorSocketIOServer(body, [], noteId, ifMayEdit); + var server = new ot.EditorSocketIOServer(body, [], noteId, ifMayEdit, operationCallback); + + var authors = {}; + for (var i = 0; i < note.authors.length; i++) { + var author = note.authors[i]; + var profile = models.User.parseProfile(author.user.profile); + authors[author.userId] = { + userid: author.userId, + color: author.color, + photo: profile.photo, + name: profile.name + }; + } notes[noteId] = { id: noteId, @@ -437,7 +457,9 @@ function startConnection(socket) { users: {}, createtime: moment(createtime).valueOf(), updatetime: moment(updatetime).valueOf(), - server: server + server: server, + authors: authors, + authorship: note.authorship ? JSON.parse(LZString.decompressFromBase64(note.authorship)) : [] }; return finishConnection(socket, notes[noteId], users[socket.id]); @@ -581,6 +603,136 @@ function ifMayEdit(socket, callback) { return callback(mayEdit); } +function operationCallback(socket, operation) { + var noteId = socket.noteId; + if (!noteId || !notes[noteId]) return; + var note = notes[noteId]; + var userId = null; + // save authors + if (socket.request.user && socket.request.user.logged_in) { + var socketId = socket.id; + var user = users[socketId]; + userId = socket.request.user.id; + if (!note.authors[userId]) { + models.Author.create({ + noteId: noteId, + userId: userId, + color: users[socketId].color + }).then(function (author) { + note.authors[author.userId] = { + userid: author.userId, + color: author.color, + photo: user.photo, + name: user.name + }; + }).catch(function (err) { + return logger.error('operation callback failed: ' + err); + }); + } + } + // save authorship + var index = 0; + var authorships = note.authorship; + var timestamp = Date.now(); + for (var i = 0; i < operation.length; i++) { + var op = operation[i]; + if (ot.TextOperation.isRetain(op)) { + index += op; + } else if (ot.TextOperation.isInsert(op)) { + var opStart = index; + var opEnd = index + op.length; + var inserted = false; + // authorship format: [userId, startPos, endPos, createdAt, updatedAt] + if (authorships.length <= 0) authorships.push([userId, opStart, opEnd, timestamp, timestamp]); + else { + for (var j = 0; j < authorships.length; j++) { + var authorship = authorships[j]; + if (!inserted) { + var nextAuthorship = authorships[j + 1] || -1; + if (nextAuthorship != -1 && nextAuthorship[1] >= opEnd || j >= authorships.length - 1) { + if (authorship[1] < opStart && authorship[2] > opStart) { + // divide + var postLength = authorship[2] - opStart; + authorship[2] = opStart; + authorship[4] = timestamp; + authorships.splice(j + 1, 0, [userId, opStart, opEnd, timestamp, timestamp]); + authorships.splice(j + 2, 0, [authorship[0], opEnd, opEnd + postLength, authorship[3], timestamp]); + j += 2; + inserted = true; + } else if (authorship[1] >= opStart) { + authorships.splice(j, 0, [userId, opStart, opEnd, timestamp, timestamp]); + j += 1; + inserted = true; + } else if (authorship[2] <= opStart) { + authorships.splice(j + 1, 0, [userId, opStart, opEnd, timestamp, timestamp]); + j += 1; + inserted = true; + } + } + } + if (authorship[1] >= opStart) { + authorship[1] += op.length; + authorship[2] += op.length; + } + } + } + index += op.length; + } else if (ot.TextOperation.isDelete(op)) { + var opStart = index; + var opEnd = index - op; + if (operation.length == 1) { + authorships = []; + } else if (authorships.length > 0) { + for (var j = 0; j < authorships.length; j++) { + var authorship = authorships[j]; + if (authorship[1] >= opStart && authorship[1] <= opEnd && authorship[2] >= opStart && authorship[2] <= opEnd) { + authorships.splice(j, 1); + j -= 1; + } else if (authorship[1] < opStart && authorship[1] < opEnd && authorship[2] > opStart && authorship[2] > opEnd) { + authorship[2] += op; + authorship[4] = timestamp; + } else if (authorship[2] >= opStart && authorship[2] <= opEnd) { + authorship[2] = opStart; + authorship[4] = timestamp; + } else if (authorship[1] >= opStart && authorship[1] <= opEnd) { + authorship[1] = opEnd; + authorship[4] = timestamp; + } + if (authorship[1] >= opEnd) { + authorship[1] += op; + authorship[2] += op; + } + } + } + index += op; + } + } + // merge + for (var j = 0; j < authorships.length; j++) { + var authorship = authorships[j]; + for (var k = j + 1; k < authorships.length; k++) { + var nextAuthorship = authorships[k]; + if (nextAuthorship && authorship[0] === nextAuthorship[0] && authorship[2] === nextAuthorship[1]) { + var minTimestamp = Math.min(authorship[3], nextAuthorship[3]); + var maxTimestamp = Math.max(authorship[3], nextAuthorship[3]); + authorships.splice(j, 1, [authorship[0], authorship[1], nextAuthorship[2], minTimestamp, maxTimestamp]); + authorships.splice(k, 1); + j -= 1; + break; + } + } + } + // clear + for (var j = 0; j < authorships.length; j++) { + var authorship = authorships[j]; + if (!authorship[0]) { + authorships.splice(j, 1); + j -= 1; + } + } + note.authorship = authorships; +} + function connection(socket) { if (config.maintenance) return; parseNoteIdFromSocket(socket, function (err, noteId) {