Futa Arai 1 год назад
Родитель
Сommit
5922ac852a

+ 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!",

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

@@ -636,7 +636,11 @@
     "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"
+    "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": "接続に成功しました!",

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

@@ -645,7 +645,11 @@
     "bulk_export_started": "目前我们正在准备...",
     "bulk_export_download_expired": "下载期限已过",
     "bulk_export_job_expired": "由于导出时间太长,处理被中断",
-    "duplicate_bulk_export_job_error": "正在导出同一页面及其子页面"
+    "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>
     </>
   );
 };

+ 5 - 1
apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts

@@ -10,10 +10,14 @@ export const PageBulkExportFormat = {
 
 export type PageBulkExportFormat = typeof PageBulkExportFormat[keyof typeof PageBulkExportFormat]
 
-export const PageBulkExportJobStatus = {
+export const PageBulkExportJobInProgressStatus = {
   initializing: 'initializing', // preparing for export
   exporting: 'exporting', // exporting to fs
   uploading: 'uploading', // uploading to cloud storage
+} as const;
+
+export const PageBulkExportJobStatus = {
+  ...PageBulkExportJobInProgressStatus,
   completed: 'completed',
   failed: 'failed',
 } as const;

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

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

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

@@ -30,7 +30,7 @@ import { preNotifyService } from '~/server/service/pre-notify';
 import { getBufferToFixedSizeTransform } from '~/server/util/stream';
 import loggerFactory from '~/utils/logger';
 
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import { PageBulkExportFormat, PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 import type { PageBulkExportPageSnapshotDocument } from '../models/page-bulk-export-page-snapshot';
@@ -55,6 +55,14 @@ class BulkExportJobExpiredError extends Error {
 
 }
 
+class BulkExportJobRestartedError extends Error {
+
+  constructor() {
+    super('Bulk export job has restarted');
+  }
+
+}
+
 /**
  * Used to keep track of streams currently being executed, and enable destroying them
  */
@@ -70,10 +78,15 @@ class PageBulkExportJobStreamManager {
     delete this.jobStreams[jobId.toString()];
   }
 
-  destroyJobStream(jobId: ObjectIdLike) {
+  destroyJobStream(jobId: ObjectIdLike, restarted = false) {
     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);
   }
@@ -110,7 +123,7 @@ class PageBulkExportService {
   /**
    * Create a new page bulk export job and execute it
    */
-  async createAndExecuteBulkExportJob(basePagePath: string, currentUser): Promise<void> {
+  async createAndExecuteOrRestartBulkExportJob(basePagePath: string, currentUser, restartJob = false): Promise<void> {
     const basePage = await this.pageModel.findByPathAndViewer(basePagePath, currentUser, null, true);
 
     if (basePage == null) {
@@ -122,11 +135,13 @@ class PageBulkExportService {
       user: currentUser,
       page: basePage,
       format,
-      $or: [
-        { status: PageBulkExportJobStatus.initializing }, { status: PageBulkExportJobStatus.exporting }, { status: PageBulkExportJobStatus.uploading },
-      ],
+      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
     });
     if (duplicatePageBulkExportJobInProgress != null) {
+      if (restartJob) {
+        this.restartBulkExportJob(duplicatePageBulkExportJobInProgress);
+        return;
+      }
       throw new DuplicateBulkExportJobError();
     }
 
@@ -139,6 +154,18 @@ class PageBulkExportService {
     this.executePageBulkExportJob(pageBulkExportJob);
   }
 
+  /**
+   * Restart page bulk export job in progress from the beginning
+   */
+  async restartBulkExportJob(pageBulkExportJob: PageBulkExportJobDocument & HasObjectId): Promise<void> {
+    this.pageBulkExportJobStreamManager.destroyJobStream(pageBulkExportJob._id, true);
+    await this.cleanUpExportJobResources(pageBulkExportJob);
+
+    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.
    */
@@ -177,11 +204,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);
       }
+      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);
       }
       return;