mizozobu 6 лет назад
Родитель
Сommit
89f6dceaf7

+ 45 - 5
src/client/js/components/Admin/Export/ExportAsZip.jsx

@@ -11,14 +11,32 @@ class ExportPage extends React.Component {
   constructor(props) {
     super(props);
 
+    this.state = {
+      files: {},
+    };
+
     this.createZipFile = this.createZipFile.bind(this);
     this.deleteZipFile = this.deleteZipFile.bind(this);
   }
 
+  async componentDidMount() {
+    const res = await this.props.appContainer.apiGet('/v3/export', {});
+
+    this.setState({ files: res.files });
+  }
+
   async createZipFile() {
     // TODO use appContainer.apiv3.post
-    await this.props.appContainer.apiPost('/v3/export/pages', {});
+    const res = await this.props.appContainer.apiPost('/v3/export/pages', {});
     // TODO toastSuccess, toastError
+    this.setState((prevState) => {
+      return {
+        files: {
+          ...prevState.files,
+          [res.collection]: res.file,
+        },
+      };
+    });
   }
 
   async deleteZipFile() {
@@ -27,16 +45,38 @@ class ExportPage extends React.Component {
   }
 
   render() {
-    const { t } = this.props;
+    // const { t } = this.props;
 
     return (
       <Fragment>
         <h2>Export Data as Zip</h2>
-        <button onClick={this.createZipFile}>Generate</button>
+        <form className="my-5">
+          {Object.keys(this.state.files).map((file) => {
+            const disabled = file !== 'pages';
+            return (
+              <div className="form-check" key={file}>
+                <input
+                  type="radio"
+                  id={file}
+                  name="collection"
+                  className="form-check-input"
+                  value={file}
+                  disabled={disabled}
+                  checked={!disabled}
+                  onChange={() => {}}
+                />
+                <label className={`form-check-label ml-3 ${disabled ? 'text-muted' : ''}`} htmlFor={file}>
+                  {file} ({this.state.files[file] || 'not found'})
+                </label>
+              </div>
+            );
+          })}
+        </form>
+        <button type="button" className="btn btn-sm btn-default" onClick={this.createZipFile}>Generate</button>
         <a href="/_api/v3/export/pages">
-          <button>Download</button>
+          <button type="button" className="btn btn-sm btn-primary ml-2">Download</button>
         </a>
-        <button onClick={this.deleteZipFile}>Clear</button>
+        {/* <button type="button" className="btn btn-sm btn-danger ml-2" onClick={this.deleteZipFile}>Clear</button> */}
       </Fragment>
     );
   }

+ 99 - 3
src/server/routes/apiv3/export.js

@@ -1,6 +1,7 @@
 const loggerFactory = require('@alias/logger');
 
 const logger = loggerFactory('growi:routes:apiv3:export'); // eslint-disable-line no-unused-vars
+const path = require('path');
 
 const express = require('express');
 
@@ -16,12 +17,74 @@ module.exports = (crowi) => {
   const { exportService } = crowi;
   const { Page } = crowi.models;
 
+  /**
+   * @swagger
+   *
+   *  /export:
+   *    get:
+   *      tags: [Export]
+   *      description: get mongodb collections names and zip files for them
+   *      produces:
+   *        - application/json
+   *      responses:
+   *        200:
+   *          description: export cache info
+   *          content:
+   *            application/json:
+   */
+  router.get('/', async(req, res) => {
+    try {
+      const files = exportService.getStatus();
+
+      // TODO:use res.apiv3
+      return res.json({ ok: true, files });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
   /**
    * @swagger
    *
    *  /export/pages:
    *    get:
-   *      tags: [Page]
+   *      tags: [Export]
+   *      description: download a zipped json for page collection
+   *      produces:
+   *        - application/json
+   *      responses:
+   *        200:
+   *          description: a zip file
+   *          content:
+   *            application/zip:
+   */
+  router.get('/pages', async(req, res) => {
+    // TODO: rename path to "/:collection" and add express validator
+    try {
+      const file = exportService.getZipFile(Page);
+
+      if (file == null) {
+        throw new Error('the target file does not exist');
+      }
+
+      return res.download(file);
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  /export/pages:
+   *    post:
+   *      tags: [Export]
    *      description: generate a zipped json for page collection
    *      produces:
    *        - application/json
@@ -31,10 +94,43 @@ module.exports = (crowi) => {
    *          content:
    *            application/json:
    */
-  router.get('/pages', async(req, res) => {
+  router.post('/pages', async(req, res) => {
+    // TODO: rename path to "/:collection" and add express validator
+    try {
+      const file = await exportService.exportCollection(Page);
+      // TODO:use res.apiv3
+      return res.status(200).json({
+        ok: true,
+        collection: [Page.collection.collectionName],
+        file: path.basename(file),
+      });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  /export/pages:
+   *    delete:
+   *      tags: [Export]
+   *      description: unlink a json and zip file for page collection
+   *      produces:
+   *        - application/json
+   *      responses:
+   *        200:
+   *          description: the json and zip file are removed
+   *          content:
+   *            application/json:
+   */
+  router.delete('/pages', async(req, res) => {
     // TODO: rename path to "/:collection" and add express validator
     try {
-      await exportService.exportCollection(Page);
+      // remove .json and .zip for collection
       // TODO:use res.apiv3
       return res.status(200).send({ status: 'DONE' });
     }

+ 71 - 15
src/server/service/export.js

@@ -29,6 +29,29 @@ class ExportService {
     });
   }
 
+  /**
+   * dump a collection into json
+   *
+   * @memberOf ExportService
+   * @return {object} cache info for exported zip files
+   */
+  getStatus() {
+    const status = {};
+    const collections = Object.keys(this.files);
+    collections.forEach((file) => {
+      status[path.basename(file, '.zip')] = null;
+    });
+
+    // extract ${collectionName}.zip
+    const files = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' && collections.includes(path.basename(file, '.zip')) });
+
+    files.forEach((file) => {
+      status[path.basename(file, '.zip')] = file;
+    });
+
+    return status;
+  }
+
   /**
    * dump a collection into json
    *
@@ -41,6 +64,9 @@ class ExportService {
     let n = 0;
     const ws = fs.createWriteStream(file, { encoding: this.encoding });
 
+    // open an array
+    ws.write('[');
+
     readStream.on('data', (chunk) => {
       if (n !== 0) ws.write(',');
       ws.write(JSON.stringify(chunk));
@@ -54,12 +80,31 @@ class ExportService {
       ws.close();
     });
 
-    // open an array
-    ws.write('[');
-
     await streamToPromise(readStream);
   }
 
+  /**
+   * dump a mongodb collection into json
+   *
+   * @memberOf ExportService
+   * @param {object} Model instance of mongoose model
+   * @return {string} path to zip file
+   */
+  async exportCollection(Model) {
+    const modelName = Model.collection.collectionName;
+    const file = this.files[modelName];
+    const total = await Model.countDocuments();
+    const readStream = Model.find().cursor();
+
+    await this.export(file, readStream, total);
+
+    const { file: zipFile, size } = await this.zipSingleFile(file);
+
+    logger.info(`exported ${modelName} collection into ${zipFile} (${size} bytes)`);
+
+    return zipFile;
+  }
+
   /**
    * log export progress
    *
@@ -89,9 +134,10 @@ class ExportService {
    * @param {string} from path to input file
    * @param {string} [to=`${path.join(path.dirname(from), `${path.basename(from, path.extname(from))}.zip`)}`] path to output file
    * @param {string} [as=path.basename(from)] file name after unzipped
+   * @return {object} file path and file size
    * @see https://www.archiverjs.com/#quick-start
    */
-  async zipSingleFile(from, to = `${path.join(path.dirname(from), `${path.basename(from, path.extname(from))}.zip`)}`, as = path.basename(from)) {
+  async zipSingleFile(from, to = this.replaceExtension(from, 'zip'), as = path.basename(from)) {
     const archive = archiver('zip', {
       zlib: { level: this.zlibLevel },
     });
@@ -126,22 +172,32 @@ class ExportService {
   }
 
   /**
-   * dump a mongodb collection into json
+   * replace a file extension
    *
    * @memberOf ExportService
-   * @param {object} Model instance of mongoose model
+   * @param {string} file file path
+   * @param {string} extension new extension
+   * @return {string} path to file with new extension
    */
-  async exportCollection(Model) {
-    const modelName = Model.collection.collectionName;
-    const file = this.files[modelName];
-    const total = await Model.countDocuments();
-    const readStream = Model.find().cursor();
-
-    await this.export(file, readStream, total);
+  replaceExtension(file, extension) {
+    return `${path.join(path.dirname(file), `${path.basename(file, path.extname(file))}.${extension}`)}`;
+  }
 
-    const { file: zipFile, size } = await this.zipSingleFile(file);
+  /**
+   * get the path to the zipped file for a collection
+   *
+   * @memberOf ExportService
+   * @param {object} Model instance of mongoose model
+   * @return {string} path to zip file
+   */
+  getZipFile(Model) {
+    const json = this.files[Model.collection.collectionName];
+    const zip = this.replaceExtension(json, 'zip');
+    if (!fs.existsSync(zip)) {
+      return null;
+    }
 
-    logger.info(`exported ${modelName} collection into ${zipFile} (${size} bytes)`);
+    return zip;
   }
 
 }