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

Merge pull request #9020 from weseek/feat/150418-151978-enable-restarting-bulk-export

enable restarting bulk export
Futa Arai 1 год назад
Родитель
Сommit
7976928832

+ 4 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -642,7 +642,10 @@
     "bulk_export_started": "Please wait a moment...",
     "bulk_export_download_expired": "Download period has expired",
     "bulk_export_job_expired": "Export process was canceled because it took too long",
-    "duplicate_bulk_export_job_error": "Export for the same page and its children is in progress"
+    "export_in_progress": "Export in progress",
+    "export_in_progress_explanation": "Export with the same format is already in progress. Would you like to restart to export the latest page contents?",
+    "export_cancel_warning": "The export in progress will be canceled",
+    "restart": "Restart"
   },
   "message": {
     "successfully_connected": "Successfully Connected!",

+ 4 - 1
apps/app/public/static/locales/fr_FR/translation.json

@@ -636,7 +636,10 @@
     "bulk_export_started": "Patientez s'il-vous-plait...",
     "bulk_export_download_expired": "La période de téléchargement a expiré",
     "bulk_export_job_expired": "Le traitement a été interrompu car le temps d'exportation était trop long",
-    "duplicate_bulk_export_job_error": "L'export pour la même page et ses enfants est en cours"
+    "export_in_progress": "Exportation en cours",
+    "export_in_progress_explanation": "L'exportation avec le même format est déjà en cours. Souhaitez-vous redémarrer pour exporter le dernier contenu de la page ?",
+    "export_cancel_warning": "L'export en cours sera annulé",
+    "restart": "Redémarrage"
   },
   "message": {
     "successfully_connected": "Connecté!",

+ 4 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -675,7 +675,10 @@
     "bulk_export_started": "ただいま準備中です...",
     "bulk_export_download_expired": "ダウンロード期限が切れました",
     "bulk_export_job_expired": "エクスポート時間が長すぎるため、処理が中断されました",
-    "duplicate_bulk_export_job_error": "既に同じページとその配下のエクスポートが進行中です"
+    "export_in_progress": "エクスポート進行中",
+    "export_in_progress_explanation": "既に同じ形式でのエクスポートが進行中です。最新のページ内容でエクスポートを最初からやり直しますか?",
+    "export_cancel_warning": "進行中のエクスポートはキャンセルされます",
+    "restart": "やり直す"
   },
   "message": {
     "successfully_connected": "接続に成功しました!",

+ 4 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -645,7 +645,10 @@
     "bulk_export_started": "目前我们正在准备...",
     "bulk_export_download_expired": "下载期限已过",
     "bulk_export_job_expired": "由于导出时间太长,处理被中断",
-    "duplicate_bulk_export_job_error": "正在导出同一页面及其子页面"
+    "export_in_progress": "导出正在进行中",
+    "export_in_progress_explanation": "已在进行相同格式的导出。您要重新启动以导出最新的页面内容吗?",
+    "export_cancel_warning": "正在进行的导出将被取消",
+    "restart": "重新开始"
   },
   "message": {
     "successfully_connected": "连接成功!",

+ 49 - 6
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -1,3 +1,5 @@
+import { useState } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
@@ -12,42 +14,83 @@ const PageBulkExportSelectModal = (): JSX.Element => {
   const { data: status, close } = usePageBulkExportSelectModal();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const [isRestartModalOpened, setIsRestartModalOpened] = useState(false);
+  const [formatMemoForRestart, setFormatMemoForRestart] = useState<PageBulkExportFormat | null>(null);
+
   const startBulkExport = async(format: PageBulkExportFormat) => {
     try {
+      setFormatMemoForRestart(format);
       await apiv3Post('/page-bulk-export', { path: currentPagePath, format });
       toastSuccess(t('page_export.bulk_export_started'));
     }
     catch (e) {
-      // TODO: Enable cancel and restart of export if duplicate export exists (https://redmine.weseek.co.jp/issues/150418)
       const errorCode = e?.[0].code ?? 'page_export.failed_to_export';
-      toastError(t(errorCode));
+      if (errorCode === 'page_export.duplicate_bulk_export_job_error') {
+        setIsRestartModalOpened(true);
+      }
+      else {
+        toastError(t(errorCode));
+      }
     }
     close();
   };
 
+  const restartBulkExport = async() => {
+    if (formatMemoForRestart != null) {
+      try {
+        await apiv3Post('/page-bulk-export', { path: currentPagePath, format: formatMemoForRestart, restartJob: true });
+        toastSuccess(t('page_export.bulk_export_started'));
+      }
+      catch (e) {
+        toastError(t('page_export.failed_to_export'));
+      }
+      setIsRestartModalOpened(false);
+    }
+  };
+
   return (
     <>
       {status != null && (
         <Modal isOpen={status.isOpened} toggle={close}>
-          <ModalHeader tag="h5" toggle={close} className="bg-primary text-light">
+          <ModalHeader tag="h4" toggle={close}>
             {t('page_export.bulk_export')}
           </ModalHeader>
           <ModalBody>
             {t('page_export.choose_export_format')}
-            <div className="my-2">
+            <div className="my-1">
               <small className="text-muted">
                 {t('page_export.bulk_export_notice')}
               </small>
             </div>
-            <div className="d-flex justify-content-center mt-2">
+            <div className="d-flex justify-content-center mt-3">
               <button className="btn btn-primary" type="button" onClick={() => startBulkExport(PageBulkExportFormat.md)}>
                 {t('page_export.markdown')}
               </button>
-              <button className="btn btn-primary ms-2" type="button" onClick={() => startBulkExport(PageBulkExportFormat.pdf)}>PDF</button>
+              {/* TODO: enable in https://redmine.weseek.co.jp/issues/135772 */}
+              {/* <button className="btn btn-primary ms-2" type="button" onClick={() => startBulkExport(PageBulkExportFormat.pdf)}>PDF</button> */}
             </div>
           </ModalBody>
         </Modal>
       )}
+
+      <Modal isOpen={isRestartModalOpened} toggle={() => setIsRestartModalOpened(false)}>
+        <ModalHeader tag="h4" toggle={() => setIsRestartModalOpened(false)}>
+          {t('page_export.export_in_progress')}
+        </ModalHeader>
+        <ModalBody>
+          {t('page_export.export_in_progress_explanation')}
+          <div className="my-1 text-danger">
+            <small className="text-danger">
+              {t('page_export.export_cancel_warning')}
+            </small>
+          </div>
+          <div className="d-flex justify-content-center mt-3">
+            <button className="btn btn-primary" type="button" onClick={() => restartBulkExport()}>
+              {t('page_export.restart')}
+            </button>
+          </div>
+        </ModalBody>
+      </Modal>
     </>
   );
 };

+ 3 - 2
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -25,6 +25,7 @@ module.exports = (crowi: Crowi): Router => {
     pageBulkExport: [
       body('path').exists({ checkFalsy: true }).isString(),
       body('format').exists({ checkFalsy: true }).isString(),
+      body('restartJob').isBoolean().optional(),
     ],
   };
 
@@ -34,14 +35,14 @@ module.exports = (crowi: Crowi): Router => {
       return res.status(400).json({ errors: errors.array() });
     }
 
-    const { path, format } = req.body;
+    const { path, format, restartJob } = req.body;
     const activityParameters = {
       ip: req.ip,
       endpoint: req.originalUrl,
     };
 
     try {
-      await pageBulkExportService?.createAndExecuteBulkExportJob(path, req.user, activityParameters);
+      await pageBulkExportService?.createAndExecuteOrRestartBulkExportJob(path, req.user, activityParameters, restartJob);
       return res.apiv3({}, 204);
     }
     catch (err) {

+ 8 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export/errors.ts

@@ -13,3 +13,11 @@ export class BulkExportJobExpiredError extends Error {
   }
 
 }
+
+export class BulkExportJobRestartedError extends Error {
+
+  constructor() {
+    super('Bulk export job has restarted');
+  }
+
+}

+ 25 - 5
apps/app/src/features/page-bulk-export/server/service/page-bulk-export/index.ts

@@ -34,7 +34,7 @@ import PageBulkExportJob from '../../models/page-bulk-export-job';
 import type { PageBulkExportPageSnapshotDocument } from '../../models/page-bulk-export-page-snapshot';
 import PageBulkExportPageSnapshot from '../../models/page-bulk-export-page-snapshot';
 
-import { BulkExportJobExpiredError, DuplicateBulkExportJobError } from './errors';
+import { BulkExportJobExpiredError, BulkExportJobRestartedError, DuplicateBulkExportJobError } from './errors';
 import { PageBulkExportJobStreamManager } from './page-bulk-export-job-stream-manager';
 
 
@@ -75,7 +75,7 @@ class PageBulkExportService {
   /**
    * Create a new page bulk export job and execute it
    */
-  async createAndExecuteBulkExportJob(basePagePath: string, currentUser, activityParameters: ActivityParameters): Promise<void> {
+  async createAndExecuteOrRestartBulkExportJob(basePagePath: string, currentUser, activityParameters: ActivityParameters, restartJob = false): Promise<void> {
     const basePage = await this.pageModel.findByPathAndViewer(basePagePath, currentUser, null, true);
 
     if (basePage == null) {
@@ -90,6 +90,10 @@ class PageBulkExportService {
       $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
     });
     if (duplicatePageBulkExportJobInProgress != null) {
+      if (restartJob) {
+        this.restartBulkExportJob(duplicatePageBulkExportJobInProgress);
+        return;
+      }
       throw new DuplicateBulkExportJobError();
     }
     const pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument> = await PageBulkExportJob.create({
@@ -101,6 +105,17 @@ class PageBulkExportService {
     this.executePageBulkExportJob(pageBulkExportJob, activityParameters);
   }
 
+  /**
+   * Restart page bulk export job in progress from the beginning
+   */
+  async restartBulkExportJob(pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument>): Promise<void> {
+    await this.cleanUpExportJobResources(pageBulkExportJob, true);
+
+    pageBulkExportJob.status = PageBulkExportJobStatus.initializing;
+    await pageBulkExportJob.save();
+    this.executePageBulkExportJob(pageBulkExportJob);
+  }
+
   /**
    * Execute a page bulk export job. This method can also resume a previously inturrupted job.
    */
@@ -139,11 +154,16 @@ class PageBulkExportService {
       }
     }
     catch (err) {
-      logger.error(err);
       if (err instanceof BulkExportJobExpiredError) {
+        logger.error(err);
         await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED, pageBulkExportJob, activityParameters);
       }
+      else if (err instanceof BulkExportJobRestartedError) {
+        logger.info(err.message);
+        await this.cleanUpExportJobResources(pageBulkExportJob);
+      }
       else {
+        logger.error(err);
         await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED, pageBulkExportJob, activityParameters);
       }
       return;
@@ -408,8 +428,8 @@ class PageBulkExportService {
    * - remove the temporal output directory
    * - abort multipart upload
    */
-  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument) {
-    this.pageBulkExportJobStreamManager.destroyJobStream(pageBulkExportJob._id);
+  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument, restarted = false) {
+    this.pageBulkExportJobStreamManager.destroyJobStream(pageBulkExportJob._id, restarted);
 
     const promises = [
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),

+ 8 - 3
apps/app/src/features/page-bulk-export/server/service/page-bulk-export/page-bulk-export-job-stream-manager.ts

@@ -2,7 +2,7 @@ import type { Readable } from 'stream';
 
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
-import { BulkExportJobExpiredError } from './errors';
+import { BulkExportJobExpiredError, BulkExportJobRestartedError } from './errors';
 
 /**
  * Used to keep track of streams currently being executed, and enable destroying them
@@ -22,10 +22,15 @@ export class PageBulkExportJobStreamManager {
     delete this.jobStreams[jobId.toString()];
   }
 
-  destroyJobStream(jobId: ObjectIdLike): void {
+  destroyJobStream(jobId: ObjectIdLike, restarted = false): void {
     const stream = this.jobStreams[jobId.toString()];
     if (stream != null) {
-      stream.destroy(new BulkExportJobExpiredError());
+      if (restarted) {
+        stream.destroy(new BulkExportJobRestartedError());
+      }
+      else {
+        stream.destroy(new BulkExportJobExpiredError());
+      }
     }
     this.removeJobStream(jobId);
   }