diff --git a/public/res/classes/FileDescriptor.js b/public/res/classes/FileDescriptor.js index ea4efe81..d8d16357 100644 --- a/public/res/classes/FileDescriptor.js +++ b/public/res/classes/FileDescriptor.js @@ -88,7 +88,7 @@ define([ }); Object.defineProperty(this, 'discussionListJSON', { get: function() { - return storage[this.fileIndex + ".discussionList"]; + return storage[this.fileIndex + ".discussionList"] || '{}'; }, set: function(discussionList) { this._discussionList = JSON.parse(discussionList); diff --git a/public/res/classes/Provider.js b/public/res/classes/Provider.js index 6749a19f..114237c0 100644 --- a/public/res/classes/Provider.js +++ b/public/res/classes/Provider.js @@ -1,10 +1,126 @@ -define(function() { - +define([ + 'underscore', + 'utils', + 'settings', + 'eventMgr', + 'fileMgr', + 'diff_match_patch_uncompressed', + 'jsondiffpatch', +], function(_, utils, settings, eventMgr, fileMgr, diff_match_patch, jsondiffpatch) { + function Provider(providerId, providerName) { this.providerId = providerId; this.providerName = providerName; this.isPublishEnabled = true; } - + + // Parse and check a JSON discussion list + Provider.prototype.parseDiscussionList = function(discussionListJSON) { + try { + var discussionList = JSON.parse(discussionListJSON); + _.each(discussionList, function(discussion, discussionIndex) { + if( + (discussion.discussionIndex != discussionIndex) || + (!_.isNumber(discussion.selectionStart)) || + (!_.isNumber(discussion.selectionEnd)) + ) { + throw 'invalid'; + } + discussion.commentList.forEach(function(comment) { + if( + (!_.isString(comment.author)) || + (!_.isString(comment.content)) + ) { + throw 'invalid'; + } + }); + }); + return discussionList; + } + catch(e) { + } + }; + + Provider.prototype.serializeContent = function(content, discussionList) { + if(_.size(discussionList) !== 0) { + return content + ''; + } + return content; + }; + + Provider.prototype.parseSerializedContent = function(content) { + var discussionList = '{}'; + var discussionExtractor = /$/.exec(content); + if(discussionExtractor && this.parseDiscussionList(discussionExtractor[1])) { + content = content.substring(0, discussionExtractor.index); + discussionList = discussionExtractor[1]; + } + return { + content: content, + discussionList: discussionList + }; + }; + + var diffMatchPatch = new diff_match_patch(); + var jsonDiffPatch = jsondiffpatch.create({ + objectHash: function(obj) { + return JSON.stringify(obj); + }, + arrays: { + detectMove: false, + }, + textDiff: { + minLength: 9999999 + } + }); + + var merge = settings.conflictMode == 'merge'; + Provider.prototype.merge = function(localContent, remoteContent, localTitle, remoteTitle, localDiscussionList, remoteDiscussionList, syncAttributes) { + + // Local/Remote CRCs + var localContentCRC = utils.crc32(localContent); + var localTitleCRC = utils.crc32(localTitle); + var localDiscussionListCRC = utils.crc32(localDiscussionList); + var remoteContentCRC = utils.crc32(remoteContent); + var remoteTitleCRC = utils.crc32(remoteTitle); + var remoteDiscussionListCRC = utils.crc32(remoteDiscussionList); + + // Check content + var contentChanged = localContent != remoteContent; + var localContentChanged = syncAttributes.contentCRC != localContentCRC; + var remoteContentChanged = syncAttributes.contentCRC != remoteContentCRC; + var contentConflict = contentChanged && localContentChanged && remoteContentChanged; + + // Check title + syncAttributes.titleCRC = syncAttributes.titleCRC || localTitleCRC; // Not synchronized with Dropbox + var titleChanged = localTitle != remoteTitle; + var localTitleChanged = syncAttributes.titleCRC != localTitleCRC; + var remoteTitleChanged = syncAttributes.titleCRC != remoteTitleCRC; + var titleConflict = titleChanged && localTitleChanged && remoteTitleChanged; + + // Check discussionList + var discussionListChanged = localDiscussionList != remoteDiscussionList; + var localDiscussionListChanged = syncAttributes.discussionListCRC != localDiscussionListCRC; + var remoteDiscussionListChanged = syncAttributes.discussionListCRC != remoteDiscussionListCRC; + var discussionListConflict = discussionListChanged && localDiscussionListChanged && remoteDiscussionListChanged; + + // Conflict detection + if( + (!merge && (contentConflict || titleConflict || discussionListConflict)) || + (contentConflict && syncAttributes.content === undefined) || + (titleConflict && syncAttributes.title === undefined) || + (discussionListConflict && syncAttributes.discussionList === undefined) + ) { + fileMgr.createFile(localTitle + " (backup)", localContent); + eventMgr.onMessage('Conflict detected on "' + localTitle + '". A backup has been created locally.'); + } + else { + if(contentConflict === true) { + var patch = diffMatchPatch.patch_make(syncAttributes.content, localContent); + } + } + + }; + return Provider; -}); \ No newline at end of file +}); diff --git a/public/res/editor.js b/public/res/editor.js index 0605e538..bdb5e46f 100644 --- a/public/res/editor.js +++ b/public/res/editor.js @@ -11,18 +11,6 @@ define([ 'MutationObservers', 'libs/prism-markdown' ], function ($, _, settings, eventMgr, Prism, diff_match_patch, jsondiffpatch, crel) { - var diffMatchPatch = new diff_match_patch(); - var jsonDiffPatch = jsondiffpatch.create({ - objectHash: function(obj) { - return JSON.stringify(obj); - }, - arrays: { - detectMove: false, - }, - textDiff: { - minLength: 9999999 - } - }); function strSplice(str, i, remove, add) { remove = +remove || 0; @@ -86,6 +74,19 @@ define([ }); } + var diffMatchPatch = new diff_match_patch(); + var jsonDiffPatch = jsondiffpatch.create({ + objectHash: function(obj) { + return JSON.stringify(obj); + }, + arrays: { + detectMove: false, + }, + textDiff: { + minLength: 9999999 + } + }); + var previousTextContent; var currentMode; editor.undoManager = (function() { diff --git a/public/res/extensions/comments.js b/public/res/extensions/comments.js index 797703ed..e6024d27 100644 --- a/public/res/extensions/comments.js +++ b/public/res/extensions/comments.js @@ -111,7 +111,7 @@ define([ refreshDiscussions(); }; - comments.onContentChanged = function(fileDesc, content) { + comments.onContentChanged = function(fileDesc) { currentFileDesc === fileDesc && refreshDiscussions(); }; @@ -359,7 +359,7 @@ define([ // Focus on textarea context.$contentInputElt.focus().val(previousContent); - }).on('hide.bs.popover', '#wmd-input > .editor-margin', function(evt) { + }).on('hide.bs.popover', '#wmd-input > .editor-margin', function() { if(!currentContext) { return; } diff --git a/public/res/fileMgr.js b/public/res/fileMgr.js index 9c29e417..50cb2721 100644 --- a/public/res/fileMgr.js +++ b/public/res/fileMgr.js @@ -50,7 +50,7 @@ define([ core.initEditor(fileDesc); }; - fileMgr.createFile = function(title, content, syncLocations, isTemporary) { + fileMgr.createFile = function(title, content, discussionListJSON, syncLocations, isTemporary) { content = content !== undefined ? content : settings.defaultContent; if(!title) { // Create a file title @@ -86,6 +86,7 @@ define([ // Create the file descriptor var fileDesc = new FileDescriptor(fileIndex, title, syncLocations); + discussionListJSON && (fileDesc.discussionListJSON = discussionListJSON); // Add the index to the file list if(!isTemporary) { diff --git a/public/res/providers/dropboxProvider.js b/public/res/providers/dropboxProvider.js index 68123d09..a7db4da2 100644 --- a/public/res/providers/dropboxProvider.js +++ b/public/res/providers/dropboxProvider.js @@ -2,11 +2,12 @@ define([ "underscore", "utils", "storage", + "settings", "classes/Provider", "eventMgr", "fileMgr", "helpers/dropboxHelper" -], function(_, utils, storage, Provider, eventMgr, fileMgr, dropboxHelper) { +], function(_, utils, storage, settings, Provider, eventMgr, fileMgr, dropboxHelper) { var PROVIDER_DROPBOX = "dropbox"; @@ -55,7 +56,8 @@ define([ var syncAttributes = createSyncAttributes(file.path, file.versionTag, file.content); var syncLocations = {}; syncLocations[syncAttributes.syncIndex] = syncAttributes; - var fileDesc = fileMgr.createFile(file.name, file.content, syncLocations); + var parsingResult = dropboxProvider.parseSerializedContent(file.content); + var fileDesc = fileMgr.createFile(file.name, parsingResult.content, parsingResult.discussionList, syncLocations); fileMgr.selectFile(fileDesc); fileDescList.push(fileDesc); }); @@ -76,8 +78,7 @@ define([ var syncIndex = createSyncIndex(path); var fileDesc = fileMgr.getFileFromSyncIndex(syncIndex); if(fileDesc !== undefined) { - eventMgr.onError('"' + fileDesc.title + '" was already imported.'); - return; + return eventMgr.onError('"' + fileDesc.title + '" was already imported.'); } importPaths.push(path); }); @@ -89,8 +90,7 @@ define([ var path = utils.getInputTextValue("#input-sync-export-dropbox-path", event); path = checkPath(path); if(path === undefined) { - callback(true); - return; + return callback(true); } // Check that file is not synchronized with another one var syncIndex = createSyncIndex(path); @@ -98,33 +98,39 @@ define([ if(fileDesc !== undefined) { var existingTitle = fileDesc.title; eventMgr.onError('File path is already synchronized with "' + existingTitle + '".'); - callback(true); - return; + return callback(true); } dropboxHelper.upload(path, content, function(error, result) { if(error) { - callback(error); - return; + return callback(error); } var syncAttributes = createSyncAttributes(result.path, result.versionTag, content); callback(undefined, syncAttributes); }); }; - dropboxProvider.syncUp = function(uploadContent, uploadContentCRC, uploadTitle, uploadTitleCRC, syncAttributes, callback) { - var syncContentCRC = syncAttributes.contentCRC; - // Skip if CRC has not changed - if(uploadContentCRC == syncContentCRC) { - callback(undefined, false); - return; + var merge = settings.conflictMode == 'merge'; + dropboxProvider.syncUp = function(content, contentCRC, title, titleCRC, discussionList, discussionListCRC, syncAttributes, callback) { + if( + (syncAttributes.contentCRC == contentCRC) && // Content CRC hasn't changed + (syncAttributes.discussionListCRC == discussionListCRC) // Discussion list CRC hasn't changed + ) { + return callback(undefined, false); } - dropboxHelper.upload(syncAttributes.path, uploadContent, function(error, result) { + var uploadedContent = dropboxProvider.serializeContent(content, discussionList); + dropboxHelper.upload(syncAttributes.path, uploadedContent, function(error, result) { if(error) { - callback(error, true); - return; + return callback(error, true); } syncAttributes.version = result.versionTag; - syncAttributes.contentCRC = uploadContentCRC; + if(merge === true) { + // Need to store the whole content for merge + syncAttributes.content = content; + syncAttributes.discussionList = discussionList; + } + syncAttributes.contentCRC = contentCRC; + syncAttributes.discussionListCRC = discussionListCRC; + callback(undefined, true); }); }; @@ -133,8 +139,7 @@ define([ var lastChangeId = storage[PROVIDER_DROPBOX + ".lastChangeId"]; dropboxHelper.checkChanges(lastChangeId, function(error, changes, newChangeId) { if(error) { - callback(error); - return; + return callback(error); } var interestingChanges = []; _.each(changes, function(change) { @@ -164,8 +169,7 @@ define([ var syncAttributes = change.syncAttributes; var syncIndex = syncAttributes.syncIndex; var fileDesc = fileMgr.getFileFromSyncIndex(syncIndex); - // No file corresponding (file may have been deleted - // locally) + // No file corresponding (file may have been deleted locally) if(fileDesc === undefined) { return; } @@ -174,15 +178,22 @@ define([ if(change.wasRemoved === true) { eventMgr.onError('"' + localTitle + '" has been removed from Dropbox.'); fileDesc.removeSyncLocation(syncAttributes); - eventMgr.onSyncRemoved(fileDesc, syncAttributes); - return; + return eventMgr.onSyncRemoved(fileDesc, syncAttributes); } var localContent = fileDesc.content; var localContentChanged = syncAttributes.contentCRC != utils.crc32(localContent); + var localDiscussionList = fileDesc.discussionListJSON; + var localDiscussionListChanged = syncAttributes.discussionListCRC != utils.crc32(localDiscussionList); var file = change.stat; - var remoteContentCRC = utils.crc32(file.content); + var parsingResult = dropboxProvider.parseSerializedContent(file.content); + var remoteContent = parsingResult.content; + var remoteDiscussionList = parsingResult.discussionList; + var remoteContentCRC = utils.crc32(remoteContent); var remoteContentChanged = syncAttributes.contentCRC != remoteContentCRC; - var fileContentChanged = localContent != file.content; + var remoteDiscussionListCRC = utils.crc32(remoteDiscussionList); + var remoteDiscussionListChanged = syncAttributes.discussionListCRC != remoteDiscussionListCRC; + var fileContentChanged = localContent != remoteContent; + var fileDiscussionListChanged = localDiscussionList != remoteDiscussionList; // Conflict detection if(fileContentChanged === true && localContentChanged === true && remoteContentChanged === true) { fileMgr.createFile(localTitle + " (backup)", localContent); @@ -211,8 +222,7 @@ define([ dropboxProvider.publish = function(publishAttributes, frontMatter, title, content, callback) { var path = checkPath(publishAttributes.path); if(path === undefined) { - callback(true); - return; + return callback(true); } dropboxHelper.upload(path, content, callback); }; diff --git a/public/res/providers/gdriveProviderBuilder.js b/public/res/providers/gdriveProviderBuilder.js index cb63a245..b4ec687a 100644 --- a/public/res/providers/gdriveProviderBuilder.js +++ b/public/res/providers/gdriveProviderBuilder.js @@ -56,7 +56,8 @@ define([ syncAttributes.isRealtime = file.isRealtime; var syncLocations = {}; syncLocations[syncAttributes.syncIndex] = syncAttributes; - fileDesc = fileMgr.createFile(file.title, file.content, syncLocations); + var parsingResult = gdriveProvider.parseSerializedContent(file.content); + fileDesc = fileMgr.createFile(file.title, parsingResult.content, parsingResult.discussionList, syncLocations); fileDescList.push(fileDesc); }); if(fileDesc !== undefined) { @@ -122,37 +123,48 @@ define([ }); }; - gdriveProvider.syncUp = function(uploadContent, uploadContentCRC, uploadTitle, uploadTitleCRC, syncAttributes, callback) { - // Skip if CRC has not changed - if(uploadContentCRC == syncAttributes.contentCRC && uploadTitleCRC == syncAttributes.titleCRC) { - callback(undefined, false); - return; + var merge = settings.conflictMode == 'merge'; + gdriveProvider.syncUp = function(content, contentCRC, title, titleCRC, discussionList, discussionListCRC, syncAttributes, callback) { + if( + (syncAttributes.contentCRC == contentCRC) && // Content CRC hasn't changed + (syncAttributes.titleCRC == titleCRC) && // Content CRC hasn't changed + (syncAttributes.discussionListCRC == discussionListCRC) // Discussion list CRC hasn't changed + ) { + return callback(undefined, false); } - googleHelper.upload(syncAttributes.id, undefined, uploadTitle, uploadContent, undefined, syncAttributes.etag, accountId, function(error, result) { + var uploadedContent = gdriveProvider.serializeContent(content, discussionList); + googleHelper.upload(syncAttributes.id, undefined, title, uploadedContent, undefined, syncAttributes.etag, accountId, function(error, result) { if(error) { callback(error, true); return; } syncAttributes.etag = result.etag; - syncAttributes.contentCRC = uploadContentCRC; - syncAttributes.titleCRC = uploadTitleCRC; + if(merge === true) { + // Need to store the whole content for merge + syncAttributes.content = content; + syncAttributes.title = title; + syncAttributes.discussionList = discussionList; + } + syncAttributes.contentCRC = contentCRC; + syncAttributes.titleCRC = titleCRC; + syncAttributes.discussionListCRC = discussionListCRC; callback(undefined, true); }); }; - gdriveProvider.syncUpRealtime = function(uploadContent, uploadContentCRC, uploadTitle, uploadTitleCRC, syncAttributes, callback) { + gdriveProvider.syncUpRealtime = function(content, contentCRC, title, titleCRC, discussionList, discussionListCRC, syncAttributes, callback) { // Skip if title CRC has not changed - if(uploadTitleCRC == syncAttributes.titleCRC) { + if(titleCRC == syncAttributes.titleCRC) { callback(undefined, false); return; } - googleHelper.rename(syncAttributes.id, uploadTitle, accountId, function(error, result) { + googleHelper.rename(syncAttributes.id, title, accountId, function(error, result) { if(error) { callback(error, true); return; } syncAttributes.etag = result.etag; - syncAttributes.titleCRC = uploadTitleCRC; + syncAttributes.titleCRC = titleCRC; callback(undefined, true); }); }; @@ -646,7 +658,7 @@ define([ var syncAttributes = createSyncAttributes(file.id, file.etag, file.content, file.title); var syncLocations = {}; syncLocations[syncAttributes.syncIndex] = syncAttributes; - var fileDesc = fileMgr.createFile(file.title, file.content, syncLocations); + var fileDesc = fileMgr.createFile(file.title, file.content, undefined, syncLocations); fileMgr.selectFile(fileDesc); eventMgr.onMessage('"' + file.title + '" created successfully on ' + providerName + '.'); }); diff --git a/public/res/settings.js b/public/res/settings.js index 110359ad..17e80396 100644 --- a/public/res/settings.js +++ b/public/res/settings.js @@ -13,6 +13,7 @@ define([ maxWidth: 960, defaultContent: "\n\n\n> Written with [StackEdit](" + constants.MAIN_URL + ").", commitMsg: "Published with " + constants.MAIN_URL, + conflictMode: 'merge', gdriveMultiAccount: 1, gdriveFullAccess: true, dropboxFullAccess: true, diff --git a/public/res/sharing.js b/public/res/sharing.js index f31ddcf0..65a1eaab 100644 --- a/public/res/sharing.js +++ b/public/res/sharing.js @@ -126,10 +126,10 @@ define([ if(error) { return; } - var fileDesc = fileMgr.createFile(title, content, undefined, true); + var fileDesc = fileMgr.createFile(title, content, undefined, undefined, true); fileMgr.selectFile(fileDesc); }); }); return sharing; -}); \ No newline at end of file +}); diff --git a/public/res/styles/main.less b/public/res/styles/main.less index 10b92afb..eb9a5896 100644 --- a/public/res/styles/main.less +++ b/public/res/styles/main.less @@ -1041,7 +1041,6 @@ a { font-family: "PT Sans", sans-serif; line-height: @editor-line-weight; letter-spacing: normal; - font-size: @font-size-base; border-radius: 0; color: @tertiary-color-dark; .box-shadow(none); diff --git a/public/res/synchronizer.js b/public/res/synchronizer.js index e1c79c8c..ff824ba7 100644 --- a/public/res/synchronizer.js +++ b/public/res/synchronizer.js @@ -47,12 +47,12 @@ define([ } }); }); - + // AutoSync configuration _.each(providerMap, function(provider) { provider.autosyncConfig = utils.retrieveIgnoreError(provider.providerId + ".autosyncConfig") || {}; }); - + // Returns true if at least one file has synchronized location synchronizer.hasSync = function(provider) { return _.some(fileSystem, function(fileDesc) { @@ -72,6 +72,8 @@ define([ var uploadContentCRC; var uploadTitle; var uploadTitleCRC; + var uploadDiscussionList; + var uploadDiscussionListCRC; function locationUp(callback) { // No more synchronized location for this document @@ -90,21 +92,30 @@ define([ } // Use the specified provider to perform the upload - providerSyncUpFunction(uploadContent, uploadContentCRC, uploadTitle, uploadTitleCRC, syncAttributes, function(error, uploadFlag) { - if(uploadFlag === true) { - // If uploadFlag is true, request another upload cycle - uploadCycle = true; + providerSyncUpFunction( + uploadContent, + uploadContentCRC, + uploadTitle, + uploadTitleCRC, + uploadDiscussionList, + uploadTitleCRC, + uploadDiscussionListCRC, + function(error, uploadFlag) { + if(uploadFlag === true) { + // If uploadFlag is true, request another upload cycle + uploadCycle = true; + } + if(error) { + callback(error); + return; + } + if(uploadFlag) { + // Update syncAttributes in storage + utils.storeAttributes(syncAttributes); + } + locationUp(callback); } - if(error) { - callback(error); - return; - } - if(uploadFlag) { - // Update syncAttributes in storage - utils.storeAttributes(syncAttributes); - } - locationUp(callback); - }); + ); } // Recursive function to upload multiple files @@ -130,6 +141,8 @@ define([ uploadContentCRC = utils.crc32(uploadContent); uploadTitle = fileDesc.title; uploadTitleCRC = utils.crc32(uploadTitle); + uploadDiscussionList = fileDesc.discussionListJSON; + uploadDiscussionListCRC = utils.crc32(uploadDiscussionList); locationUp(callback); }