mizozobu 6 лет назад
Родитель
Сommit
21b5a48771

+ 89 - 30
src/client/js/components/Admin/Import/GrowiImportForm.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
@@ -11,9 +11,19 @@ class GrowiImportForm extends React.Component {
   constructor(props) {
     super(props);
 
+    this.state = {
+      meta: {},
+      files: [],
+      schema: {
+        pages: {},
+        revisions: {},
+      },
+    };
+
     this.inputRef = React.createRef();
 
     this.changeFileName = this.changeFileName.bind(this);
+    this.uploadZipFile = this.uploadZipFile.bind(this);
     this.import = this.import.bind(this);
     this.validateForm = this.validateForm.bind(this);
   }
@@ -24,7 +34,7 @@ class GrowiImportForm extends React.Component {
     this.setState({ name: e.target.files[0].name });
   }
 
-  async import(e) {
+  async uploadZipFile(e) {
     e.preventDefault();
 
     const formData = new FormData();
@@ -32,7 +42,24 @@ class GrowiImportForm extends React.Component {
     formData.append('file', this.inputRef.current.files[0]);
 
     // TODO use appContainer.apiv3.post
-    await this.props.appContainer.apiPost('/v3/import/pages', formData);
+    const { data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
+    this.setState({ meta: data.meta, files: data.files });
+    // TODO toastSuccess, toastError
+  }
+
+  async import(e) {
+    e.preventDefault();
+
+    // TODO use appContainer.apiv3.post
+    await this.props.appContainer.apiPost('/v3/import', {
+      meta: this.state.meta,
+      options: this.state.files.map((option) => {
+        return {
+          ...option,
+          schema: this.state.schema[option.collectionName],
+        };
+      }),
+    });
     // TODO toastSuccess, toastError
   }
 
@@ -48,35 +75,67 @@ class GrowiImportForm extends React.Component {
     const { t } = this.props;
 
     return (
-      <form className="form-horizontal" onSubmit={this.import}>
-        <fieldset>
-          <legend>Import</legend>
-          <div className="well well-sm small">
-            <ul>
-              <li>Imported pages will overwrite existing pages</li>
-            </ul>
-          </div>
-          <div className="form-group d-flex align-items-center">
-            <label htmlFor="file" className="col-xs-3 control-label">Zip File</label>
-            <div className="col-xs-6">
-              <input
-                type="file"
-                name="file"
-                className="form-control-file"
-                ref={this.inputRef}
-                onChange={this.changeFileName}
-              />
+      <Fragment>
+        <form className="form-horizontal" onSubmit={this.uploadZipFile}>
+          <fieldset>
+            <legend>Import</legend>
+            <div className="well well-sm small">
+              <ul>
+                <li>Imported pages will overwrite existing pages</li>
+              </ul>
+            </div>
+            <div className="form-group d-flex align-items-center">
+              <label htmlFor="file" className="col-xs-3 control-label">Zip File</label>
+              <div className="col-xs-6">
+                <input
+                  type="file"
+                  name="file"
+                  className="form-control-file"
+                  ref={this.inputRef}
+                  onChange={this.changeFileName}
+                />
+              </div>
             </div>
-          </div>
-          <div className="form-group">
-            <div className="col-xs-offset-3 col-xs-6">
-              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
-                { t('importer_management.import') }
-              </button>
+            <div className="form-group">
+              <div className="col-xs-offset-3 col-xs-6">
+                <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
+                  Upload
+                </button>
+              </div>
             </div>
-          </div>
-        </fieldset>
-      </form>
+          </fieldset>
+        </form>
+
+        {/* TODO: move to another component 1 */}
+        {this.state.files.length > 0 && (
+          <Fragment>
+            {/* TODO: move to another component 2 */}
+            <div>{JSON.stringify(this.state.meta)}</div>
+            <table className="table table-bordered table-mapping">
+              <thead>
+                <tr>
+                  <th>File</th>
+                  <th>Collection</th>
+                </tr>
+              </thead>
+              <tbody>
+                {this.state.files.map((file) => {
+                  return (
+                    <tr key={file.fileName}>
+                      <td>{file.fileName}</td>
+                      <td>{file.collectionName}</td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+            {/* TODO: move to another component 3 */}
+            <button type="submit" className="btn btn-primary" onClick={this.import}>
+              { t('importer_management.import') }
+            </button>
+          </Fragment>
+        )}
+      </Fragment>
     );
   }
 

+ 37 - 14
src/server/routes/apiv3/import.js

@@ -37,13 +37,14 @@ module.exports = (crowi) => {
    * @param {object} req request object
    * @return {object} document to be persisted
    */
-  const overwriteParamsFn = async(Model, req) => {
+  const overwriteParamsFn = async(Model, schema, req) => {
     const { collectionName } = Model.collection;
 
     /* eslint-disable no-case-declarations */
     switch (Model.collection.collectionName) {
       case 'pages':
-        // TODO: use req.body to generate overwriteParams
+        // TODO: use schema and req to generate overwriteParams
+        // e.g. { creator: schema.creator === 'me' ? ObjectId(req.user._id) : importService.keepOriginal }
         return {
           status: 'published', // FIXME when importing users and user groups
           grant: 1, // FIXME when importing users and user groups
@@ -73,7 +74,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *  /import/:collection:
+   *  /import:
    *    post:
    *      tags: [Import]
    *      description: import a collection from a zipped json
@@ -85,21 +86,26 @@ module.exports = (crowi) => {
    *          content:
    *            application/json:
    */
-  router.post('/:collection', uploads.single('file'), autoReap, async(req, res) => {
+  router.post('/', async(req, res) => {
     // TODO: add express validator
-    const { file } = req;
-    const { collection } = req.params;
-    const Model = importService.getModelFromCollectionName(collection);
-    const zipFilePath = path.join(file.destination, file.filename);
+    // eslint-disable-next-line no-unused-vars
+    const { meta, options } = req.body;
+
+    // TODO: validate using meta data
 
     try {
-      let overwriteParams;
-      if (overwriteParamsFn[collection] != null) {
-        // await in case overwriteParamsFn[collection] is a Promise
-        overwriteParams = await overwriteParamsFn(Model, req);
-      }
+      await Promise.all(options.map(async({ collectionName, fileName, schema }) => {
+        const Model = importService.getModelFromCollectionName(collectionName);
+        const jsonFile = importService.getJsonFile(fileName, true);
 
-      await importService.importFromZip(Model, zipFilePath, overwriteParams);
+        let overwriteParams;
+        if (overwriteParamsFn[collectionName] != null) {
+          // await in case overwriteParamsFn[collection] is a Promise
+          overwriteParams = await overwriteParamsFn(Model, schema, req);
+        }
+
+        await importService.import(Model, jsonFile, overwriteParams);
+      }));
 
       // TODO: use res.apiv3
       return res.send({ status: 'OK' });
@@ -111,5 +117,22 @@ module.exports = (crowi) => {
     }
   });
 
+  router.post('/upload', uploads.single('file'), autoReap, async(req, res) => {
+    const { file } = req;
+    const zipFile = path.join(file.destination, file.filename);
+
+    try {
+      const data = await importService.unzip(zipFile);
+
+      // TODO: use res.apiv3
+      return res.send({ ok: true, data });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
+
   return router;
 };

+ 6 - 6
src/server/service/export.js

@@ -10,13 +10,12 @@ class ExportService {
   constructor(crowi) {
     this.appService = crowi.appService;
     this.baseDir = path.join(crowi.tmpDir, 'downloads');
+    this.zipFileName = 'GROWI.zip';
+    this.metaFileName = 'meta.json';
     this.encoding = 'utf-8';
     this.per = 100;
     this.zlibLevel = 9; // 0(min) - 9(max)
 
-    // path to zip file for exporting multiple collection
-    this.zipFile = path.join(this.baseDir, 'GROWI.zip');
-
     // { pages: Page, users: User, ... }
     this.collectionMap = {};
     this.initCollectionMap(crowi.models);
@@ -164,6 +163,7 @@ class ExportService {
    */
   async zipFiles(_configs) {
     const configs = toArrayIfNot(_configs);
+    const zipFile = path.join(this.baseDir, this.zipFileName);
     const archive = archiver('zip', {
       zlib: { level: this.zlibLevel },
     });
@@ -184,7 +184,7 @@ class ExportService {
       archive.append(input, { name: as });
     }
 
-    const output = fs.createWriteStream(this.zipFile);
+    const output = fs.createWriteStream(zipFile);
 
     // pipe archive data to the file
     archive.pipe(output);
@@ -195,9 +195,9 @@ class ExportService {
 
     await streamToPromise(archive);
 
-    logger.debug(`zipped growi data into ${this.zipFile} (${archive.pointer()} bytes)`);
+    logger.debug(`zipped growi data into ${zipFile} (${archive.pointer()} bytes)`);
 
-    return this.zipFile;
+    return zipFile;
   }
 
   /**

+ 67 - 19
src/server/service/import.js

@@ -3,13 +3,14 @@ const fs = require('fs');
 const path = require('path');
 const JSONStream = require('JSONStream');
 const streamToPromise = require('stream-to-promise');
-const unzip = require('unzipper');
+const unzipper = require('unzipper');
 const { ObjectId } = require('mongoose').Types;
 
 class ImportService {
 
   constructor(crowi) {
     this.baseDir = path.join(crowi.tmpDir, 'imports');
+    this.metaFileName = 'meta.json';
     this.encoding = 'utf-8';
     this.per = 100;
     this.keepOriginal = this.keepOriginal.bind(this);
@@ -79,23 +80,18 @@ class ImportService {
    *
    * @memberOf ImportService
    * @param {object} Model instance of mongoose model
-   * @param {string} filePath path to zipped json
    * @param {object} overwriteParams overwrite each document with unrelated value. e.g. { creator: req.user }
    */
-  async importFromZip(Model, filePath, overwriteParams = {}) {
+  async import(Model, jsonFile, overwriteParams = {}) {
     const { collectionName } = Model.collection;
 
-    // extract zip file
-    await this.unzip(filePath);
-
     let counter = 0;
     let nInsertedTotal = 0;
 
     let failedIds = [];
     let unorderedBulkOp = Model.collection.initializeUnorderedBulkOp();
 
-    const tmpJson = path.join(this.baseDir, `${collectionName}.json`);
-    const readStream = fs.createReadStream(tmpJson, { encoding: this.encoding });
+    const readStream = fs.createReadStream(jsonFile, { encoding: this.encoding });
     const jsonStream = readStream.pipe(JSONStream.parse('*'));
 
     jsonStream.on('data', async(document) => {
@@ -139,25 +135,47 @@ class ImportService {
     await streamToPromise(readStream);
 
     // clean up tmp directory
-    fs.unlinkSync(tmpJson);
+    fs.unlinkSync(jsonFile);
   }
 
   /**
    * extract a zip file
    *
    * @memberOf ImportService
-   * @param {string} zipFilePath path to zip file
+   * @param {string} zipFile path to zip file
+   * @return  {object} meta{object} and files{array<object>}
    */
-  unzip(zipFilePath) {
-    return new Promise((resolve, reject) => {
-      const unzipStream = fs.createReadStream(zipFilePath).pipe(unzip.Extract({ path: this.baseDir }));
-      unzipStream.on('error', (err) => {
-        reject(err);
-      });
-      unzipStream.on('close', () => {
-        resolve();
-      });
+  async unzip(zipFile) {
+    const readStream = fs.createReadStream(zipFile);
+    const unzipStream = readStream.pipe(unzipper.Parse());
+    const files = [];
+
+    unzipStream.on('entry', (entry) => {
+      const fileName = entry.path;
+      const size = entry.vars.uncompressedSize; // There is also compressedSize;
+
+      if (fileName === this.metaFileName) {
+        // TODO: parse meta.json
+        entry.autodrain();
+      }
+      else {
+        const writeStream = fs.createWriteStream(this.getJsonFile(fileName), { encoding: this.encoding });
+        entry.pipe(writeStream);
+
+        files.push({
+          fileName,
+          collectionName: path.basename(fileName, '.json'),
+          size,
+        });
+      }
     });
+
+    await streamToPromise(unzipStream);
+
+    return {
+      meta: {},
+      files,
+    };
   }
 
   /**
@@ -256,6 +274,36 @@ class ImportService {
     return Model;
   }
 
+  /**
+   * get the absolute path to a file
+   *
+   * @memberOf ImportService
+   * @param {object} fileName base name of file
+   * @param {boolean} [validate=false] boolean to check if the file exists
+   * @return {string} absolute path to the file
+   */
+  getJsonFile(fileName, validate = false) {
+    const jsonFile = path.join(this.baseDir, fileName);
+
+    if (validate) {
+      try {
+        fs.accessSync(jsonFile);
+      }
+      catch (err) {
+        if (err.code === 'ENOENT') {
+          logger.error(`${jsonFile} does not exist`, err);
+        }
+        else {
+          logger.error(err);
+        }
+
+        throw err;
+      }
+    }
+
+    return jsonFile;
+  }
+
 }
 
 module.exports = ImportService;