Преглед изворни кода

Merge pull request #1153 from weseek/imprv/hackmd-editor

Imprv/hackmd editor
Yuki Takei пре 6 година
родитељ
комит
b74ca85959

+ 1 - 0
.prettierignore

@@ -1 +1,2 @@
+src/client/styles/bootstrap4/
 src/client/styles/scss/_override-bootstrap-variables.scss

+ 3 - 0
CHANGES.md

@@ -3,6 +3,9 @@
 ## 3.5.7-RC
 
 * Improvement: Show commented date with date distance format
+* Improvement: GROWI server obtains HackMD/CodiMD page id from the 302 response header
+* Improvement: Controls when HackMD/CodiMD has unsaved draft
+* Improvement: Show hints if HackMD/CodiMD integration is not working
 
 ## 3.5.6
 

+ 173 - 96
src/client/js/components/PageEditorByHackmd.jsx

@@ -2,9 +2,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
-import SplitButton from 'react-bootstrap/es/SplitButton';
-import MenuItem from 'react-bootstrap/es/MenuItem';
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import EditorContainer from '../services/EditorContainer';
@@ -20,9 +17,12 @@ class PageEditorByHackmd extends React.Component {
     super(props);
 
     this.state = {
-      markdown: this.props.pageContainer.state.markdown,
       isInitialized: false,
       isInitializing: false,
+      // for error
+      hasError: false,
+      errorMessage: '',
+      errorReason: '',
     };
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
@@ -30,6 +30,7 @@ class PageEditorByHackmd extends React.Component {
     this.resumeToEdit = this.resumeToEdit.bind(this);
     this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
+    this.penpalErrorOccuredHandler = this.penpalErrorOccuredHandler.bind(this);
   }
 
   componentWillMount() {
@@ -45,11 +46,7 @@ class PageEditorByHackmd extends React.Component {
       return Promise.reject(new Error('HackmdEditor component has not initialized'));
     }
 
-    return this.hackmdEditor.getValue()
-      .then((document) => {
-        this.setState({ markdown: document });
-        return document;
-      });
+    return this.hackmdEditor.getValue();
   }
 
   /**
@@ -67,7 +64,7 @@ class PageEditorByHackmd extends React.Component {
   /**
    * Start integration with HackMD
    */
-  startToEdit() {
+  async startToEdit() {
     const { pageContainer } = this.props;
     const hackmdUri = this.getHackmdUri();
 
@@ -84,26 +81,33 @@ class PageEditorByHackmd extends React.Component {
     const params = {
       pageId: pageContainer.state.pageId,
     };
-    this.props.appContainer.apiPost('/hackmd.integrate', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-
-        this.setState({
-          isInitialized: true,
-        });
-        pageContainer.setState({
-          pageIdOnHackmd: res.pageIdOnHackmd,
-          revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-        });
-      })
-      .catch((err) => {
-        pageContainer.showErrorToastr(err);
-      })
-      .then(() => {
-        this.setState({ isInitializing: false });
+
+    try {
+      const res = await this.props.appContainer.apiPost('/hackmd.integrate', params);
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      await pageContainer.setState({
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      pageContainer.showErrorToastr(err);
+
+      this.setState({
+        hasError: true,
+        errorMessage: 'GROWI server failed to connect to HackMD.',
+        errorReason: err.toString(),
       });
+    }
+
+    this.setState({
+      isInitialized: true,
+      isInitializing: false,
+    });
   }
 
   /**
@@ -116,8 +120,27 @@ class PageEditorByHackmd extends React.Component {
   /**
    * Reset draft
    */
-  discardChanges() {
-    this.props.pageContainer.setState({ hasDraftOnHackmd: false });
+  async discardChanges() {
+    const { pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await this.props.appContainer.apiPost('/hackmd.discard', { pageId });
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      this.props.pageContainer.setState({
+        hasDraftOnHackmd: false,
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      pageContainer.showErrorToastr(err);
+    }
   }
 
   /**
@@ -160,7 +183,7 @@ class PageEditorByHackmd extends React.Component {
     }
 
     // do nothing if contents are same
-    if (this.state.markdown === body) {
+    if (pageContainer.state.markdown === body) {
       return;
     }
 
@@ -178,7 +201,19 @@ class PageEditorByHackmd extends React.Component {
     }
   }
 
-  render() {
+  penpalErrorOccuredHandler(error) {
+    const { pageContainer } = this.props;
+
+    pageContainer.showErrorToastr(error);
+
+    this.setState({
+      hasError: true,
+      errorMessage: 'GROWI client failed to connect to GROWI agent for HackMD.',
+      errorReason: error.toString(),
+    });
+  }
+
+  renderPreInitContent() {
     const hackmdUri = this.getHackmdUri();
     const { pageContainer } = this.props;
     const {
@@ -188,26 +223,8 @@ class PageEditorByHackmd extends React.Component {
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
     const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
-    if (this.state.isInitialized) {
-      return (
-        <HackmdEditor
-          ref={(c) => { this.hackmdEditor = c }}
-          hackmdUri={hackmdUri}
-          pageIdOnHackmd={pageIdOnHackmd}
-          initializationMarkdown={isResume ? null : this.state.markdown}
-          onChange={this.hackmdEditorChangeHandler}
-          onSaveWithShortcut={(document) => {
-            this.onSaveWithShortcut(document);
-          }}
-        >
-        </HackmdEditor>
-      );
-    }
-
-    const isRevisionOutdated = revisionId !== remoteRevisionId;
-    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
-
     let content;
+
     /*
      * HackMD is not setup
      */
@@ -222,58 +239,59 @@ class PageEditorByHackmd extends React.Component {
      * Resume to edit or discard changes
      */
     else if (isResume) {
-      const title = (
-        <React.Fragment>
-          <span className="btn-label"><i className="icon-control-end"></i></span>
-          Resume to edit with HackMD
-        </React.Fragment>
-      );
+      const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
+
       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-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={() => { return this.resumeToEdit() }}
-            >
-              <MenuItem className="text-center" onClick={() => { return this.discardChanges() }}>
-                <i className="icon-control-rewind"></i> Discard changes
-              </MenuItem>
-            </SplitButton>
-          </div>
-          <p className="text-center">
-            Click to edit from the previous continuation<br />
-            or
-            <button
-              type="button"
-              className="btn btn-link text-danger p-0 hackmd-discard-button"
-              onClick={() => { return this.discardChanges() }}
-            >
-              Discard changes
-            </button>.
-          </p>
-          { isHackmdDocumentOutdated
-            && (
-            <div className="panel panel-warning mt-5">
+          <p className="text-center"><strong>HackMD has unsaved draft.</strong></p>
+
+          { isHackmdDocumentOutdated && (
+            <div className="panel panel-warning">
               <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 />
-                <button
-                  type="button"
-                  className="btn btn-link text-danger p-0 hackmd-discard-button"
-                  onClick={() => { return this.discardChanges() }}
-                >
-                  Discard it
-                </button> to start to edit with current revision.
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.
+
+                <div className="text-center mt-3">
+                  <button
+                    className="btn btn-link btn-view-outdated-draft p-0"
+                    type="button"
+                    disabled={this.state.isInitializing}
+                    onClick={() => { return this.resumeToEdit() }}
+                  >
+                    View the outdated draft on HackMD
+                  </button>
+                </div>
               </div>
             </div>
-            )
-          }
+          ) }
+
+          { !isHackmdDocumentOutdated && (
+            <div className="text-center hackmd-resume-button-container mb-3">
+              <button
+                className="btn btn-success btn-lg waves-effect waves-light"
+                type="button"
+                disabled={this.state.isInitializing}
+                onClick={() => { return this.resumeToEdit() }}
+              >
+                <span className="btn-label"><i className="icon-control-end"></i></span>
+                <span className="btn-text">Resume to edit with HackMD</span>
+              </button>
+            </div>
+          ) }
+
+          <div className="text-center hackmd-discard-button-container mb-3">
+            <button
+              className="btn btn-default btn-lg waves-effect waves-light"
+              type="button"
+              onClick={() => { return this.discardChanges() }}
+            >
+              <span className="btn-label"><i className="icon-control-start"></i></span>
+              <span className="btn-text">Discard changes of HackMD</span>
+            </button>
+          </div>
+
         </div>
       );
     }
@@ -281,6 +299,8 @@ class PageEditorByHackmd extends React.Component {
      * Start to edit
      */
     else {
+      const isRevisionOutdated = revisionId !== remoteRevisionId;
+
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
@@ -307,6 +327,63 @@ class PageEditorByHackmd extends React.Component {
     );
   }
 
+  render() {
+    const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
+    const {
+      markdown, pageIdOnHackmd, hasDraftOnHackmd,
+    } = pageContainer.state;
+
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
+
+    let content;
+
+    if (this.state.isInitialized) {
+      content = (
+        <HackmdEditor
+          ref={(c) => { this.hackmdEditor = c }}
+          hackmdUri={hackmdUri}
+          pageIdOnHackmd={pageIdOnHackmd}
+          initializationMarkdown={isResume ? null : markdown}
+          onChange={this.hackmdEditorChangeHandler}
+          onSaveWithShortcut={(document) => {
+            this.onSaveWithShortcut(document);
+          }}
+          onPenpalErrorOccured={this.penpalErrorOccuredHandler}
+        >
+        </HackmdEditor>
+      );
+    }
+    else {
+      content = this.renderPreInitContent();
+    }
+
+
+    return (
+      <div className="position-relative">
+
+        {content}
+
+        { this.state.hasError && (
+          <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
+            <div className="white-box text-center">
+              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> HackMD Integration failed</h2>
+              <h4>{this.state.errorMessage}</h4>
+              <p className="well well-sm text-danger">
+                {this.state.errorReason}
+              </p>
+              <p>
+                Check your configuration following <a href="https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html">the manual</a>.
+              </p>
+            </div>
+          </div>
+        ) }
+
+      </div>
+    );
+  }
+
 }
 
 /**

+ 17 - 6
src/client/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -1,18 +1,18 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import connectToChild from 'penpal/lib/connectToChild';
 
 const DEBUG_PENPAL = false;
 
+const logger = loggerFactory('growi:HackmdEditor');
+
 export default class HackmdEditor extends React.PureComponent {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-    };
-
     this.hackmd = null;
 
     this.initHackmdWithPenpal = this.initHackmdWithPenpal.bind(this);
@@ -26,7 +26,7 @@ export default class HackmdEditor extends React.PureComponent {
     this.initHackmdWithPenpal();
   }
 
-  initHackmdWithPenpal() {
+  async initHackmdWithPenpal() {
     const _this = this; // for in methods scope
 
     const iframe = document.createElement('iframe');
@@ -43,14 +43,24 @@ export default class HackmdEditor extends React.PureComponent {
           _this.saveWithShortcutHandler(document);
         },
       },
+      timeout: 15000,
       debug: DEBUG_PENPAL,
     });
-    connection.promise.then((child) => {
+
+    try {
+      const child = await connection.promise;
       this.hackmd = child;
       if (this.props.initializationMarkdown != null) {
         child.setValueOnInit(this.props.initializationMarkdown);
       }
-    });
+    }
+    catch (err) {
+      logger.error(err);
+
+      if (this.props.onPenpalErrorOccured != null) {
+        this.props.onPenpalErrorOccured(err);
+      }
+    }
   }
 
   /**
@@ -93,4 +103,5 @@ HackmdEditor.propTypes = {
   initializationMarkdown: PropTypes.string,
   onChange: PropTypes.func,
   onSaveWithShortcut: PropTypes.func,
+  onPenpalErrorOccured: PropTypes.func,
 };

+ 3 - 3
src/client/js/services/PageContainer.js

@@ -42,13 +42,13 @@ export default class PageContainer extends Container {
       likerUserIds: [],
 
       tags: [],
-      templateTagData: mainContent.getAttribute('data-template-tags'),
+      templateTagData: mainContent.getAttribute('data-template-tags') || null,
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
-      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced'),
+      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: undefined,
-      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd'),
+      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
     };

+ 1 - 1
src/client/styles/bootstrap4/_utilities.scss

@@ -6,7 +6,7 @@
 // @import "utilities/embed";
 @import 'utilities/flex';
 // @import "utilities/float";
-// @import "utilities/position";
+@import 'utilities/position';
 // @import "utilities/screenreaders";
 // @import "utilities/shadows";
 // @import "utilities/sizing";

+ 2 - 2
src/client/styles/bootstrap4/_variables.scss

@@ -606,8 +606,8 @@ $font-weight-bold: 700 !default;
 // // of components dependent on the z-axis and are designed to all work together.
 
 // $zindex-dropdown:                   1000 !default;
-// $zindex-sticky:                     1020 !default;
-// $zindex-fixed:                      1030 !default;
+$zindex-sticky:                     1020 !default;
+$zindex-fixed:                      1030 !default;
 // $zindex-modal-backdrop:             1040 !default;
 // $zindex-modal:                      1050 !default;
 // $zindex-popover:                    1060 !default;

+ 1 - 0
src/client/styles/scss/_mixins.scss

@@ -73,6 +73,7 @@
       #page-editor-with-hackmd {
         &,
         .hackmd-preinit,
+        .hackmd-error,
         #iframe-hackmd-container > iframe {
           width: 100vw;
           height: calc(100vh - #{$header-plus-footer});

+ 11 - 13
src/client/styles/scss/_on-edit.scss

@@ -267,27 +267,25 @@ body.on-edit {
       border: none;
     }
 
+    .hackmd-error {
+      top: 0;
+      background-color: rgba($gray-dark, 0.8);
+    }
+
     .hackmd-status-label {
       font-size: 3em;
       color: $muted;
     }
 
-    .hackmd-start-button-container,
-    .hackmd-resume-button-container {
-      .btn-lg .btn-label {
-        padding-top: 6px; // for SplitButton
-        padding-bottom: 6px; // for SplitButton
-      }
-    }
-
-    .hackmd-resume-button-container {
-      .dropdown-menu {
-        right: 0;
-        left: unset;
+    .hackmd-resume-button-container,
+    .hackmd-discard-button-container {
+      .btn-text {
+        display: inline-block;
+        min-width: 230px;
       }
     }
 
-    .hackmd-discard-button {
+    .btn-view-outdated-draft {
       text-decoration: underline;
       vertical-align: unset;
     }

+ 0 - 5
src/server/models/page.js

@@ -1333,12 +1333,7 @@ module.exports = function(crowi) {
    * @param {string} pageIdOnHackmd
    */
   pageSchema.statics.registerHackmdPage = function(pageData, pageIdOnHackmd) {
-    if (pageData.pageIdOnHackmd != null) {
-      throw new Error(`'pageIdOnHackmd' of the page '${pageData.path}' is not empty`);
-    }
-
     pageData.pageIdOnHackmd = pageIdOnHackmd;
-
     return this.syncRevisionToHackmd(pageData);
   };
 

+ 67 - 20
src/server/routes/hackmd.js

@@ -116,21 +116,53 @@ module.exports = function(crowi, app) {
     const hackmdUri = process.env.HACKMD_URI_FOR_SERVER || process.env.HACKMD_URI;
     let page = req.page;
 
-    if (page.pageIdOnHackmd != null) {
-      try {
-        // check if page exists in HackMD
-        await axios.get(`${hackmdUri}/${page.pageIdOnHackmd}`);
-      }
-      catch (err) {
-        // reset if pages doesn't exist
-        page.pageIdOnHackmd = undefined;
-      }
+    const hackmdPageUri = (page.pageIdOnHackmd != null)
+      ? `${hackmdUri}/${page.pageIdOnHackmd}`
+      : `${hackmdUri}/new`;
+
+    let hackmdResponse;
+    try {
+      // check if page is found or created in HackMD
+      hackmdResponse = await axios.get(hackmdPageUri, {
+        maxRedirects: 0,
+        // validate HTTP status is 200 or 302 or 404
+        validateStatus: (status) => {
+          return status === 200 || status === 302 || status === 404;
+        },
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error(err));
+    }
+
+    const { status, headers } = hackmdResponse;
+
+    // validate HackMD/CodiMD specific header
+    if (headers['codimd-version'] == null && headers['hackmd-version'] == null) {
+      const message = 'Connecting to a non-HackMD server.';
+      logger.error(message);
+      return res.json(ApiResponse.error(message));
     }
 
     try {
-      if (page.pageIdOnHackmd == null) {
-        page = await createNewPageOnHackmdAndRegister(hackmdUri, page);
+      // when page is not found
+      if (status === 404) {
+        // reset registered data
+        page = await Page.registerHackmdPage(page, undefined);
+        // re-invoke
+        return integrate(req, res);
+      }
+
+      // when redirect
+      if (status === 302) {
+        // extract page id on HackMD
+        const pagePathOnHackmd = headers.location; // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
+        const pageIdOnHackmd = pagePathOnHackmd.substr(1); //        strip the head '/'
+
+        page = await Page.registerHackmdPage(page, pageIdOnHackmd);
       }
+      // when page is found
       else {
         page = await Page.syncRevisionToHackmd(page);
       }
@@ -148,17 +180,31 @@ module.exports = function(crowi, app) {
     }
   };
 
-  async function createNewPageOnHackmdAndRegister(hackmdUri, page) {
-    // access to HackMD and create page
-    const response = await axios.get(`${hackmdUri}/new`);
-    logger.debug('HackMD responds', response);
+  /**
+   * POST /_api/hackmd.discard
+   *
+   * Create page on HackMD and start to integrate
+   * @param {object} req
+   * @param {object} res
+   */
+  const discard = async function(req, res) {
+    let page = req.page;
 
-    // extract page id on HackMD
-    const pagePathOnHackmd = response.request.path; // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
-    const pageIdOnHackmd = pagePathOnHackmd.substr(1); // strip the head '/'
+    try {
+      page = await Page.syncRevisionToHackmd(page);
 
-    return Page.registerHackmdPage(page, pageIdOnHackmd);
-  }
+      const data = {
+        pageIdOnHackmd: page.pageIdOnHackmd,
+        revisionIdHackmdSynced: page.revisionHackmdSynced,
+        hasDraftOnHackmd: page.hasDraftOnHackmd,
+      };
+      return res.json(ApiResponse.success(data));
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error('discard process failed'));
+    }
+  };
 
   /**
    * POST /_api/hackmd.saveOnHackmd
@@ -188,6 +234,7 @@ module.exports = function(crowi, app) {
     loadStyles,
     validateForApi,
     integrate,
+    discard,
     saveOnHackmd,
   };
 };

+ 1 - 0
src/server/routes/index.js

@@ -226,6 +226,7 @@ module.exports = function(crowi, app) {
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
   app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   // API v3

+ 12 - 7
src/server/routes/page.js

@@ -169,6 +169,10 @@ module.exports = function(crowi, app) {
   }
 
   function replacePlaceholdersOfTemplate(template, req) {
+    if (req.user == null) {
+      return '';
+    }
+
     const definitions = {
       pagepath: getPathFromRequest(req),
       username: req.user.name,
@@ -427,13 +431,14 @@ module.exports = function(crowi, app) {
       view = 'customlayout-selector/not_found';
 
       // retrieve templates
-      const template = await Page.findTemplate(path);
-
-      if (template.templateBody) {
-        const body = replacePlaceholdersOfTemplate(template.templateBody, req);
-        const tags = template.templateTags;
-        renderVars.template = body;
-        renderVars.templateTags = tags;
+      if (req.user != null) {
+        const template = await Page.findTemplate(path);
+        if (template.templateBody) {
+          const body = replacePlaceholdersOfTemplate(template.templateBody, req);
+          const tags = template.templateTags;
+          renderVars.template = body;
+          renderVars.templateTags = tags;
+        }
       }
 
       // add scope variables by ancestor page