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

Merge branch 'master' into feat/importer

yusuketk 7 лет назад
Родитель
Сommit
baba8d027b

+ 1 - 0
CHANGES.md

@@ -4,6 +4,7 @@ CHANGES
 ## 3.2.0-RC
 ## 3.2.0-RC
 
 
 * Feature: HackMD integration so that user can simultaneously edit with multiple people
 * Feature: HackMD integration so that user can simultaneously edit with multiple people
+* Feature: Login with Twitter Account
 * Fix: The Initial scroll position is wrong when reloading the page
 * Fix: The Initial scroll position is wrong when reloading the page
 
 
 ## 3.1.14
 ## 3.1.14

+ 4 - 1
config/logger/config.dev.js

@@ -1,7 +1,7 @@
 module.exports = {
 module.exports = {
   default: 'info',
   default: 'info',
 
 
-  //// configure level for name
+  //// configure level for server
   // 'express:*': 'debug',
   // 'express:*': 'debug',
   // 'growi:*': 'debug',
   // 'growi:*': 'debug',
   'growi:crowi': 'debug',
   'growi:crowi': 'debug',
@@ -15,4 +15,7 @@ module.exports = {
   // 'growi:routes:page': 'debug',
   // 'growi:routes:page': 'debug',
   // 'growi-plugin:*': 'debug',
   // 'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
   // 'growi:InterceptorManager': 'debug',
+
+  //// configure level for client
+  'growi:app': 'debug',
 };
 };

+ 75 - 80
lib/models/page.js

@@ -950,46 +950,25 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.pushRevision = function(pageData, newRevision, user) {
-    var isCreate = false;
-    if (pageData.revision === undefined) {
-      debug('pushRevision on Create');
-      isCreate = true;
-    }
+  pageSchema.statics.pushRevision = async function(pageData, newRevision, user) {
+    await newRevision.save();
+    debug('Successfully saved new revision', newRevision);
 
 
-    return new Promise(function(resolve, reject) {
-      newRevision.save(function(err, newRevision) {
-        if (err) {
-          debug('Error on saving revision', err);
-          return reject(err);
-        }
+    pageData.revision = newRevision;
+    pageData.lastUpdateUser = user;
+    pageData.updatedAt = Date.now();
 
 
-        debug('Successfully saved new revision', newRevision);
-        pageData.revision = newRevision;
-        pageData.lastUpdateUser = user;
-        pageData.updatedAt = Date.now();
-        pageData.save(function(err, data) {
-          if (err) {
-            // todo: remove new revision?
-            debug('Error on save page data (after push revision)', err);
-            return reject(err);
-          }
-
-          resolve(data);
-          if (!isCreate) {
-            debug('pushRevision on Update');
-          }
-        });
-      });
-    });
+    return pageData.save();
   };
   };
 
 
-  pageSchema.statics.create = function(path, body, user, options) {
+  pageSchema.statics.create = function(path, body, user, options = {}) {
     const Page = this
     const Page = this
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
       , format = options.format || 'markdown'
       , redirectTo = options.redirectTo || null
       , redirectTo = options.redirectTo || null
-      , grantUserGroupId = options.grantUserGroupId || null;
+      , grantUserGroupId = options.grantUserGroupId || null
+      , socketClientId = options.socketClientId || null
+      ;
 
 
     let grant = options.grant || GRANT_PUBLIC;
     let grant = options.grant || GRANT_PUBLIC;
 
 
@@ -1033,34 +1012,47 @@ module.exports = function(crowi) {
         return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
         return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
       })
       })
       .then(() => {
       .then(() => {
-        pageEvent.emit('create', savedPage, user);
+        if (socketClientId != null) {
+          pageEvent.emit('create', savedPage, user, socketClientId);
+        }
         return savedPage;
         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')
       , Revision = crowi.model('Revision')
       , grant = options.grant || null
       , grant = options.grant || null
       , grantUserGroupId = options.grantUserGroupId || null
       , grantUserGroupId = options.grantUserGroupId || null
+      , isSyncRevisionToHackmd = options.isSyncRevisionToHackmd
+      , socketClientId = options.socketClientId || null
       ;
       ;
+
     // update existing page
     // 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 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) {
     if (grant != null) {
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       debug('Page grant update:', grantData);
       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;
     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)
       , newPath = Page.getDeletedPageName(pageData.path)
       , isTrashed = checkIfTrashed(pageData.path)
       , isTrashed = checkIfTrashed(pageData.path)
+      , socketClientId = options.socketClientId || null
       ;
       ;
 
 
     if (Page.isDeletableName(pageData.path)) {
     if (Page.isDeletableName(pageData.path)) {
@@ -1068,13 +1060,13 @@ module.exports = function(crowi) {
         return Page.completelyDeletePage(pageData, user, options);
         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 {
     else {
       return Promise.reject('Page is not deletable.');
       return Promise.reject('Page is not deletable.');
@@ -1086,11 +1078,11 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.statics.deletePageRecursively = function(pageData, user, options) {
   pageSchema.statics.deletePageRecursively = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , path = pageData.path
       , path = pageData.path
-      , options = options || {}
-      , isTrashed = checkIfTrashed(pageData.path);
+      , isTrashed = checkIfTrashed(pageData.path)
       ;
       ;
+    options = options || {};
 
 
     if (isTrashed) {
     if (isTrashed) {
       return Page.completelyDeletePageRecursively(pageData, user, options);
       return Page.completelyDeletePageRecursively(pageData, user, options);
@@ -1109,7 +1101,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
   pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , newPath = Page.getRevertDeletedPageName(pageData.path)
       , newPath = Page.getRevertDeletedPageName(pageData.path)
       ;
       ;
 
 
@@ -1123,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.');
           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) {
       }).then(function(done) {
         return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user});
         return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user});
       }).then(function(done) {
       }).then(function(done) {
@@ -1138,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
       , path = pageData.path
-      , options = options || { includeDeletedPage: true}
       ;
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       Page
       Page
@@ -1162,15 +1154,16 @@ module.exports = function(crowi) {
   /**
   /**
    * This is danger.
    * 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
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Bookmark = crowi.model('Bookmark')
+    const Bookmark = crowi.model('Bookmark')
       , Attachment = crowi.model('Attachment')
       , Attachment = crowi.model('Attachment')
       , Comment = crowi.model('Comment')
       , Comment = crowi.model('Comment')
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
       , PageGroupRelation = crowi.model('PageGroupRelation')
       , PageGroupRelation = crowi.model('PageGroupRelation')
       , Page = this
       , Page = this
       , pageId = pageData._id
       , pageId = pageData._id
+      , socketClientId = options.socketClientId || null
       ;
       ;
 
 
     debug('Completely delete', pageData.path);
     debug('Completely delete', pageData.path);
@@ -1191,18 +1184,20 @@ module.exports = function(crowi) {
       }).then(function(done) {
       }).then(function(done) {
         return PageGroupRelation.removeAllByPage(pageData);
         return PageGroupRelation.removeAllByPage(pageData);
       }).then(function(done) {
       }).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);
         resolve(pageData);
       }).catch(reject);
       }).catch(reject);
     });
     });
   };
   };
 
 
-  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options) {
+  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options = {}) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Page = this
+    const Page = this
       , path = pageData.path
       , path = pageData.path
-      , options = options || { includeDeletedPage: true }
       ;
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       Page
       Page
@@ -1270,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
     const Page = this
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
       , path = pageData.path
       , path = pageData.path
       , createRedirectPage = options.createRedirectPage || 0
       , createRedirectPage = options.createRedirectPage || 0
+      , socketClientId = options.socketClientId || null
       ;
       ;
 
 
     // sanitize path
     // sanitize path
     newPagePath = crowi.xss.process(newPagePath);
     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 を変更
         // reivisions の path を変更
-        return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
-      })
-      .then(function(data) {
-        pageData.path = newPagePath;
+    await Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
 
 
         if (createRedirectPage) {
         if (createRedirectPage) {
           const body = 'redirect ' + newPagePath;
           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) {
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
@@ -1337,11 +1331,17 @@ module.exports = function(crowi) {
   /**
   /**
    * update revisionHackmdSynced
    * update revisionHackmdSynced
    * @param {Page} pageData
    * @param {Page} pageData
+   * @param {bool} isSave whether save or not
    */
    */
-  pageSchema.statics.syncRevisionToHackmd = function(pageData) {
+  pageSchema.statics.syncRevisionToHackmd = function(pageData, isSave = true) {
     pageData.revisionHackmdSynced = pageData.revision;
     pageData.revisionHackmdSynced = pageData.revision;
     pageData.hasDraftOnHackmd = false;
     pageData.hasDraftOnHackmd = false;
-    return pageData.save();
+
+    let returnData = pageData;
+    if (isSave) {
+      returnData = pageData.save();
+    }
+    return returnData;
   };
   };
 
 
   /**
   /**
@@ -1352,12 +1352,7 @@ module.exports = function(crowi) {
    * @param {Boolean} newValue
    * @param {Boolean} newValue
    */
    */
   pageSchema.statics.updateHasDraftOnHackmd = async function(pageData, 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
       // do nothing when hasDraftOnHackmd equals to newValue
       return;
       return;
     }
     }

+ 6 - 0
lib/routes/hackmd.js

@@ -9,6 +9,7 @@ const ApiResponse = require('../util/apiResponse');
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const config = crowi.getConfig();
   const config = crowi.getConfig();
   const Page = crowi.models.Page;
   const Page = crowi.models.Page;
+  const pageEvent = crowi.event('page');
 
 
   // load GROWI agent script for HackMD
   // load GROWI agent script for HackMD
   const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
   const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
@@ -18,6 +19,10 @@ module.exports = function(crowi, app) {
   let agentScriptContentTpl = undefined;
   let agentScriptContentTpl = undefined;
   let stylesScriptContentTpl = undefined;
   let stylesScriptContentTpl = undefined;
 
 
+  // init 'saveOnHackmd' event
+  pageEvent.on('saveOnHackmd', function(page) {
+    crowi.getIo().sockets.emit('page:editingWithHackmd', {page});
+  });
 
 
   /**
   /**
    * GET /_hackmd/load-agent
    * GET /_hackmd/load-agent
@@ -172,6 +177,7 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       await Page.updateHasDraftOnHackmd(page, true);
       await Page.updateHasDraftOnHackmd(page, true);
+      pageEvent.emit('saveOnHackmd', page);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
     }
     }
     catch (err) {
     catch (err) {

+ 52 - 27
lib/routes/page.js

@@ -23,11 +23,28 @@ module.exports = function(crowi, app) {
   // register page events
   // register page events
 
 
   const pageEvent = crowi.event('page');
   const pageEvent = crowi.event('page');
-  pageEvent.on('update', function(page, user) {
-    crowi.getIo().sockets.emit('page edited', {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) {
   function getPathFromRequest(req) {
     const path = '/' + (req.params[0] || '');
     const path = '/' + (req.params[0] || '');
     return path.replace(/\.md$/, '');
     return path.replace(/\.md$/, '');
@@ -776,6 +793,7 @@ module.exports = function(crowi, app) {
     const grantUserGroupId = req.body.grantUserGroupId || 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 slackChannels = req.body.slackChannels || null;
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     if (body === null || pagePath === null) {
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
       return res.json(ApiResponse.error('Parameters body and path are required.'));
@@ -788,13 +806,14 @@ module.exports = function(crowi, app) {
           throw new Error('Page exists');
           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) {
       .catch(function(err) {
         return res.json(ApiResponse.error(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.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
@@ -833,8 +852,10 @@ module.exports = function(crowi, app) {
     const revisionId = req.body.revision_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
     const grant = req.body.grant || null;
     const grantUserGroupId = req.body.grantUserGroupId || 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 slackChannels = req.body.slackChannels || null;
+    const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd;     // cast to boolean
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     if (pageId === null || pageBody === null) {
     if (pageId === null || pageBody === null) {
       return res.json(ApiResponse.error('page_id and body are required.'));
       return res.json(ApiResponse.error('page_id and body are required.'));
@@ -844,28 +865,28 @@ module.exports = function(crowi, app) {
     let updatedPage = await Page.findPageByIdAndGrantedUser(pageId, req.user)
     let updatedPage = await Page.findPageByIdAndGrantedUser(pageId, req.user)
       .then(function(pageData) {
       .then(function(pageData) {
         if (pageData && revisionId !== null && !pageData.isUpdatable(revisionId)) {
         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) {
         if (grant != null) {
-          grantOption.grant = grant;
+          options.grant = grant;
         }
         }
         if (grantUserGroupId != null) {
         if (grantUserGroupId != null) {
-          grantOption.grantUserGroupId = grantUserGroupId;
+          options.grantUserGroupId = grantUserGroupId;
         }
         }
 
 
         // store previous revision
         // store previous revision
         previousRevision = pageData.revision;
         previousRevision = pageData.revision;
 
 
-        return Page.updatePage(pageData, pageBody, req.user, grantOption);
+        return Page.updatePage(pageData, pageBody, req.user, options);
       })
       })
       .catch(function(err) {
       .catch(function(err) {
-        debug('error on _api/pages.update', err);
+        logger.error('error on _api/pages.update', err);
         res.json(ApiResponse.error(err));
         res.json(ApiResponse.error(err));
       });
       });
 
 
-    const result = { page: updatedPage.toObject() };
+    const result = { page: serializeToObj(updatedPage) };
     result.page.lastUpdateUser = User.filterToPublicFields(updatedPage.lastUpdateUser);
     result.page.lastUpdateUser = User.filterToPublicFields(updatedPage.lastUpdateUser);
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
@@ -911,7 +932,7 @@ module.exports = function(crowi, app) {
 
 
     pageFinder.then(function(pageData) {
     pageFinder.then(function(pageData) {
       const result = {};
       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));
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
     }).catch(function(err) {
@@ -1037,22 +1058,25 @@ module.exports = function(crowi, app) {
   api.remove = function(req, res) {
   api.remove = function(req, res) {
     const pageId = req.body.page_id;
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
     const previousRevision = req.body.revision_id || null;
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     // get completely flag
     // get completely flag
     const isCompletely = (req.body.completely != null);
     const isCompletely = (req.body.completely != null);
     // get recursively flag
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
     const isRecursively = (req.body.recursively != null);
 
 
+    const options = {socketClientId};
+
     Page.findPageByIdAndGrantedUser(pageId, req.user)
     Page.findPageByIdAndGrantedUser(pageId, req.user)
       .then(function(pageData) {
       .then(function(pageData) {
         debug('Delete page', pageData._id, pageData.path);
         debug('Delete page', pageData._id, pageData.path);
 
 
         if (isCompletely) {
         if (isCompletely) {
           if (isRecursively) {
           if (isRecursively) {
-            return Page.completelyDeletePageRecursively(pageData, req.user);
+            return Page.completelyDeletePageRecursively(pageData, req.user, options);
           }
           }
           else {
           else {
-            return Page.completelyDeletePage(pageData, req.user);
+            return Page.completelyDeletePage(pageData, req.user, options);
           }
           }
         }
         }
 
 
@@ -1063,16 +1087,16 @@ module.exports = function(crowi, app) {
         }
         }
 
 
         if (isRecursively) {
         if (isRecursively) {
-          return Page.deletePageRecursively(pageData, req.user);
+          return Page.deletePageRecursively(pageData, req.user, options);
         }
         }
         else {
         else {
-          return Page.deletePage(pageData, req.user);
+          return Page.deletePage(pageData, req.user, options);
         }
         }
       })
       })
       .then(function(data) {
       .then(function(data) {
         debug('Page deleted', data.path);
         debug('Page deleted', data.path);
         const result = {};
         const result = {};
-        result.page = data;
+        result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
 
         res.json(ApiResponse.success(result));
         res.json(ApiResponse.success(result));
         return data;
         return data;
@@ -1082,7 +1106,7 @@ module.exports = function(crowi, app) {
         return globalNotificationService.notifyPageDelete(page);
         return globalNotificationService.notifyPageDelete(page);
       })
       })
       .catch(function(err) {
       .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.'));
         return res.json(ApiResponse.error('Failed to delete page.'));
       });
       });
   };
   };
@@ -1094,8 +1118,9 @@ module.exports = function(crowi, app) {
    *
    *
    * @apiParam {String} page_id Page Id.
    * @apiParam {String} page_id Page Id.
    */
    */
-  api.revertRemove = function(req, res) {
+  api.revertRemove = function(req, res, options) {
     const pageId = req.body.page_id;
     const pageId = req.body.page_id;
+    const socketClientId = req.body.socketClientId || undefined;
 
 
     // get recursively flag
     // get recursively flag
     const isRecursively = (req.body.recursively !== undefined);
     const isRecursively = (req.body.recursively !== undefined);
@@ -1104,19 +1129,18 @@ module.exports = function(crowi, app) {
     .then(function(pageData) {
     .then(function(pageData) {
 
 
       if (isRecursively) {
       if (isRecursively) {
-        return Page.revertDeletedPageRecursively(pageData, req.user);
+        return Page.revertDeletedPageRecursively(pageData, req.user, {socketClientId});
       }
       }
       else {
       else {
-        return Page.revertDeletedPage(pageData, req.user);
+        return Page.revertDeletedPage(pageData, req.user, {socketClientId});
       }
       }
     }).then(function(data) {
     }).then(function(data) {
-      debug('Complete to revert deleted page', data.path);
       const result = {};
       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));
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
     }).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.'));
       return res.json(ApiResponse.error('Failed to revert deleted page.'));
     });
     });
   };
   };
@@ -1139,6 +1163,7 @@ module.exports = function(crowi, app) {
     const options = {
     const options = {
       createRedirectPage: req.body.create_redirect || 0,
       createRedirectPage: req.body.create_redirect || 0,
       moveUnderTrees: req.body.move_trees || 0,
       moveUnderTrees: req.body.move_trees || 0,
+      socketClientId: +req.body.socketClientId || undefined,
     };
     };
     const isRecursiveMove = req.body.move_recursively || 0;
     const isRecursiveMove = req.body.move_recursively || 0;
 
 
@@ -1170,7 +1195,7 @@ module.exports = function(crowi, app) {
       })
       })
       .then(function() {
       .then(function() {
         const result = {};
         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));
         return res.json(ApiResponse.success(result));
       })
       })
@@ -1227,7 +1252,7 @@ module.exports = function(crowi, app) {
     }).then(function(data) {
     }).then(function(data) {
       debug('Redirect Page deleted', data.path);
       debug('Redirect Page deleted', data.path);
       const result = {};
       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));
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
     }).catch(function(err) {

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

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

+ 1 - 3
lib/views/widget/page_content.html

@@ -51,7 +51,5 @@
 
 
   </div>
   </div>
 
 
-  <div id="notifPageEdited" class="myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
-    <i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span> {{ t('edited this page') }} <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> {{ t('Load latest') }}</a>
-  </div>
+  <div id="page-status-alert"></div>
 </div>
 </div>

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.2.0-RC4",
+  "version": "3.2.0-RC6",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 146 - 12
resource/js/app.js

@@ -3,8 +3,11 @@ import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
+import io from 'socket.io-client';
+
 import i18nFactory from './i18n';
 import i18nFactory from './i18n';
 
 
+import loggerFactory from '@alias/logger';
 import Xss from '../../lib/util/xss';
 import Xss from '../../lib/util/xss';
 
 
 import Crowi from './util/Crowi';
 import Crowi from './util/Crowi';
@@ -24,6 +27,7 @@ import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
 import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
 import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment   from './components/PageAttachment';
 import PageAttachment   from './components/PageAttachment';
+import PageStatusAlert  from './components/PageStatusAlert';
 import SeenUserList     from './components/SeenUserList';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import RevisionUrl      from './components/Page/RevisionUrl';
@@ -36,6 +40,8 @@ import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 
 
 import * as entities from 'entities';
 import * as entities from 'entities';
 
 
+const logger = loggerFactory('growi:app');
+
 if (!window) {
 if (!window) {
   window = {};
   window = {};
 }
 }
@@ -43,6 +49,8 @@ if (!window) {
 const userlang = $('body').data('userlang');
 const userlang = $('body').data('userlang');
 const i18n = i18nFactory(userlang);
 const i18n = i18nFactory(userlang);
 
 
+const socket = io();
+
 // setup xss library
 // setup xss library
 const xss = new Xss();
 const xss = new Xss();
 window.xss = xss;
 window.xss = xss;
@@ -86,6 +94,7 @@ crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').text
 if (isLoggedin) {
 if (isLoggedin) {
   crowi.fetchUsers();
   crowi.fetchUsers();
 }
 }
+const socketClientId = crowi.getSocketClientId();
 
 
 const crowiRenderer = new GrowiRenderer(crowi, null, {
 const crowiRenderer = new GrowiRenderer(crowi, null, {
   mode: 'page',
   mode: 'page',
@@ -170,25 +179,35 @@ const saveWithShortcutSuccessHandler = function(page) {
 
 
   pageId = page._id;
   pageId = page._id;
   pageRevisionId = page.revision._id;
   pageRevisionId = page.revision._id;
+  pageRevisionIdHackmdSynced = page.revisionHackmdSynced;
 
 
   // set page id to SavePageControls
   // set page id to SavePageControls
-  componentInstances.savePageControls.setPageId(pageId);  // TODO fix this line failed because of i18next
+  componentInstances.savePageControls.setPageId(pageId);
 
 
-  // re-render Page component
+  // Page component
   if (componentInstances.page != null) {
   if (componentInstances.page != null) {
     componentInstances.page.setMarkdown(page.revision.body);
     componentInstances.page.setMarkdown(page.revision.body);
   }
   }
-  // re-render PageEditor component
+  // PageEditor component
   if (componentInstances.pageEditor != null) {
   if (componentInstances.pageEditor != null) {
     const updateEditorValue = (editorMode !== 'builtin');
     const updateEditorValue = (editorMode !== 'builtin');
     componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
     componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
   }
   }
-  // set revision id to PageEditorByHackmd
+  // PageEditorByHackmd component
   if (componentInstances.pageEditorByHackmd != null) {
   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();
+    }
+  }
+  // PageStatusAlert component
+  const pageStatusAlert = componentInstances.pageStatusAlert;
+  // clear state of PageStatusAlert
+  if (componentInstances.pageStatusAlert != null) {
+    pageStatusAlert.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
   }
   }
 };
 };
 
 
@@ -209,15 +228,25 @@ const saveWithShortcut = function(markdown) {
     // do nothing
     // do nothing
     return;
     return;
   }
   }
+
+  let revisionId = pageRevisionId;
   // get options
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   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;
   let promise = undefined;
   if (pageId == null) {
   if (pageId == null) {
     promise = crowi.createPage(pagePath, markdown, options);
     promise = crowi.createPage(pagePath, markdown, options);
   }
   }
   else {
   else {
-    promise = crowi.updatePage(pageId, pageRevisionId, markdown, options);
+    promise = crowi.updatePage(pageId, revisionId, markdown, options);
   }
   }
 
 
   promise
   promise
@@ -236,16 +265,24 @@ const saveWithSubmitButton = function() {
     // do nothing
     // do nothing
     return;
     return;
   }
   }
+
+  let revisionId = pageRevisionId;
   // get options
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+  options.socketClientId = socketClientId;
 
 
   let promise = undefined;
   let promise = undefined;
-  // get markdown
   if (editorMode === 'builtin') {
   if (editorMode === 'builtin') {
+    // get markdown
     promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
     promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
   }
   }
   else {
   else {
+    // get markdown
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
     promise = componentInstances.pageEditorByHackmd.getMarkdown();
+    // use revisionId of PageEditorByHackmd
+    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    // set option to sync
+    options.isSyncRevisionToHackmd = true;
   }
   }
   // create or update
   // create or update
   if (pageId == null) {
   if (pageId == null) {
@@ -255,7 +292,7 @@ const saveWithSubmitButton = function() {
   }
   }
   else {
   else {
     promise = promise.then(markdown => {
     promise = promise.then(markdown => {
-      return crowi.updatePage(pageId, pageRevisionId, markdown, options);
+      return crowi.updatePage(pageId, revisionId, markdown, options);
     });
     });
   }
   }
 
 
@@ -341,8 +378,8 @@ if (writeCommentElem) {
     <CommentForm crowi={crowi}
     <CommentForm crowi={crowi}
       crowiOriginRenderer={crowiRenderer}
       crowiOriginRenderer={crowiRenderer}
       pageId={pageId}
       pageId={pageId}
-      revisionId={pageRevisionId}
       pagePath={pagePath}
       pagePath={pagePath}
+      revisionId={pageRevisionId}
       onPostComplete={postCompleteHandler}
       onPostComplete={postCompleteHandler}
       editorOptions={editorOptions}
       editorOptions={editorOptions}
       slackChannels = {slackChannels}/>,
       slackChannels = {slackChannels}/>,
@@ -367,6 +404,25 @@ if (pageEditorOptionsSelectorElem) {
   );
   );
 }
 }
 
 
+// render PageStatusAlert
+let pageStatusAlert = null;
+const pageStatusAlertElem = document.getElementById('page-status-alert');
+if (pageStatusAlertElem) {
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <PageStatusAlert crowi={crowi}
+          ref={(elem) => {
+            if (pageStatusAlert == null) {
+              pageStatusAlert = elem.getWrappedInstance();
+            }
+          }}
+          revisionId={pageRevisionId} revisionIdHackmdSynced={pageRevisionIdHackmdSynced} hasDraftOnHackmd={hasDraftOnHackmd} />
+    </I18nextProvider>,
+    pageStatusAlertElem
+  );
+  componentInstances.pageStatusAlert = pageStatusAlert;
+}
+
 // render for admin
 // render for admin
 const customCssEditorElem = document.getElementById('custom-css-editor');
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {
 if (customCssEditorElem != null) {
@@ -399,6 +455,84 @@ if (customHeaderEditorElem != null) {
   );
   );
 }
 }
 
 
+// notification from websocket
+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) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
+socket.on('page:update', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+
+  if (data.page.path == pagePath) {
+    // update PageStatusAlert
+    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) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
+socket.on('page:editingWithHackmd', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
+
+  if (data.page.path == pagePath) {
+    // update PageStatusAlert
+    const pageStatusAlert = componentInstances.pageStatusAlert;
+    if (pageStatusAlert != null) {
+      pageStatusAlert.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+    // update PageEditorByHackmd
+    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
+    if (pageEditorByHackmd != null) {
+      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+  }
+});
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));

+ 65 - 9
resource/js/components/PageEditorByHackmd.jsx

@@ -17,7 +17,9 @@ export default class PageEditorByHackmd extends React.PureComponent {
       markdown: this.props.markdown,
       markdown: this.props.markdown,
       isInitialized: false,
       isInitialized: false,
       isInitializing: false,
       isInitializing: false,
+      initialRevisionId: this.props.revisionId,
       revisionId: this.props.revisionId,
       revisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
       pageIdOnHackmd: this.props.pageIdOnHackmd,
       pageIdOnHackmd: this.props.pageIdOnHackmd,
       hasDraftOnHackmd: this.props.hasDraftOnHackmd,
       hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
     };
@@ -56,12 +58,44 @@ 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
    * update revisionId of state
    * @param {string} revisionId
    * @param {string} revisionId
+   * @param {string} revisionIdHackmdSynced
+   */
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
+  }
+
+  getRevisionIdHackmdSynced() {
+    return this.state.revisionIdHackmdSynced;
+  }
+
+  /**
+   * update hasDraftOnHackmd of state
+   * @param {bool} hasDraftOnHackmd
    */
    */
-  setRevisionId(revisionId) {
-    this.setState({revisionId});
+  setHasDraftOnHackmd(hasDraftOnHackmd) {
+    this.setState({ hasDraftOnHackmd });
   }
   }
 
 
   getHackmdUri() {
   getHackmdUri() {
@@ -163,8 +197,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
     const hackmdUri = this.getHackmdUri();
     const hackmdUri = this.getHackmdUri();
 
 
     const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
     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) {
     if (this.state.isInitialized) {
       return (
       return (
@@ -182,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;
     let content = undefined;
-    // HackMD is not setup
+    /*
+     * HackMD is not setup
+     */
     if (hackmdUri == null) {
     if (hackmdUri == null) {
       content = (
       content = (
         <div>
         <div>
@@ -191,7 +229,11 @@ export default class PageEditorByHackmd extends React.PureComponent {
         </div>
         </div>
       );
       );
     }
     }
+    /*
+     * Resume to edit or discard changes
+     */
     else if (isResume) {
     else if (isResume) {
+      const revisionIdHackmdSynced = this.state.revisionIdHackmdSynced;
       const title = (
       const title = (
         <React.Fragment>
         <React.Fragment>
           <span className="btn-label"><i className="icon-control-end"></i></span>
           <span className="btn-label"><i className="icon-control-end"></i></span>
@@ -201,23 +243,37 @@ export default class PageEditorByHackmd extends React.PureComponent {
         <div>
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <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">
           <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()}>
               <MenuItem className="text-center" onClick={() => this.discardChanges()}>
                 <i className="icon-control-rewind"></i> Discard changes
                 <i className="icon-control-rewind"></i> Discard changes
               </MenuItem>
               </MenuItem>
             </SplitButton>
             </SplitButton>
           </div>
           </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>
         </div>
       );
       );
     }
     }
+    /*
+     * Start to edit
+     */
     else {
     else {
       content = (
       content = (
         <div>
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <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">
           <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>
               <span className="btn-label"><i className="icon-paper-plane"></i></span>
               Start to edit with HackMD
               Start to edit with HackMD
             </button>
             </button>

+ 2 - 2
resource/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -63,8 +63,8 @@ export default class HackmdEditor extends React.PureComponent {
   }
   }
 
 
   notifyBodyChangesHandler(body) {
   notifyBodyChangesHandler(body) {
-    // dispatch onChange()
-    if (this.props.onChange != null) {
+    // dispatch onChange() when there is difference from 'initializationMarkdown' props
+    if (this.props.onChange != null && body !== this.props.initializationMarkdown) {
       this.props.onChange(body);
       this.props.onChange(body);
     }
     }
   }
   }

+ 143 - 0
resource/js/components/PageStatusAlert.jsx

@@ -0,0 +1,143 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageStatusAlert
+ * @extends {React.Component}
+ */
+
+class PageStatusAlert extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      initialRevisionId: this.props.revisionId,
+      revisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
+      lastUpdateUsername: undefined,
+      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
+      isDraftUpdatingInRealtime: false,
+    };
+
+    this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
+    this.renderDraftExistsAlert = this.renderDraftExistsAlert.bind(this);
+    this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
+  }
+
+  /**
+   * clear status (invoked when page is updated by myself)
+   */
+  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
+    this.setState({
+      initialRevisionId: updatedRevisionId,
+      revisionId: updatedRevisionId,
+      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
+      hasDraftOnHackmd: false,
+      isDraftUpdatingInRealtime: false,
+    });
+  }
+
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
+  }
+
+  setLastUpdateUsername(lastUpdateUsername) {
+    this.setState({ lastUpdateUsername });
+  }
+
+  setHasDraftOnHackmd(hasDraftOnHackmd) {
+    this.setState({
+      hasDraftOnHackmd,
+      isDraftUpdatingInRealtime: true,
+    });
+  }
+
+  renderSomeoneEditingAlert() {
+    return (
+      <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;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="#hackmd">
+          Open HackMD Editor
+        </a>
+      </div>
+    );
+  }
+
+  renderDraftExistsAlert(isRealtime) {
+    return (
+      <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;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="#hackmd">
+          Open HackMD Editor
+        </a>
+      </div>
+    );
+  }
+
+  renderUpdatedAlert() {
+    const { t } = this.props;
+    const label1 = t('edited this page');
+    const label2 = t('Load latest');
+
+    return (
+      <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;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="javascript:location.reload();">
+          {label2}
+        </a>
+      </div>
+    );
+  }
+
+  render() {
+    let content = <React.Fragment></React.Fragment>;
+
+    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;
+  }
+}
+
+PageStatusAlert.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  crowi: PropTypes.object.isRequired,
+  hasDraftOnHackmd: PropTypes.bool.isRequired,
+  revisionId: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+};
+
+PageStatusAlert.defaultProps = {
+};
+
+export default translate()(PageStatusAlert);

+ 5 - 1
resource/js/hackmd-agent.js

@@ -65,7 +65,7 @@ function postParentToNotifyBodyChanges(body) {
   window.growi.notifyBodyChanges(body);
   window.growi.notifyBodyChanges(body);
 }
 }
 // generate debounced function
 // generate debounced function
-const debouncedPostParentToNotifyBodyChanges = debounce(1500, postParentToNotifyBodyChanges);
+const debouncedPostParentToNotifyBodyChanges = debounce(800, postParentToNotifyBodyChanges);
 
 
 /**
 /**
  * postMessage to GROWI to save with shortcut
  * postMessage to GROWI to save with shortcut
@@ -88,6 +88,10 @@ function addEventListenersToCodemirror() {
 
 
   //// change event
   //// change event
   editor.on('change', (cm, change) => {
   editor.on('change', (cm, change) => {
+    if (change.origin === 'ignoreHistory') {
+      // do nothing because this operation triggered by other user
+      return;
+    }
     debouncedPostParentToNotifyBodyChanges(cm.doc.getValue());
     debouncedPostParentToNotifyBodyChanges(cm.doc.getValue());
   });
   });
 
 

+ 8 - 22
resource/js/legacy/crowi.js

@@ -13,7 +13,6 @@ import { debounce } from 'throttle-debounce';
 import GrowiRenderer from '../util/GrowiRenderer';
 import GrowiRenderer from '../util/GrowiRenderer';
 import Page from '../components/Page';
 import Page from '../components/Page';
 
 
-const io = require('socket.io-client');
 const entities = require('entities');
 const entities = require('entities');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
 require('jquery.cookie');
 require('jquery.cookie');
@@ -68,8 +67,7 @@ Crowi.setCaretLineData = function(line) {
 /**
 /**
  * invoked when;
  * invoked when;
  *
  *
- * 1. window loaded
- * 2. 'shown.bs.tab' event fired
+ * 1. 'shown.bs.tab' event fired
  */
  */
 Crowi.setCaretLineAndFocusToEditor = function() {
 Crowi.setCaretLineAndFocusToEditor = function() {
   // get 'data-caret-line' attributes
   // get 'data-caret-line' attributes
@@ -79,13 +77,10 @@ Crowi.setCaretLineAndFocusToEditor = function() {
     return;
     return;
   }
   }
 
 
-  const line = pageEditorDom.getAttribute('data-caret-line');
-
-  if (line != null && !isNaN(line)) {
-    crowi.setCaretLine(+line);
-    // reset data-caret-line attribute
-    pageEditorDom.removeAttribute('data-caret-line');
-  }
+  const line = pageEditorDom.getAttribute('data-caret-line') || 0;
+  crowi.setCaretLine(+line);
+  // reset data-caret-line attribute
+  pageEditorDom.removeAttribute('data-caret-line');
 
 
   // focus
   // focus
   crowi.focusToEditor();
   crowi.focusToEditor();
@@ -384,10 +379,12 @@ $(function() {
       nameValueMap[obj.name] = obj.value;
       nameValueMap[obj.name] = obj.value;
     });
     });
 
 
+    const data = $(this).serialize() + `&socketClientId=${crowi.getSocketClientId()}`;
+
     $.ajax({
     $.ajax({
       type: 'POST',
       type: 'POST',
       url: '/_api/pages.rename',
       url: '/_api/pages.rename',
-      data: $(this).serialize(),
+      data: data,
       dataType: 'json'
       dataType: 'json'
     })
     })
     .done(function(res) {
     .done(function(res) {
@@ -677,16 +674,6 @@ $(function() {
       $b.toggleClass('overlay-on');
       $b.toggleClass('overlay-on');
     });
     });
 
 
-    //
-    const me = $('body').data('me');
-    const socket = io();
-    socket.on('page edited', function(data) {
-      if (data.user._id != me
-        && data.page.path == pagePath) {
-        $('#notifPageEdited').show();
-        $('#notifPageEdited .edited-user').html(data.user.name);
-      }
-    });
   } // end if pageId
   } // end if pageId
 
 
   // tab changing handling
   // tab changing handling
@@ -807,7 +794,6 @@ window.addEventListener('load', function(e) {
 
 
   Crowi.highlightSelectedSection(location.hash);
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
-  Crowi.setCaretLineAndFocusToEditor();
   Crowi.initSlimScrollForRevisionToc();
   Crowi.initSlimScrollForRevisionToc();
   Crowi.initAffix();
   Crowi.initAffix();
 });
 });

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

@@ -16,7 +16,6 @@ export default class Crowi {
   constructor(context, window) {
   constructor(context, window) {
     this.context = context;
     this.context = context;
     this.config = {};
     this.config = {};
-    this.csrfToken = context.csrfToken;
 
 
     const userAgent = window.navigator.userAgent.toLowerCase();
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
     this.isMobile = /iphone|ipad|android/.test(userAgent);
@@ -25,6 +24,7 @@ export default class Crowi {
     this.location = window.location || {};
     this.location = window.location || {};
     this.document = window.document || {};
     this.document = window.document || {};
     this.localStorage = window.localStorage || {};
     this.localStorage = window.localStorage || {};
+    this.socketClientId = Math.floor(Math.random() * 100000);
     this.pageEditor = undefined;
     this.pageEditor = undefined;
 
 
     this.fetchUsers = this.fetchUsers.bind(this);
     this.fetchUsers = this.fetchUsers.bind(this);
@@ -39,6 +39,7 @@ export default class Crowi {
     // FIXME
     // FIXME
     this.me = context.me;
     this.me = context.me;
     this.isAdmin = context.isAdmin;
     this.isAdmin = context.isAdmin;
+    this.csrfToken = context.csrfToken;
 
 
     this.users = [];
     this.users = [];
     this.userByName = {};
     this.userByName = {};
@@ -56,10 +57,6 @@ export default class Crowi {
     return window.Crowi;
     return window.Crowi;
   }
   }
 
 
-  getContext() {
-    return this.context;
-  }
-
   setConfig(config) {
   setConfig(config) {
     this.config = config;
     this.config = config;
   }
   }
@@ -72,6 +69,10 @@ export default class Crowi {
     this.pageEditor = pageEditor;
     this.pageEditor = pageEditor;
   }
   }
 
 
+  getSocketClientId() {
+    return this.socketClientId;
+  }
+
   getEmojiStrategy() {
   getEmojiStrategy() {
     return emojiStrategy;
     return emojiStrategy;
   }
   }

+ 20 - 0
resource/styles/scss/_on-edit.scss

@@ -53,6 +53,13 @@ body.on-edit {
     display: none;
     display: none;
   }
   }
 
 
+  // hide hackmd related alert
+  &.hackmd #page-status-alert {
+    .alert-hackmd-someone-editing, .alert-hackmd-draft-exists {
+      display: none;
+    }
+  }
+
   /*****************
   /*****************
    * Expand Editor
    * 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 {
   &.builtin-editor {
 
 
@@ -290,6 +309,7 @@ body.on-edit {
         right: 0;
         right: 0;
       }
       }
     }
     }
+
   }
   }
 
 
 }
 }

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

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