Răsfoiți Sursa

Merge branch 'feat/display-BookMarkList-including-pagination-as-component' into imprv/gw3957-dropdown-buttons-commonization

kaori 5 ani în urmă
părinte
comite
ca6daf58d1

+ 5 - 0
resource/locales/en_US/admin/admin.json

@@ -161,6 +161,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
+        "different_versions": "this growi and the uploarded data versions are not met",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -203,6 +204,10 @@
       "test_connection": "Test connection to qiita:team"
     },
     "import": "Import",
+    "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
+    "prepare_new_account_for_migration":"Prepare new account for migration",
+    "archive_data_import_detail":"More Details? Ckick here.",
+    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
     "page_skip": "Pages with a name that already exists on GROWI are not imported",
     "Directory_hierarchy_tag": "Directory hierarchy tag"
   },

+ 1 - 1
resource/locales/en_US/translation.json

@@ -660,7 +660,7 @@
     "how_to": {
       "header": "How to configure Incoming Webhooks?",
       "workspace": "(At Workspace) Add a hook",
-      "workspace_desc1": "Go to <a href='https: //slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
+      "workspace_desc1": "Go to <a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
       "workspace_desc2": "Choose the default channel to post.",
       "workspace_desc3": "Add.",
       "at_growi": "(At GROWI admin page) Set Webhook URL",

+ 5 - 0
resource/locales/ja_JP/admin/admin.json

@@ -177,6 +177,7 @@
       "upload": "アップロード",
       "discard": "アップロードしたデータを破棄する",
       "errors": {
+        "different_versions": "現在のGROWIとアップロードしたデータのバージョンが違います",
         "at_least_one": "コレクションが選択されていません",
         "page_and_revision": "'Pages' と 'Revisions' はセットでインポートする必要があります",
         "depends": "'{{condition}}' をインポートする場合は、'{{target}}' を一緒に選択する必要があります"
@@ -219,6 +220,10 @@
       "test_connection": "接続テスト"
     },
     "import": "インポート",
+    "skip_username_and_email_when_overlapped": "ユーザー名またはメールアドレスが同じ場合、その部分がスキップされます。",
+    "prepare_new_account_for_migration":"移行用のアカウントを新環境で用意してください。",
+    "archive_data_import_detail":"参考: GROWI Docs - データのインポート",
+    "admin_archive_data_import_guide_url":"https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },

+ 6 - 1
resource/locales/zh_CN/admin/admin.json

@@ -171,6 +171,7 @@
 			"upload": "Upload",
 			"discard": "Discard uploaded data",
 			"errors": {
+        "versions_not_met": "this growi and the uploarded data versions are not met",
 				"at_least_one": "Select one or more collections.",
 				"page_and_revision": "'Pages' and 'Revisions' must be imported both.",
 				"depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -212,7 +213,11 @@
 			"access_token": "Access token",
 			"test_connection": "Test connection to qiita:team"
 		},
-		"import": "Import",
+    "import": "Import",
+    "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
+    "prepare_new_account_for_migration":"Prepare new account for migration",
+    "archive_data_import_detail":"More details? Click here.",
+    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
 		"page_skip": "Pages with a name that already exists on GROWI are not imported",
 		"Directory_hierarchy_tag": "Directory hierarchy tag"
 	},

+ 1 - 1
resource/locales/zh_CN/translation.json

@@ -647,7 +647,7 @@
 		"how_to": {
 			"header": "How to configure Incoming Webhooks?",
 			"workspace": "(At Workspace) Add a hook",
-			"workspace_desc1": "Go to <a href='https: //slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
+			"workspace_desc1": "Go to <a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
 			"workspace_desc2": "Choose the default channel to post.",
 			"workspace_desc3": "Add.",
 			"at_growi": "(At GROWI admin page) Set Webhook URL",

+ 10 - 1
src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -58,7 +58,16 @@ class RebuildIndexControls extends React.Component {
       return null;
     }
 
-    const header = isRebuildingCompleted ? 'Completed' : `Processing.. (${skip} skips)`;
+    function getCompletedLabel() {
+      const completedLabel = skip === 0 ? 'Completed' : `Done (${skip} skips)`;
+      return completedLabel;
+    }
+
+    function getSkipLabel() {
+      return `Processing.. (${skip} skips)`;
+    }
+
+    const header = isRebuildingCompleted ? getCompletedLabel() : getSkipLabel();
 
     return (
       <ProgressBar

+ 18 - 4
src/client/js/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../../UnstatedUtils';
 import AppContainer from '../../../../services/AppContainer';
-// import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastError } from '../../../../util/apiNotification';
 
 class UploadForm extends React.Component {
 
@@ -31,9 +31,21 @@ class UploadForm extends React.Component {
     formData.append('_csrf', this.props.appContainer.csrfToken);
     formData.append('file', this.inputRef.current.files[0]);
 
-    const { data } = await this.props.appContainer.apiv3Post('/import/upload', formData);
-    this.props.onUpload(data);
-    // TODO: toastSuccess, toastError
+    try {
+      const { data } = await this.props.appContainer.apiv3Post('/import/upload', formData);
+      // TODO: toastSuccess, toastError
+      this.props.onUpload(data);
+    }
+    catch (err) {
+      if (err[0].code === 'versions-are-not-met') {
+        if (this.props.onVersionMismatch !== null) {
+          this.props.onVersionMismatch(err[0].code);
+        }
+      }
+      else {
+        toastError(err);
+      }
+    }
   }
 
   validateForm() {
@@ -83,6 +95,8 @@ UploadForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   onUpload: PropTypes.func.isRequired,
+  isTheSameVersion: PropTypes.bool,
+  onVersionMismatch: PropTypes.func,
 };
 
 /**

+ 50 - 8
src/client/js/components/Admin/ImportData/GrowiArchiveSection.jsx

@@ -18,6 +18,7 @@ class GrowiArchiveSection extends React.Component {
     this.initialState = {
       fileName: null,
       innerFileStats: null,
+      isTheSameVersion: null,
     };
 
     this.state = this.initialState;
@@ -25,6 +26,8 @@ class GrowiArchiveSection extends React.Component {
     this.handleUpload = this.handleUpload.bind(this);
     this.discardData = this.discardData.bind(this);
     this.resetState = this.resetState.bind(this);
+    this.handleMismatchedVersions = this.handleMismatchedVersions.bind(this);
+    this.renderDefferentVersionAlert = this.renderDefferentVersionAlert.bind(this);
   }
 
   async componentWillMount() {
@@ -33,14 +36,19 @@ class GrowiArchiveSection extends React.Component {
 
     if (res.data.zipFileStat != null) {
       const { fileName, innerFileStats } = res.data.zipFileStat;
-      this.setState({ fileName, innerFileStats });
+      const { isTheSameVersion } = res.data;
+
+      this.setState({ fileName, innerFileStats, isTheSameVersion });
     }
   }
 
-  handleUpload({ meta, fileName, innerFileStats }) {
+  handleUpload({
+    meta, fileName, innerFileStats,
+  }) {
     this.setState({
       fileName,
       innerFileStats,
+      isTheSameVersion: true,
     });
   }
 
@@ -74,18 +82,51 @@ class GrowiArchiveSection extends React.Component {
     }
   }
 
+
+  handleMismatchedVersions(err) {
+    this.setState({
+      isTheSameVersion: false,
+    });
+
+  }
+
+  renderDefferentVersionAlert() {
+    const { t } = this.props;
+    return (
+      <div className="alert alert-warning mt-3">
+        {t('admin:importer_management.growi_settings.errors.different_versions')}
+      </div>
+    );
+  }
+
   resetState() {
     this.setState(this.initialState);
   }
 
   render() {
     const { t } = this.props;
+    const { isTheSameVersion } = this.state;
 
     return (
       <Fragment>
         <h2>{t('admin:importer_management.import_growi_archive')}</h2>
-
-        {this.state.fileName != null ? (
+        <div className="card well mb-4 small">
+          <ul>
+            <li>{t('admin:importer_management.skip_username_and_email_when_overlapped')}</li>
+            <li>{t('admin:importer_management.prepare_new_account_for_migration')}</li>
+            <li>
+              <a
+                href={`${t('admin:importer_management.admin_archive_data_import_guide_url')}`}
+                target="_blank"
+                rel="noopener noreferrer"
+              >{t('admin:importer_management.archive_data_import_detail')}
+              </a>
+            </li>
+          </ul>
+        </div>
+
+        {isTheSameVersion === false && this.renderDefferentVersionAlert()}
+        {this.state.fileName != null && isTheSameVersion === true ? (
           <div className="px-4">
             <ImportForm
               fileName={this.state.fileName}
@@ -94,10 +135,11 @@ class GrowiArchiveSection extends React.Component {
             />
           </div>
         )
-          : (
-            <UploadForm
-              onUpload={this.handleUpload}
-            />
+        : (
+          <UploadForm
+            onUpload={this.handleUpload}
+            onVersionMismatch={this.handleMismatchedVersions}
+          />
           )}
       </Fragment>
     );

+ 16 - 0
src/client/js/components/Icons/PagePreviewIcon.jsx

@@ -0,0 +1,16 @@
+import React from 'react';
+
+const PagePreviewIcon = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
+    <defs></defs>
+    <rect width="23" height="23" fillOpacity="0" />
+    <path d="M10.94,20.33H3.4V1.38H8.82V8.82h7.44v1.35a6.16,6.16,0,0,1,1.35.47V6.79L10.85,0H3.4a1.3,1.3,0,0,0-1,.39,1.3,1.3,0,0,0-.39,1v19A1.33,
+  1.33,0,0,0,3.4,21.68h9.84A5.94,5.94,0,0,1,10.94,20.33ZM10.17,1.38h.13l6,6v.11H10.17Z"
+    />
+    <path d="M21.87,22.14,18.75,19a4.74,4.74,0,0,0,1.1-3,4.89,4.89,0,1,0-1.8,3.73l3.11,3.11a.5.5,0,0,0,.35.15.51.51,0,0,0,.36-.15A.5.5,
+  0,0,0,21.87,22.14ZM15,19.57A3.57,3.57,0,1,1,18.59,16,3.58,3.58,0,0,1,15,19.57Z"
+    />
+  </svg>
+);
+
+export default PagePreviewIcon;

+ 1 - 1
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -876,7 +876,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         <LinkEditModal
           ref={this.linkEditModal}
-          onSave={(link) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), link) }}
+          onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
         <HandsontableModal
           ref={this.handsontableModal}

+ 1 - 1
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -119,7 +119,7 @@ class DrawioModal extends React.PureComponent {
   get drawioURL() {
     const { config } = this.props.appContainer;
 
-    const drawioUri = config.env.DRAWIO_URI || 'https://www.draw.io/';
+    const drawioUri = config.env.DRAWIO_URI || 'https://embed.diagrams.net/';
     const url = new URL(drawioUri);
 
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-

+ 270 - 150
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -5,13 +5,16 @@ import {
   Modal,
   ModalHeader,
   ModalBody,
-  ModalFooter,
+  Popover,
+  PopoverBody,
 } from 'reactstrap';
 
 import { debounce } from 'throttle-debounce';
 
 import path from 'path';
+import validator from 'validator';
 import Preview from './Preview';
+import PagePreviewIcon from '../Icons/PagePreviewIcon';
 
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
@@ -34,7 +37,10 @@ class LinkEditModal extends React.PureComponent {
       labelInputValue: '',
       linkerType: Linker.types.markdownLink,
       markdown: '',
+      previewError: '',
       permalink: '',
+      linkText: '',
+      isPreviewOpen: false,
     };
 
     this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
@@ -52,47 +58,78 @@ class LinkEditModal extends React.PureComponent {
     this.generateLink = this.generateLink.bind(this);
     this.renderPreview = this.renderPreview.bind(this);
     this.getRootPath = this.getRootPath.bind(this);
-
-    this.getPreviewDebounced = debounce(200, this.getPreview.bind(this));
+    this.toggleIsPreviewOpen = this.toggleIsPreviewOpen.bind(this);
+    this.generateAndSetPreviewDebounced = debounce(200, this.generateAndSetPreview.bind(this));
   }
 
   componentDidUpdate(prevProps, prevState) {
     const { linkInputValue: prevLinkInputValue } = prevState;
     const { linkInputValue } = this.state;
     if (linkInputValue !== prevLinkInputValue) {
-      this.getPreviewDebounced(linkInputValue);
+      this.generateAndSetPreviewDebounced(linkInputValue);
     }
   }
 
   // defaultMarkdownLink is an instance of Linker
   show(defaultMarkdownLink = null) {
     // if defaultMarkdownLink is null, set default value in inputs.
-    const { label = '' } = defaultMarkdownLink;
-    let { link = '', type = Linker.types.markdownLink } = defaultMarkdownLink;
+    const { label = '', link = '' } = defaultMarkdownLink;
+    let { type = Linker.types.markdownLink } = defaultMarkdownLink;
 
     // if type of defaultMarkdownLink is pukiwikiLink when pukiwikiLikeLinker plugin is disable, change type(not change label and link)
     if (type === Linker.types.pukiwikiLink && !this.isApplyPukiwikiLikeLinkerPlugin) {
       type = Linker.types.markdownLink;
     }
 
-    const url = new URL(link, 'http://example.com');
-    const isUseRelativePath = url.origin === 'http://example.com' && !link.startsWith('/') && link !== '';
-    if (isUseRelativePath) {
-      const rootPath = this.getRootPath(type);
-      link = path.resolve(rootPath, link);
-    }
+    this.parseLinkAndSetState(link, type);
 
     this.setState({
       show: true,
       labelInputValue: label,
-      linkInputValue: link,
       isUsePermanentLink: false,
       permalink: '',
       linkerType: type,
+    });
+  }
+
+  // parse link, link is ...
+  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
+  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
+  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
+  // case-4. external link (ex. 'https://growi.org')
+  // case-5. the others (ex. '')
+  parseLinkAndSetState(link, type) {
+    // create url from link, add dummy origin if link is not valid url.
+    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
+    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
+    const url = new URL(link, 'http://example.com');
+    const isUrl = url.origin !== 'http://example.com';
+
+    let isUseRelativePath = false;
+    let reshapedLink = link;
+
+    // if case-1, reshapedLink becomes page path
+    reshapedLink = this.convertUrlToPathIfPageUrl(reshapedLink, url);
+
+    // case-3
+    if (!isUrl && !reshapedLink.startsWith('/') && reshapedLink !== '') {
+      isUseRelativePath = true;
+      const rootPath = this.getRootPath(type);
+      reshapedLink = path.resolve(rootPath, reshapedLink);
+    }
+
+    this.setState({
+      linkInputValue: reshapedLink,
       isUseRelativePath,
     });
   }
 
+  // return path name of link if link is this growi page url, else return original link.
+  convertUrlToPathIfPageUrl(link, url) {
+    // when link is this growi's page url, url.origin === window.location.origin and return path name
+    return url.origin === window.location.origin ? decodeURI(url.pathname) : link;
+  }
+
   cancel() {
     this.hide();
   }
@@ -122,27 +159,73 @@ class LinkEditModal extends React.PureComponent {
   }
 
   renderPreview() {
-    return (
-      <div className="linkedit-preview">
-        <Preview
-          markdown={this.state.markdown}
-        />
-      </div>
-    );
+    if (this.state.markdown !== '') {
+      return (
+        <div className="linkedit-preview">
+          <Preview markdown={this.state.markdown} />
+        </div>
+      );
+    }
+    if (this.state.previewError !== '') {
+      return this.state.previewError;
+    }
+    return 'Page preview here.';
   }
 
-  async getPreview(path) {
+  async generateAndSetPreview(path) {
     let markdown = '';
+    let previewError = '';
     let permalink = '';
-    try {
-      const res = await this.props.appContainer.apiGet('/pages.get', { path });
-      markdown = res.page.revision.body;
-      permalink = `${window.location.origin}/${res.page.id}`;
+
+    if (path.startsWith('/')) {
+      const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
+      const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
+      const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
+
+      try {
+        const { page } = await this.props.appContainer.apiGet('/pages.get', { path: pathWithoutFragment, page_id: pageId });
+        markdown = page.revision.body;
+        // create permanent link only if path isn't permanent link because checkbox for isUsePermanentLink is disabled when permalink is ''.
+        permalink = !isPermanentLink ? `${window.location.origin}/${page.id}` : '';
+      }
+      catch (err) {
+        previewError = err.message;
+      }
+    }
+    this.setState({ markdown, previewError, permalink });
+  }
+
+  renderLinkPreview() {
+    const linker = this.generateLink();
+
+    if (this.isUsePermanentLink && this.permalink != null) {
+      linker.link = this.permalink;
     }
-    catch (err) {
-      markdown = `<div class="alert alert-warning" role="alert"><strong>${err.message}</strong></div>`;
+
+    if (linker.label === '') {
+      linker.label = linker.link;
     }
-    this.setState({ markdown, permalink });
+
+    const linkText = linker.generateMarkdownText();
+    return (
+      <div className="d-flex justify-content-between mb-3">
+        <div className="card card-disabled w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">Markdown</p>
+          <p className="text-center text-truncate text-muted">{linkText}</p>
+        </div>
+        <div className="d-flex align-items-center">
+          <span className="lead mx-3">
+            <i className="fa fa-caret-right"></i>
+          </span>
+        </div>
+        <div className="card w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">HTML</p>
+          <p className="text-center text-truncate">
+            <a href={linker.link}>{linker.label}</a>
+          </p>
+        </div>
+      </div>
+    );
   }
 
   handleChangeTypeahead(selected) {
@@ -174,10 +257,8 @@ class LinkEditModal extends React.PureComponent {
   }
 
   save() {
-    const output = this.generateLink();
-
     if (this.props.onSave != null) {
-      this.props.onSave(output);
+      this.props.onSave(this.state.linkText);
     }
 
     this.hide();
@@ -185,12 +266,7 @@ class LinkEditModal extends React.PureComponent {
 
   generateLink() {
     const {
-      linkInputValue,
-      labelInputValue,
-      linkerType,
-      isUseRelativePath,
-      isUsePermanentLink,
-      permalink,
+      linkInputValue, labelInputValue, linkerType, isUseRelativePath, isUsePermanentLink, permalink,
     } = this.state;
 
     let reshapedLink = linkInputValue;
@@ -199,13 +275,11 @@ class LinkEditModal extends React.PureComponent {
       reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
     }
 
-    return new Linker(
-      linkerType,
-      labelInputValue,
-      reshapedLink,
-      isUsePermanentLink,
-      permalink,
-    );
+    if (isUsePermanentLink && permalink != null) {
+      reshapedLink = permalink;
+    }
+
+    return new Linker(linkerType, labelInputValue, reshapedLink);
   }
 
   getRootPath(type) {
@@ -215,124 +289,170 @@ class LinkEditModal extends React.PureComponent {
     return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
   }
 
+  toggleIsPreviewOpen() {
+    this.setState({ isPreviewOpen: !this.state.isPreviewOpen });
+  }
+
+  renderLinkAndLabelForm() {
+    return (
+      <>
+        <h3 className="grw-modal-head">Set link and label</h3>
+        <form className="form-group">
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">link</span>
+              </div>
+              <SearchTypeahead
+                onChange={this.handleChangeTypeahead}
+                onInputChange={this.handleChangeLinkInput}
+                inputName="link"
+                placeholder="Input page path or URL"
+                keywordOnInit={this.state.linkInputValue}
+              />
+              <div className="input-group-append">
+                <button type="button" id="preview-btn" className="btn btn-info btn-page-preview">
+                  <PagePreviewIcon />
+                </button>
+                <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
+                  <PopoverBody>
+                    {this.renderPreview()}
+                  </PopoverBody>
+                </Popover>
+              </div>
+            </div>
+          </div>
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">label</span>
+              </div>
+              <input
+                type="text"
+                className="form-control"
+                id="label"
+                value={this.state.labelInputValue}
+                onChange={e => this.handleChangeLabelInput(e.target.value)}
+                disabled={this.state.linkerType === Linker.types.growiLink}
+              />
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  }
+
+  renderPathFormatForm() {
+    return (
+      <div className="card well pt-3">
+        <form className="form-group mb-0">
+          <div className="form-group row">
+            <label className="col-sm-3">Path format</label>
+            <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+              <input
+                className="custom-control-input"
+                id="relativePath"
+                type="checkbox"
+                checked={this.state.isUseRelativePath}
+                onChange={this.toggleIsUseRelativePath}
+                disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
+              />
+              <label className="custom-control-label" htmlFor="relativePath">
+                Use relative path
+              </label>
+            </div>
+            <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+              <input
+                className="custom-control-input"
+                id="permanentLink"
+                type="checkbox"
+                checked={this.state.isUsePermanentLink}
+                onChange={this.toggleIsUsePamanentLink}
+                disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
+              />
+              <label className="custom-control-label" htmlFor="permanentLink">
+                Use permanent link
+              </label>
+            </div>
+          </div>
+          <div className="form-group row mb-0">
+            <label className="col-sm-3">Notation</label>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="markdownType"
+                value={Linker.types.markdownLink}
+                checked={this.state.linkerType === Linker.types.markdownLink}
+                onChange={e => this.handleSelecteLinkerType(e.target.value)}
+              />
+              <label className="custom-control-label" htmlFor="markdownType">
+                Markdown
+              </label>
+            </div>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="growiType"
+                value={Linker.types.growiLink}
+                checked={this.state.linkerType === Linker.types.growiLink}
+                onChange={e => this.handleSelecteLinkerType(e.target.value)}
+              />
+              <label className="custom-control-label" htmlFor="growiType">
+                Growi original
+              </label>
+            </div>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="pukiwikiType"
+                value={Linker.types.pukiwikiLink}
+                checked={this.state.linkerType === Linker.types.pukiwikiLink}
+                onChange={e => this.handleSelecteLinkerType(e.target.value)}
+              />
+              <label className="custom-control-label" htmlFor="pukiwikiType">
+                Pukiwiki
+              </label>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
   render() {
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg">
+      <Modal className="link-edit-modal" isOpen={this.state.show} toggle={this.cancel} size="lg">
         <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
           Edit Links
         </ModalHeader>
 
         <ModalBody className="container">
           <div className="row">
-            <div className="col-12 col-lg-6">
-              <form className="form-group">
-                <div className="form-gorup my-3">
-                  <label htmlFor="linkInput">Link</label>
-                  <div className="input-group">
-                    <SearchTypeahead
-                      onChange={this.handleChangeTypeahead}
-                      onInputChange={this.handleChangeLinkInput}
-                      inputName="link"
-                      placeholder="Input page path or URL"
-                      keywordOnInit={this.state.linkInputValue}
-                    />
-                  </div>
-                </div>
-              </form>
-
-              <div className="d-block d-lg-none mb-3 overflow-auto">
-                {this.renderPreview()}
-              </div>
-
-              <div className="card">
-                <div className="card-body">
-                  <form className="form-group">
-                    <div className="form-group btn-group d-flex" role="group" aria-label="type">
-                      <button
-                        type="button"
-                        name={Linker.types.markdownLink}
-                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.markdownLink && 'active'}`}
-                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
-                      >
-                        Markdown
-                      </button>
-                      <button
-                        type="button"
-                        name={Linker.types.growiLink}
-                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.growiLink && 'active'}`}
-                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
-                      >
-                        Growi Original
-                      </button>
-                      {this.isApplyPukiwikiLikeLinkerPlugin && (
-                        <button
-                          type="button"
-                          name={Linker.types.pukiwikiLink}
-                          className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.pukiwikiLink && 'active'}`}
-                          onClick={e => this.handleSelecteLinkerType(e.target.name)}
-                        >
-                          Pukiwiki
-                        </button>
-                      )}
-                    </div>
-
-                    <div className="form-group">
-                      <label htmlFor="label">Label</label>
-                      <input
-                        type="text"
-                        className="form-control"
-                        id="label"
-                        value={this.state.labelInputValue}
-                        onChange={e => this.handleChangeLabelInput(e.target.value)}
-                        disabled={this.state.linkerType === Linker.types.growiLink}
-                      />
-                    </div>
-                    <div className="form-inline">
-                      <div className="custom-control custom-checkbox custom-checkbox-info">
-                        <input
-                          className="custom-control-input"
-                          id="relativePath"
-                          type="checkbox"
-                          checked={this.state.isUseRelativePath}
-                          disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
-                        />
-                        <label className="custom-control-label" htmlFor="relativePath" onClick={this.toggleIsUseRelativePath}>
-                          Use relative path
-                        </label>
-                      </div>
-                    </div>
-                    <div className="form-inline">
-                      <div className="custom-control custom-checkbox custom-checkbox-info">
-                        <input
-                          className="custom-control-input"
-                          id="permanentLink"
-                          type="checkbox"
-                          checked={this.state.isUsePermanentLink}
-                          disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
-                        />
-                        <label className="custom-control-label" htmlFor="permanentLink" onClick={this.toggleIsUsePamanentLink}>
-                          Use permanent link
-                        </label>
-                      </div>
-                    </div>
-                  </form>
-                </div>
-              </div>
+            <div className="col-12">
+              {this.renderLinkAndLabelForm()}
+              {this.renderPathFormatForm()}
             </div>
-
-            <div className="col d-none d-lg-block pr-0 mr-3 overflow-auto">
-              {this.renderPreview()}
+          </div>
+          <div className="row">
+            <div className="col-12">
+              <h3 className="grw-modal-head">Preview</h3>
+              {this.renderLinkPreview()}
+            </div>
+          </div>
+          <div className="row">
+            <div className="col-12 text-center">
+              <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
+                Cancel
+              </button>
+              <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
+                Done
+              </button>
             </div>
           </div>
         </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.hide}>
-            Cancel
-          </button>
-          <button type="submit" className="btn btn-sm btn-primary" onClick={this.save}>
-            Done
-          </button>
-        </ModalFooter>
       </Modal>
     );
   }

+ 3 - 4
src/client/js/components/PageEditor/MarkdownLinkUtil.js

@@ -27,16 +27,15 @@ class MarkdownLinkUtil {
   }
 
   // replace link(link is an instance of Linker)
-  replaceFocusedMarkdownLinkWithEditor(editor, link) {
+  replaceFocusedMarkdownLinkWithEditor(editor, linkText) {
     const curPos = editor.getCursor();
-    const linkStr = link.generateMarkdownText();
     if (!this.isInLink(editor)) {
-      editor.getDoc().replaceSelection(linkStr);
+      editor.getDoc().replaceSelection(linkText);
     }
     else {
       const line = editor.getDoc().getLine(curPos.line);
       const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(line, curPos.ch);
-      editor.getDoc().replaceRange(linkStr, { line: curPos.line, ch: beginningOfLink }, { line: curPos.line, ch: endOfLink });
+      editor.getDoc().replaceRange(linkText, { line: curPos.line, ch: beginningOfLink }, { line: curPos.line, ch: endOfLink });
     }
   }
 

+ 7 - 26
src/client/js/models/Linker.js

@@ -1,17 +1,13 @@
 export default class Linker {
 
   constructor(
-      type,
-      label,
-      link,
-      isUsePermanentLink = false,
-      permalink = '',
+      type = Linker.types.markdownLink,
+      label = '',
+      link = '',
   ) {
     this.type = type;
     this.label = label;
     this.link = link;
-    this.isUsePermanentLink = isUsePermanentLink;
-    this.permalink = permalink;
 
     this.generateMarkdownText = this.generateMarkdownText.bind(this);
   }
@@ -30,25 +26,15 @@ export default class Linker {
   }
 
   generateMarkdownText() {
-    let reshapedLink = this.link;
-
-    if (this.isUsePermanentLink && this.permalink != null) {
-      reshapedLink = this.permalink;
-    }
-
-    if (this.label === '') {
-      this.label = reshapedLink;
-    }
-
     if (this.type === Linker.types.pukiwikiLink) {
-      if (this.label === reshapedLink) return `[[${reshapedLink}]]`;
-      return `[[${this.label}>${reshapedLink}]]`;
+      if (this.label === this.link) return `[[${this.link}]]`;
+      return `[[${this.label}>${this.link}]]`;
     }
     if (this.type === Linker.types.growiLink) {
-      return `[${reshapedLink}]`;
+      return `[${this.link}]`;
     }
     if (this.type === Linker.types.markdownLink) {
-      return `[${this.label}](${reshapedLink})`;
+      return `[${this.label}](${this.link})`;
     }
   }
 
@@ -82,15 +68,10 @@ export default class Linker {
       link = label;
     }
 
-    const isUsePermanentLink = false;
-    const permalink = '';
-
     return new Linker(
       type,
       label,
       link,
-      isUsePermanentLink,
-      permalink,
     );
   }
 

+ 13 - 5
src/client/styles/scss/_linkedit-preview.scss

@@ -1,8 +1,16 @@
-.modal .modal-body .linkedit-preview {
-  height: 0;
-  padding-bottom: 50%;
-
+.linkedit-preview {
   .page-editor-preview-body {
-    overflow-y: unset;
+    max-height: 70vh;
+    padding-top: 0px;
+    margin: 0px -10px 0px -10px;
+    .wiki {
+      overflow-y: scroll;
+    }
   }
 }
+
+// page preview button
+.btn-page-preview svg {
+  width: 18px;
+  height: 18px;
+}

+ 31 - 1
src/client/styles/scss/theme/_apply-colors-dark.scss

@@ -57,6 +57,10 @@ textarea.form-control {
   border-right: none;
 }
 
+.input-group input {
+  border-color: $secondary;
+}
+
 /*
  * Dropdown
  */
@@ -81,10 +85,20 @@ textarea.form-control {
 /*
  * Card
  */
-.card:not([class*='bg-']) {
+.card:not([class*='bg-']):not(.well):not(.card-disabled) {
   @extend .bg-dark;
 }
 
+// [TODO] GW-3219 modify common color of well in dark theme, then remove below css.
+.card.well {
+  border-color: $secondary;
+}
+
+.card.card-disabled {
+  background-color: lighten($dark, 10%);
+  border-color: $secondary;
+}
+
 /*
  * Pagination
  */
@@ -251,6 +265,22 @@ body.on-edit {
   }
 }
 
+/*
+ * Popover
+ */
+.popover {
+  background-color: $bgcolor-global;
+  border-color: $secondary;
+  .popover-header {
+    color: $white;
+    background-color: $secondary;
+    border-color: $secondary;
+  }
+  .popover-body {
+    color: inherit;
+  }
+}
+
 /*
  * GROWI HandsontableModal
  */

+ 17 - 0
src/client/styles/scss/theme/_apply-colors-light.scss

@@ -42,6 +42,14 @@ $table-hover-bg: $bgcolor-table-hover;
   background-color: darken($bgcolor-global, 5%);
 }
 
+/*
+ * card
+ */
+.card.card-disabled {
+  background-color: darken($bgcolor-card, 5%);
+  border-color: $gray-200;
+}
+
 /*
  * GROWI Login form
  */
@@ -186,6 +194,15 @@ $table-hover-bg: $bgcolor-table-hover;
   }
 }
 
+/*
+ * GROWI Link Edit Modal
+ */
+.link-edit-modal {
+  span i {
+    color: $gray-400;
+  }
+}
+
 /*
  * GROWI HandsontableModal
  */

+ 5 - 0
src/client/styles/scss/theme/_apply-colors.scss

@@ -235,6 +235,11 @@ pre:not(.hljs):not(.CodeMirror-line) {
   fill: $color-editor-icons;
 }
 
+// page preview button in link form
+.btn-page-preview svg {
+  fill: white;
+}
+
 /*
  * Modal
  */

+ 7 - 0
src/server/middlewares/certify-shared-file.js

@@ -7,6 +7,13 @@ module.exports = (crowi) => {
 
   return async(req, res, next) => {
     const { referer } = req.headers;
+
+    // Attachments cannot be viewed by clients who do not send referer.
+    // https://github.com/weseek/growi/issues/2819
+    if (referer == null) {
+      return next();
+    }
+
     const { path } = url.parse(referer);
 
     if (!path.startsWith('/share/')) {

+ 13 - 6
src/server/routes/apiv3/import.js

@@ -8,6 +8,7 @@ const multer = require('multer');
 const express = require('express');
 
 const GrowiArchiveImportOption = require('@commons/models/admin/growi-archive-import-option');
+const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 const router = express.Router();
@@ -305,20 +306,26 @@ module.exports = (crowi) => {
   router.post('/upload', uploads.single('file'), accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const { file } = req;
     const zipFile = importService.getFile(file.filename);
+    let data = null;
 
     try {
-      const data = await growiBridgeService.parseZipFile(zipFile);
-
-      // validate with meta.json
-      importService.validate(data.meta);
-
-      return res.apiv3(data);
+      data = await growiBridgeService.parseZipFile(zipFile);
     }
     catch (err) {
       // TODO: use ApiV3Error
       logger.error(err);
       return res.status(500).send({ status: 'ERROR' });
     }
+    try {
+      // validate with meta.json
+      importService.validate(data.meta);
+      return res.apiv3(data);
+    }
+    catch {
+      const msg = 'the version of this growi and the growi that exported the data are not met';
+      const varidationErr = 'versions-are-not-met';
+      return res.apiv3Err(new ErrorV3(msg, varidationErr), 500);
+    }
   });
 
   /**

+ 13 - 3
src/server/routes/attachment.js

@@ -128,8 +128,8 @@ const ApiResponse = require('../util/apiResponse');
 module.exports = function(crowi, app) {
   const Attachment = crowi.model('Attachment');
   const Page = crowi.model('Page');
-  const { fileUploadService, attachmentService } = crowi;
-
+  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
+  const { fileUploadService, attachmentService, globalNotificationService } = crowi;
 
   /**
    * Check the user is accessible to the related page
@@ -463,7 +463,17 @@ module.exports = function(crowi, app) {
       pageCreated,
     };
 
-    return res.json(ApiResponse.success(result));
+    res.json(ApiResponse.success(result));
+
+    if (pageCreated) {
+      // global notification
+      try {
+        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, page, req.user);
+      }
+      catch (err) {
+        logger.error('Create notification failed', err);
+      }
+    }
   };
 
   /**

+ 17 - 1
src/server/service/import.js

@@ -147,8 +147,24 @@ class ImportService {
 
     const isImporting = this.currentProgressingStatus != null;
 
+    const zipFileStat = filtered.pop();
+    let isTheSameVersion = false;
+
+    if (zipFileStat != null) {
+      try {
+        this.validate(zipFileStat.meta);
+        isTheSameVersion = true;
+      }
+      catch (err) {
+        isTheSameVersion = false;
+        logger.error('the versions are not met', err);
+      }
+    }
+
+
     return {
-      zipFileStat: filtered.pop(),
+      isTheSameVersion,
+      zipFileStat,
       isImporting,
       progressList: isImporting ? this.currentProgressingStatus.progressList : null,
     };