Просмотр исходного кода

Merge pull request #582 from weseek/imprv/701-hackmd-integration

Imprv/701 hackmd integration
Yuki Takei 7 лет назад
Родитель
Сommit
0e7ffde749

+ 60 - 50
lib/models/page.js

@@ -958,17 +958,17 @@ module.exports = function(crowi) {
     pageData.lastUpdateUser = user;
     pageData.updatedAt = Date.now();
 
-    pageData = this.syncRevisionToHackmd(pageData, false);
-
     return pageData.save();
   };
 
-  pageSchema.statics.create = function(path, body, user, options) {
+  pageSchema.statics.create = function(path, body, user, options = {}) {
     const Page = this
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
       , redirectTo = options.redirectTo || null
-      , grantUserGroupId = options.grantUserGroupId || null;
+      , grantUserGroupId = options.grantUserGroupId || null
+      , socketClientId = options.socketClientId || null
+      ;
 
     let grant = options.grant || GRANT_PUBLIC;
 
@@ -1012,34 +1012,47 @@ module.exports = function(crowi) {
         return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
       })
       .then(() => {
-        pageEvent.emit('create', savedPage, user);
+        if (socketClientId != null) {
+          pageEvent.emit('create', savedPage, user, socketClientId);
+        }
         return savedPage;
       });
   };
 
-  pageSchema.statics.updatePage = async function(pageData, body, user, options) {
-    var Page = this
+  pageSchema.statics.updatePage = async function(pageData, body, user, options = {}) {
+    const Page = this
       , Revision = crowi.model('Revision')
       , grant = options.grant || null
       , grantUserGroupId = options.grantUserGroupId || null
+      , isSyncRevisionToHackmd = options.isSyncRevisionToHackmd
+      , socketClientId = options.socketClientId || null
       ;
+
     // update existing page
-    var newRevision = await Revision.prepareRevision(pageData, body, user);
+    const newRevision = await Revision.prepareRevision(pageData, body, user);
 
     const revision = await Page.pushRevision(pageData, newRevision, user);
-    const savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
+    let savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
     if (grant != null) {
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       debug('Page grant update:', grantData);
     }
-    pageEvent.emit('update', savedPage, user);
+
+    if (isSyncRevisionToHackmd) {
+      savedPage = await Page.syncRevisionToHackmd(savedPage);
+    }
+
+    if (socketClientId != null) {
+      pageEvent.emit('update', savedPage, user, socketClientId);
+    }
     return savedPage;
   };
 
-  pageSchema.statics.deletePage = function(pageData, user, options) {
-    var Page = this
+  pageSchema.statics.deletePage = async function(pageData, user, options = {}) {
+    const Page = this
       , newPath = Page.getDeletedPageName(pageData.path)
       , isTrashed = checkIfTrashed(pageData.path)
+      , socketClientId = options.socketClientId || null
       ;
 
     if (Page.isDeletableName(pageData.path)) {
@@ -1047,13 +1060,13 @@ module.exports = function(crowi) {
         return Page.completelyDeletePage(pageData, user, options);
       }
 
-      return Page.rename(pageData, newPath, user, {createRedirectPage: true})
-        .then((updatedPageData) => {
-          return Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
-        })
-        .then(() => {
-          return pageData;
-        });
+      let updatedPageData = await Page.rename(pageData, newPath, user, {createRedirectPage: true});
+      await Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
+
+      if (socketClientId != null) {
+        pageEvent.emit('delete', updatedPageData, user, socketClientId);
+      }
+      return updatedPageData;
     }
     else {
       return Promise.reject('Page is not deletable.');
@@ -1065,11 +1078,11 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.deletePageRecursively = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , path = pageData.path
-      , options = options || {}
-      , isTrashed = checkIfTrashed(pageData.path);
+      , isTrashed = checkIfTrashed(pageData.path)
       ;
+    options = options || {};
 
     if (isTrashed) {
       return Page.completelyDeletePageRecursively(pageData, user, options);
@@ -1088,7 +1101,7 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , newPath = Page.getRevertDeletedPageName(pageData.path)
       ;
 
@@ -1102,7 +1115,7 @@ module.exports = function(crowi) {
           throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
         }
 
-        return Page.completelyDeletePage(originPageData);
+        return Page.completelyDeletePage(originPageData, options);
       }).then(function(done) {
         return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user});
       }).then(function(done) {
@@ -1117,11 +1130,11 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.statics.revertDeletedPageRecursively = function(pageData, user, options) {
-    var Page = this
+  pageSchema.statics.revertDeletedPageRecursively = function(pageData, user, options = {}) {
+    const Page = this
       , path = pageData.path
-      , options = options || { includeDeletedPage: true}
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
     return new Promise(function(resolve, reject) {
       Page
@@ -1141,15 +1154,16 @@ module.exports = function(crowi) {
   /**
    * This is danger.
    */
-  pageSchema.statics.completelyDeletePage = function(pageData, user, options) {
+  pageSchema.statics.completelyDeletePage = function(pageData, user, options = {}) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Bookmark = crowi.model('Bookmark')
+    const Bookmark = crowi.model('Bookmark')
       , Attachment = crowi.model('Attachment')
       , Comment = crowi.model('Comment')
       , Revision = crowi.model('Revision')
       , PageGroupRelation = crowi.model('PageGroupRelation')
       , Page = this
       , pageId = pageData._id
+      , socketClientId = options.socketClientId || null
       ;
 
     debug('Completely delete', pageData.path);
@@ -1170,18 +1184,20 @@ module.exports = function(crowi) {
       }).then(function(done) {
         return PageGroupRelation.removeAllByPage(pageData);
       }).then(function(done) {
-        pageEvent.emit('delete', pageData, user); // update as renamed page
+        if (socketClientId != null) {
+          pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
+        }
         resolve(pageData);
       }).catch(reject);
     });
   };
 
-  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options) {
+  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options = {}) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Page = this
+    const Page = this
       , path = pageData.path
-      , options = options || { includeDeletedPage: true }
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
     return new Promise(function(resolve, reject) {
       Page
@@ -1249,32 +1265,31 @@ module.exports = function(crowi) {
       });
   };
 
-  pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
+  pageSchema.statics.rename = async function(pageData, newPagePath, user, options) {
     const Page = this
       , Revision = crowi.model('Revision')
       , path = pageData.path
       , createRedirectPage = options.createRedirectPage || 0
+      , socketClientId = options.socketClientId || null
       ;
 
     // sanitize path
     newPagePath = crowi.xss.process(newPagePath);
 
-    return Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})  // pageData の path を変更
-      .then((data) => {
+    await Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user});
         // reivisions の path を変更
-        return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
-      })
-      .then(function(data) {
-        pageData.path = newPagePath;
+    await Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
 
         if (createRedirectPage) {
           const body = 'redirect ' + newPagePath;
-          Page.create(path, body, user, {redirectTo: newPagePath});
+      await Page.create(path, body, user, {redirectTo: newPagePath});
         }
-        pageEvent.emit('update', pageData, user); // update as renamed page
 
-        return pageData;
-      });
+    let updatedPageData = await Page.findOne({path: newPagePath});
+    pageEvent.emit('delete', pageData, user, socketClientId);
+    pageEvent.emit('create', updatedPageData, user, socketClientId);
+
+    return updatedPageData;
   };
 
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
@@ -1337,12 +1352,7 @@ module.exports = function(crowi) {
    * @param {Boolean} newValue
    */
   pageSchema.statics.updateHasDraftOnHackmd = async function(pageData, newValue) {
-    const revisionIdStr = pageData.revision.toString();
-    const revisionHackmdSyncedIdStr = pageData.revisionHackmdSynced.toString();
-    if (revisionIdStr !== revisionHackmdSyncedIdStr) {
-      return;
-    }
-    else if (pageData.hasDraftOnHackmd === newValue) {
+    if (pageData.hasDraftOnHackmd === newValue) {
       // do nothing when hasDraftOnHackmd equals to newValue
       return;
     }

+ 1 - 2
lib/routes/hackmd.js

@@ -175,10 +175,9 @@ module.exports = function(crowi, app) {
   const saveOnHackmd = async function(req, res) {
     const page = req.page;
 
-    pageEvent.emit('saveOnHackmd', page);
-
     try {
       await Page.updateHasDraftOnHackmd(page, true);
+      pageEvent.emit('saveOnHackmd', page);
       return res.json(ApiResponse.success());
     }
     catch (err) {

+ 53 - 27
lib/routes/page.js

@@ -23,9 +23,27 @@ module.exports = function(crowi, app) {
   // register page events
 
   const pageEvent = crowi.event('page');
-  pageEvent.on('update', function(page, user) {
-    crowi.getIo().sockets.emit('page:update', {page, user});
+  pageEvent.on('create', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:create', {page, user, socketClientId});
   });
+  pageEvent.on('update', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:update', {page, user, socketClientId});
+  });
+  pageEvent.on('delete', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:delete', {page, user, socketClientId});
+  });
+
+
+  function serializeToObj(page) {
+    const returnObj = page.toObject();
+    if (page.revisionHackmdSynced != null && page.revisionHackmdSynced._id != null) {
+      returnObj.revisionHackmdSynced = page.revisionHackmdSynced._id;
+    }
+    return returnObj;
+  }
 
   function getPathFromRequest(req) {
     const path = '/' + (req.params[0] || '');
@@ -775,6 +793,7 @@ module.exports = function(crowi, app) {
     const grantUserGroupId = req.body.grantUserGroupId || null;
     const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
     const slackChannels = req.body.slackChannels || null;
+    const socketClientId = req.body.socketClientId || undefined;
 
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
@@ -787,13 +806,14 @@ module.exports = function(crowi, app) {
           throw new Error('Page exists');
         }
 
-        return Page.create(pagePath, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
+        const options = {grant, grantUserGroupId, socketClientId};
+        return Page.create(pagePath, body, req.user, options);
       })
       .catch(function(err) {
         return res.json(ApiResponse.error(err));
       });
 
-    const result = { page: createdPage.toObject() };
+    const result = { page: serializeToObj(createdPage) };
     result.page.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     res.json(ApiResponse.success(result));
@@ -832,8 +852,10 @@ module.exports = function(crowi, app) {
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
     const grantUserGroupId = req.body.grantUserGroupId || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
+    const isSlackEnabled = !!req.body.isSlackEnabled;                     // cast to boolean
     const slackChannels = req.body.slackChannels || null;
+    const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd;     // cast to boolean
+    const socketClientId = req.body.socketClientId || undefined;
 
     if (pageId === null || pageBody === null) {
       return res.json(ApiResponse.error('page_id and body are required.'));
@@ -843,28 +865,28 @@ module.exports = function(crowi, app) {
     let updatedPage = await Page.findPageByIdAndGrantedUser(pageId, req.user)
       .then(function(pageData) {
         if (pageData && revisionId !== null && !pageData.isUpdatable(revisionId)) {
-          throw new Error('Revision error.');
+          throw new Error('Posted param "revisionId" is outdated.');
         }
 
-        const grantOption = {};
+        const options = {isSyncRevisionToHackmd, socketClientId};
         if (grant != null) {
-          grantOption.grant = grant;
+          options.grant = grant;
         }
         if (grantUserGroupId != null) {
-          grantOption.grantUserGroupId = grantUserGroupId;
+          options.grantUserGroupId = grantUserGroupId;
         }
 
         // store previous revision
         previousRevision = pageData.revision;
 
-        return Page.updatePage(pageData, pageBody, req.user, grantOption);
+        return Page.updatePage(pageData, pageBody, req.user, options);
       })
       .catch(function(err) {
-        debug('error on _api/pages.update', err);
+        logger.error('error on _api/pages.update', err);
         res.json(ApiResponse.error(err));
       });
 
-    const result = { page: updatedPage.toObject() };
+    const result = { page: serializeToObj(updatedPage) };
     result.page.lastUpdateUser = User.filterToPublicFields(updatedPage.lastUpdateUser);
     res.json(ApiResponse.success(result));
 
@@ -910,7 +932,7 @@ module.exports = function(crowi, app) {
 
     pageFinder.then(function(pageData) {
       const result = {};
-      result.page = pageData;
+      result.page = pageData;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
@@ -1036,22 +1058,25 @@ module.exports = function(crowi, app) {
   api.remove = function(req, res) {
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
+    const socketClientId = req.body.socketClientId || undefined;
 
     // get completely flag
     const isCompletely = (req.body.completely != null);
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
 
+    const options = {socketClientId};
+
     Page.findPageByIdAndGrantedUser(pageId, req.user)
       .then(function(pageData) {
         debug('Delete page', pageData._id, pageData.path);
 
         if (isCompletely) {
           if (isRecursively) {
-            return Page.completelyDeletePageRecursively(pageData, req.user);
+            return Page.completelyDeletePageRecursively(pageData, req.user, options);
           }
           else {
-            return Page.completelyDeletePage(pageData, req.user);
+            return Page.completelyDeletePage(pageData, req.user, options);
           }
         }
 
@@ -1062,16 +1087,16 @@ module.exports = function(crowi, app) {
         }
 
         if (isRecursively) {
-          return Page.deletePageRecursively(pageData, req.user);
+          return Page.deletePageRecursively(pageData, req.user, options);
         }
         else {
-          return Page.deletePage(pageData, req.user);
+          return Page.deletePage(pageData, req.user, options);
         }
       })
       .then(function(data) {
         debug('Page deleted', data.path);
         const result = {};
-        result.page = data;
+        result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
         res.json(ApiResponse.success(result));
         return data;
@@ -1081,7 +1106,7 @@ module.exports = function(crowi, app) {
         return globalNotificationService.notifyPageDelete(page);
       })
       .catch(function(err) {
-        debug('Error occured while get setting', err, err.stack);
+        logger.error('Error occured while get setting', err, err.stack);
         return res.json(ApiResponse.error('Failed to delete page.'));
       });
   };
@@ -1093,8 +1118,9 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} page_id Page Id.
    */
-  api.revertRemove = function(req, res) {
+  api.revertRemove = function(req, res, options) {
     const pageId = req.body.page_id;
+    const socketClientId = req.body.socketClientId || undefined;
 
     // get recursively flag
     const isRecursively = (req.body.recursively !== undefined);
@@ -1103,19 +1129,18 @@ module.exports = function(crowi, app) {
     .then(function(pageData) {
 
       if (isRecursively) {
-        return Page.revertDeletedPageRecursively(pageData, req.user);
+        return Page.revertDeletedPageRecursively(pageData, req.user, {socketClientId});
       }
       else {
-        return Page.revertDeletedPage(pageData, req.user);
+        return Page.revertDeletedPage(pageData, req.user, {socketClientId});
       }
     }).then(function(data) {
-      debug('Complete to revert deleted page', data.path);
       const result = {};
-      result.page = data;
+      result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
-      debug('Error occured while get setting', err, err.stack);
+      logger.error('Error occured while get setting', err, err.stack);
       return res.json(ApiResponse.error('Failed to revert deleted page.'));
     });
   };
@@ -1138,6 +1163,7 @@ module.exports = function(crowi, app) {
     const options = {
       createRedirectPage: req.body.create_redirect || 0,
       moveUnderTrees: req.body.move_trees || 0,
+      socketClientId: +req.body.socketClientId || undefined,
     };
     const isRecursiveMove = req.body.move_recursively || 0;
 
@@ -1169,7 +1195,7 @@ module.exports = function(crowi, app) {
       })
       .then(function() {
         const result = {};
-        result.page = page;
+        result.page = page;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
         return res.json(ApiResponse.success(result));
       })
@@ -1226,7 +1252,7 @@ module.exports = function(crowi, app) {
     }).then(function(data) {
       debug('Redirect Page deleted', data.path);
       const result = {};
-      result.page = data;
+      result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {

+ 2 - 0
lib/views/widget/not_found_content.html

@@ -45,4 +45,6 @@
     {% include '../_form.html' %}
 
   </div>
+
+  <div id="page-status-alert"></div>
 </div>

+ 86 - 21
resource/js/app.js

@@ -46,6 +46,8 @@ if (!window) {
 const userlang = $('body').data('userlang');
 const i18n = i18nFactory(userlang);
 
+const socket = io();
+
 // setup xss library
 const xss = new Xss();
 window.xss = xss;
@@ -89,6 +91,7 @@ crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').text
 if (isLoggedin) {
   crowi.fetchUsers();
 }
+const socketClientId = crowi.getSocketClientId();
 
 const crowiRenderer = new GrowiRenderer(crowi, null, {
   mode: 'page',
@@ -173,30 +176,35 @@ const saveWithShortcutSuccessHandler = function(page) {
 
   pageId = page._id;
   pageRevisionId = page.revision._id;
+  pageRevisionIdHackmdSynced = page.revisionHackmdSynced;
 
   // set page id to SavePageControls
   componentInstances.savePageControls.setPageId(pageId);
 
-  // re-render Page component
+  // Page component
   if (componentInstances.page != null) {
     componentInstances.page.setMarkdown(page.revision.body);
   }
-  // re-render PageEditor component
+  // PageEditor component
   if (componentInstances.pageEditor != null) {
     const updateEditorValue = (editorMode !== 'builtin');
     componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
   }
-  // set revision id to PageEditorByHackmd
+  // PageEditorByHackmd component
   if (componentInstances.pageEditorByHackmd != null) {
-    componentInstances.pageEditorByHackmd.setRevisionId(pageRevisionId);
-
-    const updateEditorValue = (editorMode !== 'hackmd');
-    componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, updateEditorValue);
+    // clear state of PageEditorByHackmd
+    componentInstances.pageEditorByHackmd.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
+    // reset
+    if (editorMode !== 'hackmd') {
+      componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, false);
+      componentInstances.pageEditorByHackmd.reset();
+    }
   }
-  // clear state of PageStatusAlert
+  // PageStatusAlert component
   const pageStatusAlert = componentInstances.pageStatusAlert;
+  // clear state of PageStatusAlert
   if (componentInstances.pageStatusAlert != null) {
-    pageStatusAlert.clearStatus(pageRevisionId);
+    pageStatusAlert.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
   }
 };
 
@@ -217,15 +225,25 @@ const saveWithShortcut = function(markdown) {
     // do nothing
     return;
   }
+
+  let revisionId = pageRevisionId;
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+  options.socketClientId = socketClientId;
+
+  if (editorMode === 'hackmd') {
+    // set option to sync
+    options.isSyncRevisionToHackmd = true;
+    // use revisionId of PageEditorByHackmd
+    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+  }
 
   let promise = undefined;
   if (pageId == null) {
     promise = crowi.createPage(pagePath, markdown, options);
   }
   else {
-    promise = crowi.updatePage(pageId, pageRevisionId, markdown, options);
+    promise = crowi.updatePage(pageId, revisionId, markdown, options);
   }
 
   promise
@@ -244,16 +262,24 @@ const saveWithSubmitButton = function() {
     // do nothing
     return;
   }
+
+  let revisionId = pageRevisionId;
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+  options.socketClientId = socketClientId;
 
   let promise = undefined;
-  // get markdown
   if (editorMode === 'builtin') {
+    // get markdown
     promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
   }
   else {
+    // get markdown
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
+    // use revisionId of PageEditorByHackmd
+    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    // set option to sync
+    options.isSyncRevisionToHackmd = true;
   }
   // create or update
   if (pageId == null) {
@@ -263,7 +289,7 @@ const saveWithSubmitButton = function() {
   }
   else {
     promise = promise.then(markdown => {
-      return crowi.updatePage(pageId, pageRevisionId, markdown, options);
+      return crowi.updatePage(pageId, revisionId, markdown, options);
     });
   }
 
@@ -350,6 +376,7 @@ if (writeCommentElem) {
       crowiOriginRenderer={crowiRenderer}
       pageId={pageId}
       pagePath={pagePath}
+      revisionId={pageRevisionId}
       onPostComplete={postCompleteHandler}
       editorOptions={editorOptions}
       slackChannels = {slackChannels}/>,
@@ -386,7 +413,7 @@ if (pageStatusAlertElem) {
               pageStatusAlert = elem.getWrappedInstance();
             }
           }}
-          revisionId={pageRevisionId} hasDraftOnHackmd={hasDraftOnHackmd} />
+          revisionId={pageRevisionId} revisionIdHackmdSynced={pageRevisionIdHackmdSynced} hasDraftOnHackmd={hasDraftOnHackmd} />
     </I18nextProvider>,
     pageStatusAlertElem
   );
@@ -426,27 +453,65 @@ if (customHeaderEditorElem != null) {
 }
 
 // notification from websocket
-const socket = io();
+function updatePageStatusAlert(page, user) {
+  const pageStatusAlert = componentInstances.pageStatusAlert;
+  if (pageStatusAlert != null) {
+    const revisionId = page.revision._id;
+    const revisionIdHackmdSynced = page.revisionHackmdSynced;
+    pageStatusAlert.setRevisionId(revisionId, revisionIdHackmdSynced);
+    pageStatusAlert.setLastUpdateUsername(user.name);
+  }
+}
+socket.on('page:create', function(data) {
+  console.log(data);
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
 socket.on('page:update', function(data) {
-  // skip own trigger
-  if (data.user.username === crowi.me) {
+  console.log(data);
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
     return;
   }
+
   if (data.page.path == pagePath) {
     // update PageStatusAlert
-    const pageStatusAlert = componentInstances.pageStatusAlert;
-    if (pageStatusAlert != null) {
-      pageStatusAlert.setLatestRevisionId(data.page._id.toString());
-      pageStatusAlert.setLastUpdateUsername(data.user.name);
-    }
+    updatePageStatusAlert(data.page, data.user);
     // update PageEditorByHackmd
     const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
     if (pageEditorByHackmd != null) {
+      const page = data.page;
+      pageEditorByHackmd.setRevisionId(page.revision._id, page.revisionHackmdSynced);
       pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
     }
   }
 });
+socket.on('page:delete', function(data) {
+  console.log(data);
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
 socket.on('page:editingWithHackmd', function(data) {
+  console.log(data);
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
   if (data.page.path == pagePath) {
     // update PageStatusAlert
     const pageStatusAlert = componentInstances.pageStatusAlert;

+ 1 - 0
resource/js/components/PageComment/CommentForm.js

@@ -313,6 +313,7 @@ CommentForm.propTypes = {
   crowiOriginRenderer: PropTypes.object.isRequired,
   onPostComplete: PropTypes.func,
   pageId: PropTypes.string,
+  revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   editorOptions: PropTypes.object,
   slackChannels: PropTypes.string,

+ 58 - 10
resource/js/components/PageEditorByHackmd.jsx

@@ -17,7 +17,9 @@ export default class PageEditorByHackmd extends React.PureComponent {
       markdown: this.props.markdown,
       isInitialized: false,
       isInitializing: false,
+      initialRevisionId: this.props.revisionId,
       revisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
       pageIdOnHackmd: this.props.pageIdOnHackmd,
       hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
@@ -56,12 +58,36 @@ export default class PageEditorByHackmd extends React.PureComponent {
     }
   }
 
+  /**
+   * reset initialized status
+   */
+  reset() {
+    this.setState({ isInitialized: false });
+  }
+
+  /**
+   * clear revision status (invoked when page is updated by myself)
+   */
+  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
+    this.setState({
+      initialRevisionId: updatedRevisionId,
+      revisionId: updatedRevisionId,
+      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
+      isDraftUpdatingInRealtime: false,
+    });
+  }
+
   /**
    * update revisionId of state
    * @param {string} revisionId
+   * @param {string} revisionIdHackmdSynced
    */
-  setRevisionId(revisionId) {
-    this.setState({revisionId});
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
+  }
+
+  getRevisionIdHackmdSynced() {
+    return this.state.revisionIdHackmdSynced;
   }
 
   /**
@@ -69,7 +95,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * @param {bool} hasDraftOnHackmd
    */
   setHasDraftOnHackmd(hasDraftOnHackmd) {
-    this.setState({hasDraftOnHackmd});
+    this.setState({ hasDraftOnHackmd });
   }
 
   getHackmdUri() {
@@ -171,8 +197,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
     const hackmdUri = this.getHackmdUri();
 
     const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
-    const isRevisionMatch = (this.state.revisionId === this.props.revisionIdHackmdSynced);
-    const isResume = isPageExistsOnHackmd && isRevisionMatch && this.state.hasDraftOnHackmd;
+    const isResume = isPageExistsOnHackmd && this.state.hasDraftOnHackmd;
 
     if (this.state.isInitialized) {
       return (
@@ -190,8 +215,13 @@ export default class PageEditorByHackmd extends React.PureComponent {
       );
     }
 
+    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
+    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+
     let content = undefined;
-    // HackMD is not setup
+    /*
+     * HackMD is not setup
+     */
     if (hackmdUri == null) {
       content = (
         <div>
@@ -199,7 +229,11 @@ export default class PageEditorByHackmd extends React.PureComponent {
         </div>
       );
     }
+    /*
+     * Resume to edit or discard changes
+     */
     else if (isResume) {
+      const revisionIdHackmdSynced = this.state.revisionIdHackmdSynced;
       const title = (
         <React.Fragment>
           <span className="btn-label"><i className="icon-control-end"></i></span>
@@ -209,23 +243,37 @@ export default class PageEditorByHackmd extends React.PureComponent {
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <div className="text-center hackmd-resume-button-container mb-3">
-            <SplitButton id='split-button-resume-hackmd' title={title} bsStyle="success" bsSize="large" className="btn-resume waves-effect waves-light" onClick={() => this.resumeToEdit()}>
+            <SplitButton id='split-button-resume-hackmd' title={title} bsStyle="success" bsSize="large"
+                className="btn-resume waves-effect waves-light" onClick={() => this.resumeToEdit()}>
               <MenuItem className="text-center" onClick={() => this.discardChanges()}>
                 <i className="icon-control-rewind"></i> Discard changes
               </MenuItem>
             </SplitButton>
           </div>
-          <p className="text-center">Click to edit from the previous continuation.</p>
+          <p className="text-center">Click to edit from the previous continuation<br />or <span className="text-danger">Discard changes</span> from pull down.</p>
+          { isHackmdDocumentOutdated &&
+            <div className="panel panel-warning mt-5">
+              <div className="panel-heading"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
+              <div className="panel-body text-center">
+                The current draft on HackMD is based on&nbsp;
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.<br />
+                <span className="text-danger">Discard it</span> to start to edit with current revision.
+              </div>
+            </div>
+          }
         </div>
       );
     }
+    /*
+     * Start to edit
+     */
     else {
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <div className="text-center hackmd-start-button-container mb-3">
-            <button className="btn btn-info btn-lg waves-effect waves-light" type="button"
-                onClick={() => this.startToEdit()} disabled={this.state.isInitializing}>
+            <button className="btn btn-info btn-lg waves-effect waves-light" type="button" disabled={isRevisionOutdated || this.state.isInitializing}
+                onClick={() => this.startToEdit()}>
               <span className="btn-label"><i className="icon-paper-plane"></i></span>
               Start to edit with HackMD
             </button>

+ 26 - 19
resource/js/components/PageStatusAlert.jsx

@@ -17,8 +17,9 @@ class PageStatusAlert extends React.Component {
     super(props);
 
     this.state = {
+      initialRevisionId: this.props.revisionId,
       revisionId: this.props.revisionId,
-      latestRevisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
       lastUpdateUsername: undefined,
       hasDraftOnHackmd: this.props.hasDraftOnHackmd,
       isDraftUpdatingInRealtime: false,
@@ -30,23 +31,24 @@ class PageStatusAlert extends React.Component {
   }
 
   /**
-   * clear status (invoked when page is updated)
+   * clear status (invoked when page is updated by myself)
    */
-  clearStatus(updatedRevisionId) {
+  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
     this.setState({
+      initialRevisionId: updatedRevisionId,
       revisionId: updatedRevisionId,
-      latestRevisionId: updatedRevisionId,
+      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
       hasDraftOnHackmd: false,
       isDraftUpdatingInRealtime: false,
     });
   }
 
-  setLatestRevisionId(revisionId) {
-    this.setState({latestRevisionId: revisionId});
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
   }
 
   setLastUpdateUsername(lastUpdateUsername) {
-    this.setState({lastUpdateUsername});
+    this.setState({ lastUpdateUsername });
   }
 
   setHasDraftOnHackmd(hasDraftOnHackmd) {
@@ -58,7 +60,7 @@ class PageStatusAlert extends React.Component {
 
   renderSomeoneEditingAlert() {
     return (
-      <div className="myadmin-alert alert-success myadmin-alert-bottom alertbottom2" style={{display: 'block'}}>
+      <div className="alert-hackmd-someone-editing myadmin-alert alert-success myadmin-alert-bottom alertbottom2">
         <i className="icon-fw icon-people"></i>
         Someone editing this page on HackMD
         &nbsp;
@@ -73,7 +75,7 @@ class PageStatusAlert extends React.Component {
 
   renderDraftExistsAlert(isRealtime) {
     return (
-      <div className="myadmin-alert alert-success myadmin-alert-bottom alertbottom2" style={{display: 'block'}}>
+      <div className="alert-hackmd-draft-exists myadmin-alert alert-success myadmin-alert-bottom alertbottom2">
         <i className="icon-fw icon-pencil"></i>
         This page has a draft on HackMD
         &nbsp;
@@ -92,7 +94,7 @@ class PageStatusAlert extends React.Component {
     const label2 = t('Load latest');
 
     return (
-      <div className="myadmin-alert alert-warning myadmin-alert-bottom alertbottom2" style={{display: 'block'}}>
+      <div className="alert-revision-outdated myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
         <i className="icon-fw icon-bulb"></i>
         {this.state.lastUpdateUsername} {label1}
         &nbsp;
@@ -108,15 +110,20 @@ class PageStatusAlert extends React.Component {
   render() {
     let content = <React.Fragment></React.Fragment>;
 
-    if (this.state.isDraftUpdatingInRealtime) {
-      content = this.renderSomeoneEditingAlert();
-    }
-    else if (this.state.hasDraftOnHackmd) {
-      content = this.renderDraftExistsAlert();
-    }
-    else if (this.state.revisionId !== this.state.latestRevisionId) {
+    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
+    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+
+    if (isHackmdDocumentOutdated && isRevisionOutdated) {
       content = this.renderUpdatedAlert();
     }
+    else {
+      if (this.state.isDraftUpdatingInRealtime) {
+        content = this.renderSomeoneEditingAlert();
+      }
+      else if (this.state.hasDraftOnHackmd) {
+        content = this.renderDraftExistsAlert();
+      }
+    }
 
     return content;
   }
@@ -125,9 +132,9 @@ class PageStatusAlert extends React.Component {
 PageStatusAlert.propTypes = {
   t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
-  revisionId: PropTypes.string.isRequired,
   hasDraftOnHackmd: PropTypes.bool.isRequired,
-  latestRevisionId: PropTypes.string,
+  revisionId: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
 };
 
 PageStatusAlert.defaultProps = {

+ 3 - 1
resource/js/legacy/crowi.js

@@ -379,10 +379,12 @@ $(function() {
       nameValueMap[obj.name] = obj.value;
     });
 
+    const data = $(this).serialize() + `&socketClientId=${crowi.getSocketClientId()}`;
+
     $.ajax({
       type: 'POST',
       url: '/_api/pages.rename',
-      data: $(this).serialize(),
+      data: data,
       dataType: 'json'
     })
     .done(function(res) {

+ 5 - 0
resource/js/util/Crowi.js

@@ -24,6 +24,7 @@ export default class Crowi {
     this.location = window.location || {};
     this.document = window.document || {};
     this.localStorage = window.localStorage || {};
+    this.socketClientId = Math.floor(Math.random() * 100000);
     this.pageEditor = undefined;
 
     this.fetchUsers = this.fetchUsers.bind(this);
@@ -68,6 +69,10 @@ export default class Crowi {
     this.pageEditor = pageEditor;
   }
 
+  getSocketClientId() {
+    return this.socketClientId;
+  }
+
   getEmojiStrategy() {
     return emojiStrategy;
   }

+ 19 - 4
resource/styles/scss/_on-edit.scss

@@ -53,6 +53,13 @@ body.on-edit {
     display: none;
   }
 
+  // hide hackmd related alert
+  &.hackmd #page-status-alert {
+    .alert-hackmd-someone-editing, .alert-hackmd-draft-exists {
+      display: none;
+    }
+  }
+
   /*****************
    * Expand Editor
    *****************/
@@ -181,6 +188,18 @@ body.on-edit {
     }
   }
 
+  #page-status-alert .myadmin-alert {
+    position: absolute;
+    opacity: 0.8;
+    bottom: 42px;
+    right: 3px;
+    left: 3px;
+    box-shadow: 2px 2px 5px #666;
+
+    &:hover {
+      opacity: 1;
+    }
+  }
 
   &.builtin-editor {
 
@@ -291,10 +310,6 @@ body.on-edit {
       }
     }
 
-    // hide #page-status-alert
-    #page-status-alert {
-      display: none;
-    }
   }
 
 }

+ 4 - 0
resource/styles/scss/_page.scss

@@ -98,6 +98,10 @@
     padding: 10px 15px;
   }
 
+  // show PageStatusAlert in default
+  #page-status-alert .myadmin-alert {
+    display: block;
+  }
 }
 
 .main-container .main .content-main .revision-history { // {{{