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

Merge pull request #1205 from weseek/feat/export-n-import-revision-4

Feat/export n import revision 4
Yuki Takei 6 лет назад
Родитель
Сommit
72c27ee7a9

+ 16 - 0
resource/locales/en-US/translation.json

@@ -751,5 +751,21 @@
     "import": "Import",
     "page_skip": "Pages with a name that already exists on GROWI are not imported",
     "Directory_hierarchy_tag": "Directory Hierarchy Tag"
+  },
+
+  "export_management": {
+    "export_as_zip": "Export Data as Zip",
+    "export_collections": "Export Collections",
+    "check_all": "Check All",
+    "uncheck_all": "Uncheck All",
+    "export": "Export",
+    "cancel": "Cancel",
+    "file": "File",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "Export Menu",
+    "download": "Download",
+    "delete": "Delete"
   }
 }

+ 16 - 0
resource/locales/ja/translation.json

@@ -734,5 +734,21 @@
     "import": "インポート",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
+  },
+
+  "export_management": {
+    "export_as_zip": "Zipファイルでエクスポート",
+    "export_collections": "コレクションのエクスポート",
+    "check_all": "全てにチェックを付ける",
+    "uncheck_all": "全てからチェックを外す",
+    "export": "エクスポート",
+    "cancel": "キャンセル",
+    "file": "ファイル名",
+    "growi_version": "Growi バージョン",
+    "collections": "コレクション",
+    "exported_at": "エクスポートされた時間",
+    "export_menu": "エクスポートメニュー",
+    "download": "ダウンロード",
+    "delete": "削除"
   }
 }

+ 0 - 149
src/client/js/components/Admin/Export/ExportAsZip.jsx

@@ -1,149 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
-// import { toastSuccess, toastError } from '../../../util/apiNotification';
-
-class ExportAsZip extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      zipFileStats: [],
-      collections: new Set(),
-    };
-
-    this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.export = this.export.bind(this);
-    this.deleteZipFile = this.deleteZipFile.bind(this);
-  }
-
-  async componentDidMount() {
-    const { zipFileStats } = await this.props.appContainer.apiGet('/v3/export/status', {});
-    this.setState({ zipFileStats });
-  }
-
-  toggleCheckbox(e) {
-    const { target } = e;
-    const { name, checked } = target;
-
-    this.setState((prevState) => {
-      const collections = new Set(prevState.collections);
-      if (checked) {
-        collections.add(name);
-      }
-      else {
-        collections.delete(name);
-      }
-
-      return { collections };
-    });
-  }
-
-  async export() {
-    // TODO use appContainer.apiv3.post
-    const { zipFileStat } = await this.props.appContainer.apiPost('/v3/export', { collections: Array.from(this.state.collections) });
-    // TODO toastSuccess, toastError
-    this.setState((prevState) => {
-      return {
-        zipFileStats: [
-          ...prevState.zipFileStats,
-          zipFileStat,
-        ],
-      };
-    });
-  }
-
-  async deleteZipFile(zipFile) {
-    // TODO use appContainer.apiv3.delete
-    await this.props.appContainer.apiRequest('delete', `/v3/export/${zipFile}`, {});
-
-    this.setState((prevState) => {
-      return {
-        zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== zipFile),
-      };
-    });
-    // TODO toastSuccess, toastError
-  }
-
-  render() {
-    // const { t } = this.props;
-    const collections = ['pages', 'revisions'];
-
-    return (
-      <Fragment>
-        <h2>Export Data as Zip</h2>
-        <form className="my-5">
-          {collections.map((collectionName) => {
-            return (
-              <div className="checkbox checkbox-info" key={collectionName}>
-                <input
-                  type="checkbox"
-                  id={collectionName}
-                  name={collectionName}
-                  className="form-check-input"
-                  value={collectionName}
-                  checked={this.state.collections.has(collectionName)}
-                  onChange={this.toggleCheckbox}
-                />
-                <label className="form-check-label ml-3" htmlFor={collectionName}>
-                  {collectionName}
-                </label>
-              </div>
-            );
-          })}
-        </form>
-        <button type="button" className="btn btn-sm btn-default" onClick={this.export}>Generate</button>
-
-        <table className="table table-bordered">
-          <thead>
-            <tr>
-              <th>File</th>
-              <th>Growi Version</th>
-              <th>Collections</th>
-              <th>Exported At</th>
-              <th></th>
-            </tr>
-          </thead>
-          <tbody>
-            {this.state.zipFileStats.map(({ meta, fileName, fileStats }) => {
-              return (
-                <tr key={meta}>
-                  <th>{fileName}</th>
-                  <td>{meta.version}</td>
-                  <td>{fileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
-                  <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
-                  <td>
-                    <a href="/_api/v3/export">
-                      <button type="button" className="btn btn-sm btn-primary ml-2">Download</button>
-                    </a>
-                    <button type="button" className="btn btn-sm btn-danger ml-2" onClick={() => this.deleteZipFile(fileName)}>Delete</button>
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-ExportAsZip.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ExportAsZipWrapper = (props) => {
-  return createSubscribedElement(ExportAsZip, props, [AppContainer]);
-};
-
-export default withTranslation()(ExportAsZipWrapper);

+ 93 - 3
src/client/js/components/Admin/Export/ExportPage.jsx

@@ -1,17 +1,107 @@
 import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 
-import ExportAsZip from './ExportAsZip';
+import ExportZipFormModal from './ExportZipFormModal';
+import ZipFileTable from './ZipFileTable';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 class ExportPage extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      collections: [],
+      zipFileStats: [],
+      isExportModalOpen: false,
+    };
+
+    this.onZipFileStatAdd = this.onZipFileStatAdd.bind(this);
+    this.onZipFileStatRemove = this.onZipFileStatRemove.bind(this);
+    this.openExportModal = this.openExportModal.bind(this);
+    this.closeExportModal = this.closeExportModal.bind(this);
+  }
+
+  async componentDidMount() {
+    // TODO: use apiv3.get
+    const [{ collections }, { zipFileStats }] = await Promise.all([
+      this.props.appContainer.apiGet('/v3/mongo/collections', {}),
+      this.props.appContainer.apiGet('/v3/export/status', {}),
+    ]);
+    // TODO toastSuccess, toastError
+
+    this.setState({ collections, zipFileStats });
+  }
+
+  onZipFileStatAdd(newStat) {
+    this.setState((prevState) => {
+      return {
+        zipFileStats: [...prevState.zipFileStats, newStat],
+      };
+    });
+  }
+
+  async onZipFileStatRemove(fileName) {
+    await this.props.appContainer.apiRequest('delete', `/v3/export/${fileName}`, {});
+
+    this.setState((prevState) => {
+      return {
+        zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== fileName),
+      };
+    });
+  }
+
+  openExportModal() {
+    this.setState({ isExportModalOpen: true });
+  }
+
+  closeExportModal() {
+    this.setState({ isExportModalOpen: false });
+  }
+
   render() {
+    const { t } = this.props;
+
     return (
       <Fragment>
-        <ExportAsZip />
+        <h2>{t('export_management.export_as_zip')}</h2>
+        <div className="row my-5">
+          <div className="col-xs-offset-3 col-xs-6">
+            <button type="submit" className="btn btn-sm btn-primary" onClick={this.openExportModal}>{t('export_management.export')}</button>
+          </div>
+        </div>
+        <ExportZipFormModal
+          isOpen={this.state.isExportModalOpen}
+          onClose={this.closeExportModal}
+          collections={this.state.collections}
+          zipFileStats={this.state.zipFileStats}
+          onZipFileStatAdd={this.onZipFileStatAdd}
+        />
+        {this.state.zipFileStats.length > 0 && (
+          <ZipFileTable
+            zipFileStats={this.state.zipFileStats}
+            onZipFileStatRemove={this.onZipFileStatRemove}
+          />
+        )}
       </Fragment>
     );
   }
 
 }
 
-export default ExportPage;
+ExportPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportPageFormWrapper = (props) => {
+  return createSubscribedElement(ExportPage, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportPageFormWrapper);

+ 52 - 0
src/client/js/components/Admin/Export/ExportTableMenu.jsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExportTableMenu extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="btn-group admin-user-menu">
+        <button type="button" className="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown">
+          <i className="icon-settings"></i> <span className="caret"></span>
+        </button>
+        <ul className="dropdown-menu" role="menu">
+          <li className="dropdown-header">{t('export_management.export_menu')}</li>
+          <li>
+            <a href={`/admin/export/${this.props.fileName}`}>
+              <i className="icon-cloud-download" /> {t('export_management.download')}
+            </a>
+          </li>
+          <li onClick={() => this.props.onZipFileStatRemove(this.props.fileName)}>
+            <a>
+              <span className="text-danger"><i className="icon-trash" /> {t('export_management.delete')}</span>
+            </a>
+          </li>
+        </ul>
+      </div>
+    );
+  }
+
+}
+
+ExportTableMenu.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  fileName: PropTypes.string.isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportTableMenuWrapper = (props) => {
+  return createSubscribedElement(ExportTableMenu, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportTableMenuWrapper);

+ 137 - 0
src/client/js/components/Admin/Export/ExportZipFormModal.jsx

@@ -0,0 +1,137 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ExportZipFormModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      collections: new Set(),
+    };
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.checkAll = this.checkAll.bind(this);
+    this.uncheckAll = this.uncheckAll.bind(this);
+    this.export = this.export.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const collections = new Set(prevState.collections);
+      if (checked) {
+        collections.add(name);
+      }
+      else {
+        collections.delete(name);
+      }
+
+      return { collections };
+    });
+  }
+
+  checkAll() {
+    this.setState({ collections: new Set(this.props.collections) });
+  }
+
+  uncheckAll() {
+    this.setState({ collections: new Set() });
+  }
+
+  async export(e) {
+    e.preventDefault();
+
+    // TODO use appContainer.apiv3.post
+    const { zipFileStat } = await this.props.appContainer.apiPost('/v3/export', { collections: Array.from(this.state.collections) });
+    // TODO toastSuccess, toastError
+    this.props.onZipFileStatAdd(zipFileStat);
+    this.props.onClose();
+  }
+
+  validateForm() {
+    return this.state.collections.size > 0;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
+        <Modal.Header closeButton>
+          <Modal.Title>{t('export_management.export_collections')}</Modal.Title>
+        </Modal.Header>
+
+        <form onSubmit={this.export}>
+          <Modal.Body>
+            <div className="row">
+              <div className="col-sm-12">
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.checkAll}>
+                  <i className="fa fa-check-square-o"></i> {t('export_management.check_all')}
+                </button>
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.uncheckAll}>
+                  <i className="fa fa-square-o"></i> {t('export_management.uncheck_all')}
+                </button>
+              </div>
+            </div>
+            <div className="checkbox checkbox-info">
+              {this.props.collections.map((collectionName) => {
+                return (
+                  <div className="my-1" key={collectionName}>
+                    <input
+                      type="checkbox"
+                      id={collectionName}
+                      name={collectionName}
+                      className="form-check-input"
+                      value={collectionName}
+                      checked={this.state.collections.has(collectionName)}
+                      onChange={this.toggleCheckbox}
+                    />
+                    <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
+                      {collectionName}
+                    </label>
+                  </div>
+                );
+              })}
+            </div>
+          </Modal.Body>
+
+          <Modal.Footer>
+            <button type="button" className="btn btn-sm btn-default" onClick={this.props.onClose}>{t('export_management.cancel')}</button>
+            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('export_management.export')}</button>
+          </Modal.Footer>
+        </form>
+      </Modal>
+    );
+  }
+
+}
+
+ExportZipFormModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+  collections: PropTypes.arrayOf(PropTypes.string).isRequired,
+  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onZipFileStatAdd: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportZipFormModalWrapper = (props) => {
+  return createSubscribedElement(ExportZipFormModal, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportZipFormModalWrapper);

+ 67 - 0
src/client/js/components/Admin/Export/ZipFileTable.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+
+import ExportTableMenu from './ExportTableMenu';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ZipFileTable extends React.Component {
+
+  render() {
+    // eslint-disable-next-line no-unused-vars
+    const { t } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('export_management.file')}</th>
+            <th>{t('export_management.growi_version')}</th>
+            <th>{t('export_management.collections')}</th>
+            <th>{t('export_management.exported_at')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {this.props.zipFileStats.map(({ meta, fileName, fileStats }) => {
+            return (
+              <tr key={fileName}>
+                <th>{fileName}</th>
+                <td>{meta.version}</td>
+                <td className="text-capitalize">{fileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+                <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+                <td>
+                  <ExportTableMenu
+                    fileName={fileName}
+                    onZipFileStatRemove={this.props.onZipFileStatRemove}
+                  />
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+ZipFileTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ZipFileTableWrapper = (props) => {
+  return createSubscribedElement(ZipFileTable, props, [AppContainer]);
+};
+
+export default withTranslation()(ZipFileTableWrapper);

+ 16 - 0
src/server/routes/admin.js

@@ -18,6 +18,7 @@ module.exports = function(crowi, app) {
     aclService,
     slackNotificationService,
     customizeService,
+    exportService,
   } = crowi;
 
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
@@ -874,6 +875,21 @@ module.exports = function(crowi, app) {
     return res.render('admin/export');
   };
 
+  actions.export.download = (req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = exportService.getFile(fileName);
+      return res.download(zipFile);
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.json(ApiResponse.error());
+    }
+  };
+
   actions.api = {};
   actions.api.appSetting = async function(req, res) {
     const form = req.form.settingForm;

+ 1 - 28
src/server/routes/apiv3/export.js

@@ -22,7 +22,7 @@ module.exports = (crowi) => {
    *  /export/status:
    *    get:
    *      tags: [Export]
-   *      description: get mongodb collections names and zip files for them
+   *      description: get properties of zip files for export
    *      produces:
    *        - application/json
    *      responses:
@@ -38,33 +38,6 @@ module.exports = (crowi) => {
     return res.json({ ok: true, zipFileStats });
   });
 
-  /**
-   * @swagger
-   *
-   *  /export/download:
-   *    get:
-   *      tags: [Export]
-   *      description: download a zipped json for multiple collections
-   *      produces:
-   *        - application/json
-   *      responses:
-   *        200:
-   *          description: a zip file
-   *          content:
-   *            application/zip:
-   */
-  router.get('/', async(req, res) => {
-    // TODO: add express validator
-    try {
-      return res.download(exportService.getZipFile());
-    }
-    catch (err) {
-      // TODO: use ApiV3Error
-      logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
-    }
-  });
-
   /**
    * @swagger
    *

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -9,6 +9,8 @@ const router = express.Router();
 module.exports = (crowi) => {
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
+  router.use('/mongo', require('./mongo')(crowi));
+
   router.use('/export', require('./export')(crowi));
 
   router.use('/import', require('./import')(crowi));

+ 48 - 0
src/server/routes/apiv3/mongo.js

@@ -0,0 +1,48 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:mongo'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Mongo
+ */
+
+module.exports = (crowi) => {
+  /**
+   * @swagger
+   *
+   *  /mongo/collections:
+   *    get:
+   *      tags: [Mongo]
+   *      description: get mongodb collections names
+   *      produces:
+   *        - application/json
+   *      responses:
+   *        200:
+   *          description: list of collections in mongoDB
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  collections:
+   *                    type: array
+   *                    items:
+   *                      type: string
+   */
+  router.get('/collections', async(req, res) => {
+    const collections = Object.values(crowi.models).map(model => model.collection.collectionName);
+
+    // TODO: use res.apiv3
+    return res.json({
+      ok: true,
+      collections: [...new Set(collections)], // remove duplicates
+    });
+  });
+
+  return router;
+};

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

@@ -165,6 +165,7 @@ module.exports = function(crowi, app) {
 
   // export management for admin
   app.get('/admin/export' , loginRequired() , adminRequired ,admin.export.index);
+  app.get('/admin/export/:fileName' , loginRequired() , adminRequired ,admin.export.download);
 
   app.get('/me'                       , loginRequired() , me.index);
   app.get('/me/password'              , loginRequired() , me.password);

+ 3 - 17
src/server/service/export.js

@@ -12,7 +12,6 @@ class ExportService {
     this.appService = crowi.appService;
     this.growiBridgeService = crowi.growiBridgeService;
     this.baseDir = path.join(crowi.tmpDir, 'downloads');
-    this.zipFileName = 'GROWI.zip';
     this.metaFileName = 'meta.json';
     this.encoding = 'utf-8';
     this.per = 100;
@@ -185,7 +184,9 @@ class ExportService {
    */
   async zipFiles(_configs) {
     const configs = toArrayIfNot(_configs);
-    const zipFile = path.join(this.baseDir, this.zipFileName);
+    const appTitle = this.appService.getAppTitle();
+    const timeStamp = (new Date()).getTime();
+    const zipFile = path.join(this.baseDir, `${appTitle}-${timeStamp}.zip`);
     const archive = archiver('zip', {
       zlib: { level: this.zlibLevel },
     });
@@ -238,21 +239,6 @@ class ExportService {
     return jsonFile;
   }
 
-  /**
-   * get the absolute path to the zip file
-   *
-   * @memberOf ExportService
-   * @return {string} absolute path to the zip file
-   */
-  getZipFile() {
-    const zipFile = path.join(this.baseDir, this.zipFileName);
-
-    // throws err if the file does not exist
-    fs.accessSync(zipFile);
-
-    return zipFile;
-  }
-
   /**
    * get a model from collection name
    *