mizozobu před 6 roky
rodič
revize
3a81825d01

+ 47 - 25
src/client/js/components/Admin/Export/ExportAsZip.jsx

@@ -7,25 +7,24 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
-class ExportPage extends React.Component {
+class ExportAsZip extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
-      files: {},
+      zipFileStats: [],
       collections: new Set(),
     };
 
     this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.exportMultiple = this.exportMultiple.bind(this);
+    this.export = this.export.bind(this);
     this.deleteZipFile = this.deleteZipFile.bind(this);
   }
 
   async componentDidMount() {
-    const res = await this.props.appContainer.apiGet('/v3/export/status', {});
-
-    this.setState({ files: res.files });
+    const { zipFileStats } = await this.props.appContainer.apiGet('/v3/export/status', {});
+    this.setState({ zipFileStats });
   }
 
   toggleCheckbox(e) {
@@ -45,7 +44,7 @@ class ExportPage extends React.Component {
     });
   }
 
-  async exportMultiple() {
+  async export() {
     // TODO use appContainer.apiv3.post
     const res = await this.props.appContainer.apiPost('/v3/export', { collections: Array.from(this.state.collections) });
     // TODO toastSuccess, toastError
@@ -59,21 +58,21 @@ class ExportPage extends React.Component {
     });
   }
 
-  async deleteZipFile() {
+  async deleteZipFile(zipFile) {
     // TODO use appContainer.apiv3.delete
+    await this.props.appContainer.apiRequest('delete', `/v3/export/${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">
-          {Object.keys(this.state.files).map((collectionName) => {
-            const disabled = !(collectionName === 'pages' || collectionName === 'revisions');
-            const stat = this.state.files[collectionName] || {};
+          {collections.map((collectionName) => {
             return (
               <div className="checkbox checkbox-info" key={collectionName}>
                 <input
@@ -82,40 +81,63 @@ class ExportPage extends React.Component {
                   name={collectionName}
                   className="form-check-input"
                   value={collectionName}
-                  disabled={disabled}
                   checked={this.state.collections.has(collectionName)}
                   onChange={this.toggleCheckbox}
                 />
-                <label className={`form-check-label ml-3 ${disabled ? 'text-muted' : ''}`} htmlFor={collectionName}>
-                  {collectionName} ({stat.name || 'not found'}) ({stat.mtime ? format(new Date(stat.mtime), 'yyyy/MM/dd HH:mm:ss') : ''})
+                <label className="form-check-label ml-3" htmlFor={collectionName}>
+                  {collectionName}
                 </label>
               </div>
             );
           })}
         </form>
-        <button type="button" className="btn btn-sm btn-default" onClick={this.exportMultiple}>Generate</button>
-        <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}>Clear</button> */}
+        <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>
     );
   }
 
 }
 
-ExportPage.propTypes = {
+ExportAsZip.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isAclEnabled: PropTypes.bool,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const ExportPageWrapper = (props) => {
-  return createSubscribedElement(ExportPage, props, [AppContainer]);
+const ExportAsZipWrapper = (props) => {
+  return createSubscribedElement(ExportAsZip, props, [AppContainer]);
 };
 
-export default withTranslation()(ExportPageWrapper);
+export default withTranslation()(ExportAsZipWrapper);

+ 3 - 2
src/client/js/components/Admin/Import/GrowiImportForm.jsx

@@ -63,8 +63,8 @@ class GrowiImportForm extends React.Component {
     formData.append('file', this.inputRef.current.files[0]);
 
     // TODO use appContainer.apiv3.post
-    const { file, data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
-    this.setState({ meta: data.meta, zipFileName: file, fileStats: data.fileStats });
+    const { data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
+    this.setState({ meta: data.meta, zipFileName: data.fileName, fileStats: data.fileStats });
     // TODO toastSuccess, toastError
   }
 
@@ -133,6 +133,7 @@ class GrowiImportForm extends React.Component {
             <table className="table table-bordered table-mapping">
               <thead>
                 <tr>
+                  <th></th>
                   <th>File</th>
                   <th>Collection</th>
                 </tr>

+ 25 - 15
src/server/routes/apiv3/export.js

@@ -32,10 +32,10 @@ module.exports = (crowi) => {
    *            application/json:
    */
   router.get('/status', async(req, res) => {
-    const files = exportService.getStatus();
+    const zipFileStats = await exportService.getStatus();
 
     // TODO: use res.apiv3
-    return res.json({ ok: true, files });
+    return res.json({ ok: true, zipFileStats });
   });
 
   /**
@@ -122,25 +122,35 @@ module.exports = (crowi) => {
    *      description: unlink all json and zip files for exports
    *      produces:
    *        - application/json
+   *      parameters:
+   *        - name: fileName
+   *          in: path
+   *          description: file name of zip file
+   *          schema:
+   *            type: string
    *      responses:
    *        200:
    *          description: the json and zip file are deleted
    *          content:
    *            application/json:
    */
-  // router.delete('/', async(req, res) => {
-  //   // TODO: add express validator
-  //   try {
-  //     // remove .json and .zip for collection
-  //     // TODO: use res.apiv3
-  //     return res.status(200).send({ status: 'DONE' });
-  //   }
-  //   catch (err) {
-  //     // TODO: use ApiV3Error
-  //     logger.error(err);
-  //     return res.status(500).send({ status: 'ERROR' });
-  //   }
-  // });
+  router.delete('/:fileName', async(req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = exportService.getFile(fileName);
+      exportService.deleteZipFile(zipFile);
+
+      // TODO: use res.apiv3
+      return res.status(200).send({ ok: true });
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.status(500).send({ ok: false });
+    }
+  });
 
   return router;
 };

+ 0 - 1
src/server/routes/apiv3/import.js

@@ -143,7 +143,6 @@ module.exports = (crowi) => {
       // TODO: use res.apiv3
       return res.send({
         ok: true,
-        file: file.filename,
         data,
       });
     }

+ 39 - 25
src/server/service/export.js

@@ -10,6 +10,7 @@ class ExportService {
   constructor(crowi) {
     this.crowi = crowi;
     this.appService = crowi.appService;
+    this.growiBridgeService = crowi.growiBridgeService;
     this.baseDir = path.join(crowi.tmpDir, 'downloads');
     this.zipFileName = 'GROWI.zip';
     this.metaFileName = 'meta.json';
@@ -47,32 +48,19 @@ class ExportService {
   }
 
   /**
-   * dump a collection into json
+   * parse all zip files in downloads dir
    *
    * @memberOf ExportService
-   * @return {object} cache info for exported zip files
+   * @return {Array.<object>} info for 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;
-    });
-
-    files.forEach((file) => {
-      const stats = fs.statSync(path.join(this.baseDir, file));
-      stats.name = file;
-      status[path.basename(file, '.zip')] = stats;
-    });
-
-    return status;
+  async getStatus() {
+    const zipFiles = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' });
+    const zipFileStats = await Promise.all(zipFiles.map((file) => {
+      const zipFile = this.getFile(file);
+      return this.growiBridgeService.parseZipFile(zipFile);
+    }));
+
+    return zipFileStats;
   }
 
   /**
@@ -234,10 +222,26 @@ class ExportService {
     return zipFile;
   }
 
+  /**
+   * get the absolute path to a file
+   *
+   * @memberOf ExportService
+   * @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;
+  }
+
   /**
    * get the absolute path to the zip file
    *
-   * @memberOf ImportService
+   * @memberOf ExportService
    * @return {string} absolute path to the zip file
    */
   getZipFile() {
@@ -252,7 +256,7 @@ class ExportService {
   /**
    * get the absolute path to the zip file
    *
-   * @memberOf ImportService
+   * @memberOf ExportService
    * @param {boolean} [validate=false] boolean to check if the file exists
    * @return {string} absolute path to meta.json
    */
@@ -295,6 +299,16 @@ class ExportService {
     return Model;
   }
 
+  /**
+   * remove zip file from downloads dir
+   *
+   * @param {string} zipFile absolute path to zip file
+   * @memberOf ExportService
+   */
+  deleteZipFile(zipFile) {
+    fs.unlinkSync(zipFile);
+  }
+
 }
 
 module.exports = ExportService;

+ 8 - 6
src/server/service/growi-bridge.js

@@ -6,8 +6,9 @@ const unzipper = require('unzipper');
 
 class GrowiBridgeService {
 
-  // constructor(crowi) {
-  // }
+  constructor(crowi) {
+    this.metaFileName = 'meta.json';
+  }
 
   /**
    * parse a zip file
@@ -20,14 +21,14 @@ class GrowiBridgeService {
     const readStream = fs.createReadStream(zipFile);
     const unzipStream = readStream.pipe(unzipper.Parse());
     const fileStats = [];
+    let meta = {};
 
-    unzipStream.on('entry', (entry) => {
+    unzipStream.on('entry', async(entry) => {
       const fileName = entry.path;
       const size = entry.vars.uncompressedSize; // There is also compressedSize;
 
       if (fileName === this.metaFileName) {
-        // TODO: parse meta.json
-        entry.autodrain();
+        meta = JSON.parse((await entry.buffer()).toString());
       }
       else {
         fileStats.push({
@@ -43,7 +44,8 @@ class GrowiBridgeService {
     await streamToPromise(unzipStream);
 
     return {
-      meta: {},
+      meta,
+      fileName: path.basename(zipFile),
       fileStats,
     };
   }

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

@@ -154,7 +154,7 @@ class ImportService {
       const fileName = entry.path;
 
       if (fileName === this.metaFileName) {
-        // TODO: parse meta.json
+        // skip meta.json
         entry.autodrain();
       }
       else {