فهرست منبع

Merge pull request #1200 from weseek/feat/export-n-import-revision-2

Feat/export n import revision 2
Yuki Takei 6 سال پیش
والد
کامیت
57aa7df693

+ 120 - 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,24 @@ class GrowiImportForm extends React.Component {
   constructor(props) {
     super(props);
 
+    this.initialState = {
+      meta: {},
+      zipFileName: '',
+      collections: new Set(),
+      fileStats: [],
+      schema: {
+        pages: {},
+        revisions: {},
+      },
+    };
+
+    this.state = this.initialState;
+
     this.inputRef = React.createRef();
 
     this.changeFileName = this.changeFileName.bind(this);
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.uploadZipFile = this.uploadZipFile.bind(this);
     this.import = this.import.bind(this);
     this.validateForm = this.validateForm.bind(this);
   }
@@ -24,7 +39,23 @@ class GrowiImportForm extends React.Component {
     this.setState({ name: e.target.files[0].name });
   }
 
-  async import(e) {
+  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 uploadZipFile(e) {
     e.preventDefault();
 
     const formData = new FormData();
@@ -32,10 +63,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 { file, data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
+    this.setState({ meta: data.meta, zipFileName: file, fileStats: data.fileStats });
     // TODO toastSuccess, toastError
   }
 
+  async import(e) {
+    e.preventDefault();
+
+    // TODO use appContainer.apiv3.post
+    await this.props.appContainer.apiPost('/v3/import', {
+      fileName: this.state.zipFileName,
+      collections: Array.from(this.state.collections),
+      schema: this.state.schema,
+    });
+    // TODO toastSuccess, toastError
+    this.setState(this.initialState);
+  }
+
   validateForm() {
     return (
       this.inputRef.current // null check
@@ -48,35 +93,80 @@ 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.fileStats.length > 0 && (
+          <Fragment>
+            {/* TODO: move to another component 2 */}
+            <div>{this.state.zipFileName}</div>
+            <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.fileStats.map((file) => {
+                  const { fileName, collectionName } = file;
+                  return (
+                    <tr key={fileName}>
+                      <td>
+                        <input
+                          type="checkbox"
+                          id={collectionName}
+                          name={collectionName}
+                          className="form-check-input"
+                          value={collectionName}
+                          checked={this.state.collections.has(collectionName)}
+                          onChange={this.toggleCheckbox}
+                        />
+                      </td>
+                      <td>{fileName}</td>
+                      <td>{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>
     );
   }
 

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

@@ -56,7 +56,7 @@ module.exports = (crowi) => {
   router.get('/', async(req, res) => {
     // TODO: add express validator
     try {
-      return res.download(exportService.zipFile);
+      return res.download(exportService.getZipFile());
     }
     catch (err) {
       // TODO: use ApiV3Error

+ 60 - 17
src/server/routes/apiv3/import.js

@@ -3,7 +3,6 @@ const loggerFactory = require('@alias/logger');
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 const path = require('path');
 const multer = require('multer');
-const autoReap = require('multer-autoreap');
 const { ObjectId } = require('mongoose').Types;
 
 const express = require('express');
@@ -19,7 +18,15 @@ const router = express.Router();
 module.exports = (crowi) => {
   const { importService } = crowi;
   const uploads = multer({
-    dest: importService.baseDir,
+    storage: multer.diskStorage({
+      destination: (req, file, cb) => {
+        cb(null, importService.baseDir);
+      },
+      filename(req, file, cb) {
+        // to prevent hashing the file name. files with same name will be overwritten.
+        cb(null, file.originalname);
+      },
+    }),
     fileFilter: (req, file, cb) => {
       if (path.extname(file.originalname) === '.zip') {
         return cb(null, true);
@@ -37,13 +44,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 +81,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *  /import/:collection:
+   *  /import:
    *    post:
    *      tags: [Import]
    *      description: import a collection from a zipped json
@@ -85,24 +93,59 @@ 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);
+
+    const { fileName, collections, schema } = req.body;
+    const zipFile = importService.getFile(fileName);
+
+    // unzip
+    await importService.unzip(zipFile);
+    // eslint-disable-next-line no-unused-vars
+    const { meta, fileStats } = await importService.parseZipFile(zipFile);
+
+    // filter fileStats
+    const filteredFileStats = fileStats.filter(({ fileName, collectionName, size }) => { return collections.includes(collectionName) });
+
+    // 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(filteredFileStats.map(async({ fileName, collectionName, size }) => {
+        const Model = importService.getModelFromCollectionName(collectionName);
+        const jsonFile = importService.getFile(fileName);
+
+        let overwriteParams;
+        if (overwriteParamsFn[collectionName] != null) {
+          // await in case overwriteParamsFn[collection] is a Promise
+          overwriteParams = await overwriteParamsFn(Model, schema[collectionName], req);
+        }
+
+        await importService.import(Model, jsonFile, overwriteParams);
+      }));
+
+      // TODO: use res.apiv3
+      return res.send({ ok: true });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ status: 'ERROR' });
+    }
+  });
 
-      await importService.importFromZip(Model, zipFilePath, overwriteParams);
+  router.post('/upload', uploads.single('file'), async(req, res) => {
+    const { file } = req;
+    const zipFile = importService.getFile(file.filename);
+
+    try {
+      const data = await importService.parseZipFile(zipFile);
 
       // TODO: use res.apiv3
-      return res.send({ status: 'OK' });
+      return res.send({
+        ok: true,
+        file: file.filename,
+        data,
+      });
     }
     catch (err) {
       // TODO: use ApiV3Error

+ 14 - 28
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,38 +195,24 @@ 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;
   }
 
   /**
-   * replace a file extension
+   * get the absolute path to the zip file
    *
-   * @memberOf ExportService
-   * @param {string} file file path
-   * @param {string} extension new extension
-   * @return {string} path to file with new extension
+   * @memberOf ImportService
+   * @return {string} absolute path to the zip file
    */
-  replaceExtension(file, extension) {
-    return `${path.join(path.dirname(file), `${path.basename(file, path.extname(file))}.${extension}`)}`;
-  }
+  getZipFile() {
+    const zipFile = path.join(this.baseDir, this.zipFileName);
 
-  /**
-   * 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)) {
-      throw new Error(`${zip} does not exist`);
-    }
+    // throws err if the file does not exist
+    fs.accessSync(zipFile);
 
-    return zip;
+    return zipFile;
   }
 
   /**

+ 86 - 21
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);
@@ -58,7 +59,7 @@ class ImportService {
    * automatically convert ObjectId
    *
    * @memberOf ImportService
-   * @param {array<object>} _value value from imported document
+   * @param {any} _value value from imported document
    * @param {{ _document: object, schema: object, key: string }}
    * @return {any} new value for the document
    */
@@ -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,78 @@ class ImportService {
     await streamToPromise(readStream);
 
     // clean up tmp directory
-    fs.unlinkSync(tmpJson);
+    fs.unlinkSync(jsonFile);
+  }
+
+  /**
+   * parse a zip file
+   *
+   * @memberOf ImportService
+   * @param {string} zipFile path to zip file
+   * @return {object} meta{object} and files{array<object>}
+   */
+  async parseZipFile(zipFile) {
+    const readStream = fs.createReadStream(zipFile);
+    const unzipStream = readStream.pipe(unzipper.Parse());
+    const fileStats = [];
+
+    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 {
+        fileStats.push({
+          fileName,
+          collectionName: path.basename(fileName, '.json'),
+          size,
+        });
+      }
+
+      entry.autodrain();
+    });
+
+    await streamToPromise(unzipStream);
+
+    return {
+      meta: {},
+      fileStats,
+    };
   }
 
   /**
    * extract a zip file
    *
    * @memberOf ImportService
-   * @param {string} zipFilePath path to zip file
+   * @param {string} zipFile path to zip file
+   * @return {Array.<string>} array of absolute paths to extracted files
    */
-  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;
+
+      if (fileName === this.metaFileName) {
+        // TODO: parse meta.json
+        entry.autodrain();
+      }
+      else {
+        const jsonFile = path.join(this.baseDir, fileName);
+        const writeStream = fs.createWriteStream(jsonFile, { encoding: this.encoding });
+        entry.pipe(writeStream);
+        files.push(jsonFile);
+      }
     });
+
+    await streamToPromise(unzipStream);
+
+    return files;
   }
 
   /**
@@ -243,7 +292,7 @@ class ImportService {
    * get a model from collection name
    *
    * @memberOf ImportService
-   * @param {object} collectionName collection name
+   * @param {string} collectionName collection name
    * @return {object} instance of mongoose model
    */
   getModelFromCollectionName(collectionName) {
@@ -256,6 +305,22 @@ class ImportService {
     return Model;
   }
 
+  /**
+   * get the absolute path to a file
+   *
+   * @memberOf ImportService
+   * @param {string} fileName base name of file
+   * @return {string} absolute path to the file
+   */
+  getFile(fileName) {
+    const jsonFile = path.join(this.baseDir, fileName);
+
+    // throws err if the file does not exist
+    fs.accessSync(jsonFile);
+
+    return jsonFile;
+  }
+
 }
 
 module.exports = ImportService;