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

configure biome for page-bulk-export feature

Futa Arai 7 месяцев назад
Родитель
Сommit
98e984ab9c
18 измененных файлов с 662 добавлено и 321 удалено
  1. 1 0
      apps/app/.eslintrc.js
  2. 52 24
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  3. 15 7
      apps/app/src/features/page-bulk-export/client/stores/modal.tsx
  4. 27 19
      apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts
  5. 38 19
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts
  6. 26 10
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts
  7. 38 16
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  8. 19 10
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  9. 70 28
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  10. 63 26
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  11. 0 4
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts
  12. 111 52
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  13. 33 17
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  14. 33 11
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  15. 22 17
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  16. 61 34
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  17. 53 26
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts
  18. 0 1
      biome.json

+ 1 - 0
apps/app/.eslintrc.js

@@ -37,6 +37,7 @@ module.exports = {
     'src/features/search/**',
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
+    'src/features/page-bulk-export/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 52 - 24
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -1,15 +1,14 @@
-import { useState, type JSX } from 'react';
-
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import { type JSX, useState } from 'react';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import { PageBulkExportFormat } from '~/features/page-bulk-export/interfaces/page-bulk-export';
-import { useIsPdfBulkExportEnabled } from '~/stores-universal/context';
 import { useCurrentPagePath } from '~/stores/page';
+import { useIsPdfBulkExportEnabled } from '~/stores-universal/context';
 
 const PageBulkExportSelectModal = (): JSX.Element => {
   const { t } = useTranslation();
@@ -18,35 +17,40 @@ const PageBulkExportSelectModal = (): JSX.Element => {
   const { data: isPdfBulkExportEnabled } = useIsPdfBulkExportEnabled();
 
   const [isRestartModalOpened, setIsRestartModalOpened] = useState(false);
-  const [formatMemoForRestart, setFormatMemoForRestart] = useState<PageBulkExportFormat | undefined>(undefined);
-  const [duplicateJobInfo, setDuplicateJobInfo] = useState<{createdAt: string} | undefined>(undefined);
+  const [formatMemoForRestart, setFormatMemoForRestart] = useState<
+    PageBulkExportFormat | undefined
+  >(undefined);
+  const [duplicateJobInfo, setDuplicateJobInfo] = useState<
+    { createdAt: string } | undefined
+  >(undefined);
 
-  const startBulkExport = async(format: PageBulkExportFormat) => {
+  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) {
+    } catch (e) {
       const errorCode = e?.[0].code ?? 'page_export.failed_to_export';
       if (errorCode === 'page_export.duplicate_bulk_export_job_error') {
         setDuplicateJobInfo(e[0].args.duplicateJob);
         setIsRestartModalOpened(true);
-      }
-      else {
+      } else {
         toastError(t(errorCode));
       }
     }
     close();
   };
 
-  const restartBulkExport = async() => {
+  const restartBulkExport = async () => {
     if (formatMemoForRestart != null) {
       try {
-        await apiv3Post('/page-bulk-export', { path: currentPagePath, format: formatMemoForRestart, restartJob: true });
+        await apiv3Post('/page-bulk-export', {
+          path: currentPagePath,
+          format: formatMemoForRestart,
+          restartJob: true,
+        });
         toastSuccess(t('page_export.bulk_export_started'));
-      }
-      catch (e) {
+      } catch (e) {
         toastError(t('page_export.failed_to_export'));
       }
       setIsRestartModalOpened(false);
@@ -63,7 +67,10 @@ const PageBulkExportSelectModal = (): JSX.Element => {
           <ModalBody>
             <p className="card custom-card bg-warning-subtle pt-3 px-3">
               {t('page_export.bulk_export_download_explanation')}
-              <span className="mt-3"><span className="material-symbols-outlined me-1">warning</span>{t('Warning')}</span>
+              <span className="mt-3">
+                <span className="material-symbols-outlined me-1">warning</span>
+                {t('Warning')}
+              </span>
               <ul className="mt-2">
                 <li>{t('page_export.bulk_export_exec_time_warning')}</li>
                 <li>{t('page_export.large_bulk_export_warning')}</li>
@@ -71,11 +78,19 @@ const PageBulkExportSelectModal = (): JSX.Element => {
             </p>
             {t('page_export.choose_export_format')}:
             <div className="d-flex justify-content-center mt-3">
-              <button className="btn btn-primary" type="button" onClick={() => startBulkExport(PageBulkExportFormat.md)}>
+              <button
+                className="btn btn-primary"
+                type="button"
+                onClick={() => startBulkExport(PageBulkExportFormat.md)}
+              >
                 {t('page_export.markdown')}
               </button>
               {isPdfBulkExportEnabled && (
-                <button className="btn btn-primary ms-2" type="button" onClick={() => startBulkExport(PageBulkExportFormat.pdf)}>
+                <button
+                  className="btn btn-primary ms-2"
+                  type="button"
+                  onClick={() => startBulkExport(PageBulkExportFormat.pdf)}
+                >
                   PDF
                 </button>
               )}
@@ -84,7 +99,10 @@ const PageBulkExportSelectModal = (): JSX.Element => {
         </Modal>
       )}
 
-      <Modal isOpen={isRestartModalOpened} toggle={() => setIsRestartModalOpened(false)}>
+      <Modal
+        isOpen={isRestartModalOpened}
+        toggle={() => setIsRestartModalOpened(false)}
+      >
         <ModalHeader tag="h4" toggle={() => setIsRestartModalOpened(false)}>
           {t('page_export.export_in_progress')}
         </ModalHeader>
@@ -93,20 +111,30 @@ const PageBulkExportSelectModal = (): JSX.Element => {
           <div className="text-danger">
             {t('page_export.export_cancel_warning')}:
           </div>
-          { duplicateJobInfo && (
+          {duplicateJobInfo && (
             <div className="my-1">
               <ul>
-                { formatMemoForRestart && (
+                {formatMemoForRestart && (
                   <li>
-                    {t('page_export.format')}: {formatMemoForRestart === PageBulkExportFormat.md ? t('page_export.markdown') : 'PDF'}
+                    {t('page_export.format')}:{' '}
+                    {formatMemoForRestart === PageBulkExportFormat.md
+                      ? t('page_export.markdown')
+                      : 'PDF'}
                   </li>
                 )}
-                <li>{t('page_export.started_on')}: {format(new Date(duplicateJobInfo.createdAt), 'MM/dd HH:mm')}</li>
+                <li>
+                  {t('page_export.started_on')}:{' '}
+                  {format(new Date(duplicateJobInfo.createdAt), 'MM/dd HH:mm')}
+                </li>
               </ul>
             </div>
           )}
           <div className="d-flex justify-content-center mt-3">
-            <button className="btn btn-primary" type="button" onClick={() => restartBulkExport()}>
+            <button
+              className="btn btn-primary"
+              type="button"
+              onClick={() => restartBulkExport()}
+            >
               {t('page_export.restart')}
             </button>
           </div>

+ 15 - 7
apps/app/src/features/page-bulk-export/client/stores/modal.tsx

@@ -3,17 +3,25 @@ import type { SWRResponse } from 'swr';
 import { useStaticSWR } from '../../../../stores/use-static-swr';
 
 type PageBulkExportSelectModalStatus = {
-  isOpened: boolean,
-}
+  isOpened: boolean;
+};
 
 type PageBulkExportSelectModalUtils = {
-  open(): Promise<void>,
-  close(): Promise<void>,
-}
+  open(): Promise<void>;
+  close(): Promise<void>;
+};
 
-export const usePageBulkExportSelectModal = (): SWRResponse<PageBulkExportSelectModalStatus, Error> & PageBulkExportSelectModalUtils => {
+export const usePageBulkExportSelectModal = (): SWRResponse<
+  PageBulkExportSelectModalStatus,
+  Error
+> &
+  PageBulkExportSelectModalUtils => {
   const initialStatus: PageBulkExportSelectModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<PageBulkExportSelectModalStatus, Error>('pageBulkExportSelectModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useStaticSWR<PageBulkExportSelectModalStatus, Error>(
+    'pageBulkExportSelectModal',
+    undefined,
+    { fallbackData: initialStatus },
+  );
 
   return {
     ...swrResponse,

+ 27 - 19
apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts

@@ -1,6 +1,10 @@
 import type {
   HasObjectId,
-  IAttachment, IPage, IRevision, IUser, Ref,
+  IAttachment,
+  IPage,
+  IRevision,
+  IUser,
+  Ref,
 } from '@growi/core';
 
 export const PageBulkExportFormat = {
@@ -8,7 +12,8 @@ export const PageBulkExportFormat = {
   pdf: 'pdf',
 } as const;
 
-export type PageBulkExportFormat = typeof PageBulkExportFormat[keyof typeof PageBulkExportFormat]
+export type PageBulkExportFormat =
+  (typeof PageBulkExportFormat)[keyof typeof PageBulkExportFormat];
 
 export const PageBulkExportJobInProgressStatus = {
   initializing: 'initializing', // preparing for export
@@ -22,28 +27,31 @@ export const PageBulkExportJobStatus = {
   failed: 'failed',
 } as const;
 
-export type PageBulkExportJobStatus = typeof PageBulkExportJobStatus[keyof typeof PageBulkExportJobStatus]
+export type PageBulkExportJobStatus =
+  (typeof PageBulkExportJobStatus)[keyof typeof PageBulkExportJobStatus];
 
 export interface IPageBulkExportJob {
-  user: Ref<IUser>, // user that started export job
-  page: Ref<IPage>, // the root page of page tree to export
-  lastExportedPagePath?: string, // the path of page that was exported to the fs last
-  format: PageBulkExportFormat,
-  completedAt?: Date, // the date at which job was completed
-  attachment?: Ref<IAttachment>,
-  status: PageBulkExportJobStatus,
-  statusOnPreviousCronExec?: PageBulkExportJobStatus, // status on previous cron execution
-  revisionListHash?: string, // Hash created from the list of revision IDs. Used to detect existing duplicate uploads.
-  restartFlag: boolean, // flag to restart the job
-  createdAt?: Date,
-  updatedAt?: Date
+  user: Ref<IUser>; // user that started export job
+  page: Ref<IPage>; // the root page of page tree to export
+  lastExportedPagePath?: string; // the path of page that was exported to the fs last
+  format: PageBulkExportFormat;
+  completedAt?: Date; // the date at which job was completed
+  attachment?: Ref<IAttachment>;
+  status: PageBulkExportJobStatus;
+  statusOnPreviousCronExec?: PageBulkExportJobStatus; // status on previous cron execution
+  revisionListHash?: string; // Hash created from the list of revision IDs. Used to detect existing duplicate uploads.
+  restartFlag: boolean; // flag to restart the job
+  createdAt?: Date;
+  updatedAt?: Date;
 }
 
-export interface IPageBulkExportJobHasId extends IPageBulkExportJob, HasObjectId {}
+export interface IPageBulkExportJobHasId
+  extends IPageBulkExportJob,
+    HasObjectId {}
 
 // snapshot of page info to upload
 export interface IPageBulkExportPageSnapshot {
-  pageBulkExportJob: Ref<IPageBulkExportJob>,
-  path: string, // page path when export was stared
-  revision: Ref<IRevision>, // page revision when export was stared
+  pageBulkExportJob: Ref<IPageBulkExportJob>;
+  path: string; // page path when export was stared
+  revision: Ref<IRevision>; // page revision when export was stared
 }

+ 38 - 19
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts

@@ -3,27 +3,46 @@ import { type Document, type Model, Schema } from 'mongoose';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import type { IPageBulkExportJob } from '../../interfaces/page-bulk-export';
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  PageBulkExportFormat,
+  PageBulkExportJobStatus,
+} from '../../interfaces/page-bulk-export';
 
-export interface PageBulkExportJobDocument extends IPageBulkExportJob, Document {}
+export interface PageBulkExportJobDocument
+  extends IPageBulkExportJob,
+    Document {}
 
-export type PageBulkExportJobModel = Model<PageBulkExportJobDocument>
+export type PageBulkExportJobModel = Model<PageBulkExportJobDocument>;
 
-const pageBulkExportJobSchema = new Schema<PageBulkExportJobDocument>({
-  user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
-  page: { type: Schema.Types.ObjectId, ref: 'Page', required: true },
-  lastExportedPagePath: { type: String },
-  format: { type: String, enum: Object.values(PageBulkExportFormat), required: true },
-  completedAt: { type: Date },
-  attachment: { type: Schema.Types.ObjectId, ref: 'Attachment' },
-  status: {
-    type: String, enum: Object.values(PageBulkExportJobStatus), required: true, default: PageBulkExportJobStatus.initializing,
+const pageBulkExportJobSchema = new Schema<PageBulkExportJobDocument>(
+  {
+    user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+    page: { type: Schema.Types.ObjectId, ref: 'Page', required: true },
+    lastExportedPagePath: { type: String },
+    format: {
+      type: String,
+      enum: Object.values(PageBulkExportFormat),
+      required: true,
+    },
+    completedAt: { type: Date },
+    attachment: { type: Schema.Types.ObjectId, ref: 'Attachment' },
+    status: {
+      type: String,
+      enum: Object.values(PageBulkExportJobStatus),
+      required: true,
+      default: PageBulkExportJobStatus.initializing,
+    },
+    statusOnPreviousCronExec: {
+      type: String,
+      enum: Object.values(PageBulkExportJobStatus),
+    },
+    restartFlag: { type: Boolean, required: true, default: false },
+    revisionListHash: { type: String },
   },
-  statusOnPreviousCronExec: {
-    type: String, enum: Object.values(PageBulkExportJobStatus),
-  },
-  restartFlag: { type: Boolean, required: true, default: false },
-  revisionListHash: { type: String },
-}, { timestamps: true });
+  { timestamps: true },
+);
 
-export default getOrCreateModel<PageBulkExportJobDocument, PageBulkExportJobModel>('PageBulkExportJob', pageBulkExportJobSchema);
+export default getOrCreateModel<
+  PageBulkExportJobDocument,
+  PageBulkExportJobModel
+>('PageBulkExportJob', pageBulkExportJobSchema);

+ 26 - 10
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts

@@ -4,16 +4,32 @@ import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import type { IPageBulkExportPageSnapshot } from '../../interfaces/page-bulk-export';
 
-export interface PageBulkExportPageSnapshotDocument extends IPageBulkExportPageSnapshot, Document {}
+export interface PageBulkExportPageSnapshotDocument
+  extends IPageBulkExportPageSnapshot,
+    Document {}
 
-export type PageBulkExportPageSnapshotModel = Model<PageBulkExportPageSnapshotDocument>
+export type PageBulkExportPageSnapshotModel =
+  Model<PageBulkExportPageSnapshotDocument>;
 
-const pageBulkExportPageInfoSchema = new Schema<PageBulkExportPageSnapshotDocument>({
-  pageBulkExportJob: { type: Schema.Types.ObjectId, ref: 'PageBulkExportJob', required: true },
-  path: { type: String, required: true },
-  revision: { type: Schema.Types.ObjectId, ref: 'Revision', required: true },
-}, { timestamps: true });
+const pageBulkExportPageInfoSchema =
+  new Schema<PageBulkExportPageSnapshotDocument>(
+    {
+      pageBulkExportJob: {
+        type: Schema.Types.ObjectId,
+        ref: 'PageBulkExportJob',
+        required: true,
+      },
+      path: { type: String, required: true },
+      revision: {
+        type: Schema.Types.ObjectId,
+        ref: 'Revision',
+        required: true,
+      },
+    },
+    { timestamps: true },
+  );
 
-export default getOrCreateModel<PageBulkExportPageSnapshotDocument, PageBulkExportPageSnapshotModel>(
-  'PageBulkExportPageSnapshot', pageBulkExportPageInfoSchema,
-);
+export default getOrCreateModel<
+  PageBulkExportPageSnapshotDocument,
+  PageBulkExportPageSnapshotModel
+>('PageBulkExportPageSnapshot', pageBulkExportPageInfoSchema);

+ 38 - 16
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -8,19 +8,24 @@ import type Crowi from '~/server/crowi';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { pageBulkExportService, DuplicateBulkExportJobError } from '../../service/page-bulk-export';
+import {
+  DuplicateBulkExportJobError,
+  pageBulkExportService,
+} from '../../service/page-bulk-export';
 
 const logger = loggerFactory('growi:routes:apiv3:page-bulk-export');
 
 const router = Router();
 
 interface AuthorizedRequest extends Request {
-  user?: any
+  user?: any;
 }
 
 module.exports = (crowi: Crowi): Router => {
   const accessTokenParser = crowi.accessTokenParser;
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
 
   const validators = {
     pageBulkExport: [
@@ -30,8 +35,12 @@ module.exports = (crowi: Crowi): Router => {
     ],
   };
 
-  router.post('/', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE_BULK_EXPORT]),
-    loginRequiredStrictly, validators.pageBulkExport, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE_BULK_EXPORT]),
+    loginRequiredStrictly,
+    validators.pageBulkExport,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
         return res.status(400).json({ errors: errors.array() });
@@ -40,22 +49,35 @@ module.exports = (crowi: Crowi): Router => {
       const { path, format, restartJob } = req.body;
 
       try {
-        await pageBulkExportService?.createOrResetBulkExportJob(path, format, req.user, restartJob);
+        await pageBulkExportService?.createOrResetBulkExportJob(
+          path,
+          format,
+          req.user,
+          restartJob,
+        );
         return res.apiv3({}, 204);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         if (err instanceof DuplicateBulkExportJobError) {
-          return res.apiv3Err(new ErrorV3(
-            'Duplicate bulk export job is in progress',
-            'page_export.duplicate_bulk_export_job_error', undefined,
-            { duplicateJob: { createdAt: err.duplicateJob.createdAt } },
-          ), 409);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Duplicate bulk export job is in progress',
+              'page_export.duplicate_bulk_export_job_error',
+              undefined,
+              { duplicateJob: { createdAt: err.duplicateJob.createdAt } },
+            ),
+            409,
+          );
         }
-        return res.apiv3Err(new ErrorV3('Failed to start bulk export', 'page_export.failed_to_export'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to start bulk export',
+            'page_export.failed_to_export',
+          ),
+        );
       }
-    });
+    },
+  );
 
   return router;
-
 };

+ 19 - 10
apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts

@@ -7,36 +7,45 @@ import PageBulkExportJob from '../models/page-bulk-export-job';
 
 import { pageBulkExportJobCronService } from './page-bulk-export-job-cron';
 
-const logger = loggerFactory('growi:service:check-page-bulk-export-job-in-progress-cron');
+const logger = loggerFactory(
+  'growi:service:check-page-bulk-export-job-in-progress-cron',
+);
 
 /**
  * Manages cronjob which checks if PageBulkExportJob in progress exists.
  * If it does, and PageBulkExportJobCronService is not running, start PageBulkExportJobCronService
  */
 class CheckPageBulkExportJobInProgressCronService extends CronService {
-
   override getCronSchedule(): string {
-    return configManager.getConfig('app:checkPageBulkExportJobInProgressCronSchedule');
+    return configManager.getConfig(
+      'app:checkPageBulkExportJobInProgressCronSchedule',
+    );
   }
 
   override async executeJob(): Promise<void> {
     // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
-    const isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null;
+    const isBulkExportPagesEnabled =
+      configManager.getConfig('app:isBulkExportPagesEnabled') &&
+      configManager.getConfig('app:growiCloudUri') == null;
     if (!isBulkExportPagesEnabled) return;
 
     const pageBulkExportJobInProgress = await PageBulkExportJob.findOne({
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
+      $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+        status,
+      })),
     });
     const pageBulkExportInProgressExists = pageBulkExportJobInProgress != null;
 
-    if (pageBulkExportInProgressExists && !pageBulkExportJobCronService?.isJobRunning()) {
+    if (
+      pageBulkExportInProgressExists &&
+      !pageBulkExportJobCronService?.isJobRunning()
+    ) {
       pageBulkExportJobCronService?.startCron();
-    }
-    else if (!pageBulkExportInProgressExists) {
+    } else if (!pageBulkExportInProgressExists) {
       pageBulkExportJobCronService?.stopCron();
     }
   }
-
 }
 
-export const checkPageBulkExportJobInProgressCronService = new CheckPageBulkExportJobInProgressCronService(); // singleton instance
+export const checkPageBulkExportJobInProgressCronService =
+  new CheckPageBulkExportJobInProgressCronService(); // singleton instance

+ 70 - 28
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts

@@ -3,20 +3,28 @@ import mongoose from 'mongoose';
 import type Crowi from '~/server/crowi';
 import { configManager } from '~/server/service/config-manager';
 
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  PageBulkExportFormat,
+  PageBulkExportJobStatus,
+} from '../../interfaces/page-bulk-export';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 
-import instanciatePageBulkExportJobCleanUpCronService, { pageBulkExportJobCleanUpCronService } from './page-bulk-export-job-clean-up-cron';
+import instanciatePageBulkExportJobCleanUpCronService, {
+  pageBulkExportJobCleanUpCronService,
+} from './page-bulk-export-job-clean-up-cron';
 
 // TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
 // ref: https://github.com/vitest-dev/vitest/issues/846
-const userSchema = new mongoose.Schema({
-  name: { type: String },
-  username: { type: String, required: true, unique: true },
-  email: { type: String, unique: true, sparse: true },
-}, {
-  timestamps: true,
-});
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 const User = mongoose.model('User', userSchema);
 
 vi.mock('./page-bulk-export-job-cron', () => {
@@ -31,7 +39,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
   const crowi = {} as Crowi;
   let user;
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     await configManager.loadConfigs();
     user = await User.create({
       name: 'Example for PageBulkExportJobCleanUpCronService Test',
@@ -41,7 +49,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     instanciatePageBulkExportJobCleanUpCronService(crowi);
   });
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     await PageBulkExportJob.deleteMany();
   });
 
@@ -51,8 +59,11 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId2 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
-    beforeEach(async() => {
-      await configManager.updateConfig('app:bulkExportJobExpirationSeconds', 86400); // 1 day
+    beforeEach(async () => {
+      await configManager.updateConfig(
+        'app:bulkExportJobExpirationSeconds',
+        86400,
+      ); // 1 day
 
       await PageBulkExportJob.insertMany([
         {
@@ -80,12 +91,16 @@ describe('PageBulkExportJobCleanUpCronService', () => {
           createdAt: new Date(Date.now() - 86400 * 1000 - 2),
         },
         {
-          _id: jobId4, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId4,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
       ]);
     });
 
-    test('should delete expired jobs', async() => {
+    test('should delete expired jobs', async () => {
       expect(await PageBulkExportJob.find()).toHaveLength(4);
 
       // act
@@ -94,7 +109,9 @@ describe('PageBulkExportJobCleanUpCronService', () => {
 
       // assert
       expect(jobs).toHaveLength(2);
-      expect(jobs.map(job => job._id).sort()).toStrictEqual([jobId1, jobId4].sort());
+      expect(jobs.map((job) => job._id).sort()).toStrictEqual(
+        [jobId1, jobId4].sort(),
+      );
     });
   });
 
@@ -104,8 +121,11 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId2 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
-    beforeEach(async() => {
-      await configManager.updateConfig('app:bulkExportDownloadExpirationSeconds', 86400); // 1 day
+    beforeEach(async () => {
+      await configManager.updateConfig(
+        'app:bulkExportDownloadExpirationSeconds',
+        86400,
+      ); // 1 day
 
       await PageBulkExportJob.insertMany([
         {
@@ -125,15 +145,23 @@ describe('PageBulkExportJobCleanUpCronService', () => {
           completedAt: new Date(Date.now() - 86400 * 1000 - 1),
         },
         {
-          _id: jobId3, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.initializing,
+          _id: jobId3,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.initializing,
         },
         {
-          _id: jobId4, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId4,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
       ]);
     });
 
-    test('should delete download expired jobs', async() => {
+    test('should delete download expired jobs', async () => {
       expect(await PageBulkExportJob.find()).toHaveLength(4);
 
       // act
@@ -142,7 +170,9 @@ describe('PageBulkExportJobCleanUpCronService', () => {
 
       // assert
       expect(jobs).toHaveLength(3);
-      expect(jobs.map(job => job._id).sort()).toStrictEqual([jobId1, jobId3, jobId4].sort());
+      expect(jobs.map((job) => job._id).sort()).toStrictEqual(
+        [jobId1, jobId3, jobId4].sort(),
+      );
     });
   });
 
@@ -151,21 +181,33 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId1 = new mongoose.Types.ObjectId();
     const jobId2 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
-    beforeEach(async() => {
+    beforeEach(async () => {
       await PageBulkExportJob.insertMany([
         {
-          _id: jobId1, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId1,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
         {
-          _id: jobId2, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.initializing,
+          _id: jobId2,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.initializing,
         },
         {
-          _id: jobId3, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+          _id: jobId3,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.failed,
         },
       ]);
     });
 
-    test('should delete failed export jobs', async() => {
+    test('should delete failed export jobs', async () => {
       expect(await PageBulkExportJob.find()).toHaveLength(3);
 
       // act
@@ -174,7 +216,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
 
       // assert
       expect(jobs).toHaveLength(1);
-      expect(jobs.map(job => job._id)).toStrictEqual([jobId2]);
+      expect(jobs.map((job) => job._id)).toStrictEqual([jobId2]);
     });
   });
 });

+ 63 - 26
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts

@@ -5,19 +5,23 @@ import { configManager } from '~/server/service/config-manager';
 import CronService from '~/server/service/cron';
 import loggerFactory from '~/utils/logger';
 
-import { PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  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 { pageBulkExportJobCronService } from './page-bulk-export-job-cron';
 
-const logger = loggerFactory('growi:service:page-bulk-export-job-clean-up-cron');
+const logger = loggerFactory(
+  'growi:service:page-bulk-export-job-clean-up-cron',
+);
 
 /**
  * Manages cronjob which deletes unnecessary bulk export jobs
  */
 class PageBulkExportJobCleanUpCronService extends CronService {
-
   crowi: Crowi;
 
   constructor(crowi: Crowi) {
@@ -41,14 +45,25 @@ class PageBulkExportJobCleanUpCronService extends CronService {
    * Delete bulk export jobs which are on-going and has passed the limit time for execution
    */
   async deleteExpiredExportJobs() {
-    const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
+    const exportJobExpirationSeconds = configManager.getConfig(
+      'app:bulkExportJobExpirationSeconds',
+    );
     const expiredExportJobs = await PageBulkExportJob.find({
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
-      createdAt: { $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000) },
+      $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+        status,
+      })),
+      createdAt: {
+        $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000),
+      },
     });
 
     if (pageBulkExportJobCronService != null) {
-      await this.cleanUpAndDeleteBulkExportJobs(expiredExportJobs, pageBulkExportJobCronService.cleanUpExportJobResources.bind(pageBulkExportJobCronService));
+      await this.cleanUpAndDeleteBulkExportJobs(
+        expiredExportJobs,
+        pageBulkExportJobCronService.cleanUpExportJobResources.bind(
+          pageBulkExportJobCronService,
+        ),
+      );
     }
   }
 
@@ -56,63 +71,85 @@ class PageBulkExportJobCleanUpCronService extends CronService {
    * Delete bulk export jobs which have completed but the due time for downloading has passed
    */
   async deleteDownloadExpiredExportJobs() {
-    const downloadExpirationSeconds = configManager.getConfig('app:bulkExportDownloadExpirationSeconds');
-    const thresholdDate = new Date(Date.now() - downloadExpirationSeconds * 1000);
+    const downloadExpirationSeconds = configManager.getConfig(
+      'app:bulkExportDownloadExpirationSeconds',
+    );
+    const thresholdDate = new Date(
+      Date.now() - downloadExpirationSeconds * 1000,
+    );
     const downloadExpiredExportJobs = await PageBulkExportJob.find({
       status: PageBulkExportJobStatus.completed,
       completedAt: { $lt: thresholdDate },
     });
 
-    const cleanUp = async(job: PageBulkExportJobDocument) => {
+    const cleanUp = async (job: PageBulkExportJobDocument) => {
       await pageBulkExportJobCronService?.cleanUpExportJobResources(job);
 
-      const hasSameAttachmentAndDownloadNotExpired = await PageBulkExportJob.findOne({
-        attachment: job.attachment,
-        _id: { $ne: job._id },
-        completedAt: { $gte: thresholdDate },
-      });
+      const hasSameAttachmentAndDownloadNotExpired =
+        await PageBulkExportJob.findOne({
+          attachment: job.attachment,
+          _id: { $ne: job._id },
+          completedAt: { $gte: thresholdDate },
+        });
       if (hasSameAttachmentAndDownloadNotExpired == null) {
         // delete attachment if no other export job (which download has not expired) has re-used it
         await this.crowi.attachmentService?.removeAttachment(job.attachment);
       }
     };
 
-    await this.cleanUpAndDeleteBulkExportJobs(downloadExpiredExportJobs, cleanUp);
+    await this.cleanUpAndDeleteBulkExportJobs(
+      downloadExpiredExportJobs,
+      cleanUp,
+    );
   }
 
   /**
    * Delete bulk export jobs which have failed
    */
   async deleteFailedExportJobs() {
-    const failedExportJobs = await PageBulkExportJob.find({ status: PageBulkExportJobStatus.failed });
+    const failedExportJobs = await PageBulkExportJob.find({
+      status: PageBulkExportJobStatus.failed,
+    });
 
     if (pageBulkExportJobCronService != null) {
-      await this.cleanUpAndDeleteBulkExportJobs(failedExportJobs, pageBulkExportJobCronService.cleanUpExportJobResources.bind(pageBulkExportJobCronService));
+      await this.cleanUpAndDeleteBulkExportJobs(
+        failedExportJobs,
+        pageBulkExportJobCronService.cleanUpExportJobResources.bind(
+          pageBulkExportJobCronService,
+        ),
+      );
     }
   }
 
   async cleanUpAndDeleteBulkExportJobs(
-      pageBulkExportJobs: HydratedDocument<PageBulkExportJobDocument>[],
-      cleanUp: (job: PageBulkExportJobDocument) => Promise<void>,
+    pageBulkExportJobs: HydratedDocument<PageBulkExportJobDocument>[],
+    cleanUp: (job: PageBulkExportJobDocument) => Promise<void>,
   ): Promise<void> {
-    const results = await Promise.allSettled(pageBulkExportJobs.map(job => cleanUp(job)));
+    const results = await Promise.allSettled(
+      pageBulkExportJobs.map((job) => cleanUp(job)),
+    );
     results.forEach((result) => {
       if (result.status === 'rejected') logger.error(result.reason);
     });
 
     // Only batch delete jobs which have been successfully cleaned up
     // Clean up failed jobs will be retried in the next cron execution
-    const cleanedUpJobs = pageBulkExportJobs.filter((_, index) => results[index].status === 'fulfilled');
+    const cleanedUpJobs = pageBulkExportJobs.filter(
+      (_, index) => results[index].status === 'fulfilled',
+    );
     if (cleanedUpJobs.length > 0) {
-      const cleanedUpJobIds = cleanedUpJobs.map(job => job._id);
+      const cleanedUpJobIds = cleanedUpJobs.map((job) => job._id);
       await PageBulkExportJob.deleteMany({ _id: { $in: cleanedUpJobIds } });
     }
   }
-
 }
 
 // eslint-disable-next-line import/no-mutable-exports
-export let pageBulkExportJobCleanUpCronService: PageBulkExportJobCleanUpCronService | undefined; // singleton instance
+export let pageBulkExportJobCleanUpCronService:
+  | PageBulkExportJobCleanUpCronService
+  | undefined; // singleton instance
 export default function instanciate(crowi: Crowi): void {
-  pageBulkExportJobCleanUpCronService = new PageBulkExportJobCleanUpCronService(crowi);
+  pageBulkExportJobCleanUpCronService = new PageBulkExportJobCleanUpCronService(
+    crowi,
+  );
 }

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

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

+ 111 - 52
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -1,12 +1,10 @@
+import type { IUser } from '@growi/core';
+import { getIdForRef, isPopulated } from '@growi/core';
 import fs from 'fs';
+import mongoose from 'mongoose';
 import path from 'path';
 import type { Readable } from 'stream';
 
-import type { IUser } from '@growi/core';
-import { isPopulated, getIdForRef } from '@growi/core';
-import mongoose from 'mongoose';
-
-
 import type { SupportedActionType } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type Crowi from '~/server/crowi';
@@ -17,19 +15,24 @@ import CronService from '~/server/service/cron';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
-import { PageBulkExportFormat, PageBulkExportJobInProgressStatus, 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 PageBulkExportPageSnapshot from '../../models/page-bulk-export-page-snapshot';
 
-
-import { BulkExportJobExpiredError, BulkExportJobRestartedError } from './errors';
+import {
+  BulkExportJobExpiredError,
+  BulkExportJobRestartedError,
+} from './errors';
 import { requestPdfConverter } from './request-pdf-converter';
 import { compressAndUpload } from './steps/compress-and-upload';
 import { createPageSnapshotsAsync } from './steps/create-page-snapshots-async';
 import { exportPagesToFsAsync } from './steps/export-pages-to-fs-async';
 
-
 const logger = loggerFactory('growi:service:page-bulk-export-job-cron');
 
 export interface IPageBulkExportJobCronService {
@@ -39,17 +42,28 @@ export interface IPageBulkExportJobCronService {
   compressExtension: string;
   setStreamInExecution(jobId: ObjectIdLike, stream: Readable): void;
   removeStreamInExecution(jobId: ObjectIdLike): void;
-  handleError(err: Error | null, pageBulkExportJob: PageBulkExportJobDocument): void;
-  notifyExportResultAndCleanUp(action: SupportedActionType, pageBulkExportJob: PageBulkExportJobDocument): Promise<void>;
-  getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath?: boolean): string;
+  handleError(
+    err: Error | null,
+    pageBulkExportJob: PageBulkExportJobDocument,
+  ): void;
+  notifyExportResultAndCleanUp(
+    action: SupportedActionType,
+    pageBulkExportJob: PageBulkExportJobDocument,
+  ): Promise<void>;
+  getTmpOutputDir(
+    pageBulkExportJob: PageBulkExportJobDocument,
+    isHtmlPath?: boolean,
+  ): string;
 }
 
 /**
  * Manages cronjob which proceeds PageBulkExportJobs in progress.
  * If PageBulkExportJob finishes the current step, the next step will be started on the next cron execution.
  */
-class PageBulkExportJobCronService extends CronService implements IPageBulkExportJobCronService {
-
+class PageBulkExportJobCronService
+  extends CronService
+  implements IPageBulkExportJobCronService
+{
   crowi: Crowi;
 
   activityEvent: any;
@@ -76,7 +90,9 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
     super();
     this.crowi = crowi;
     this.activityEvent = crowi.event('activity');
-    this.parallelExecLimit = configManager.getConfig('app:pageBulkExportParallelExecLimit');
+    this.parallelExecLimit = configManager.getConfig(
+      'app:pageBulkExportParallelExecLimit',
+    );
   }
 
   override getCronSchedule(): string {
@@ -85,8 +101,12 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
   override async executeJob(): Promise<void> {
     const pageBulkExportJobsInProgress = await PageBulkExportJob.find({
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
-    }).sort({ createdAt: 1 }).limit(this.parallelExecLimit);
+      $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+        status,
+      })),
+    })
+      .sort({ createdAt: 1 })
+      .limit(this.parallelExecLimit);
 
     pageBulkExportJobsInProgress.forEach((pageBulkExportJob) => {
       this.proceedBulkExportJob(pageBulkExportJob);
@@ -102,9 +122,14 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param pageBulkExportJob page bulk export job in execution
    * @param isHtmlPath whether the tmp output path is for html files
    */
-  getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
+  getTmpOutputDir(
+    pageBulkExportJob: PageBulkExportJobDocument,
+    isHtmlPath = false,
+  ): string {
     const jobId = pageBulkExportJob._id.toString();
-    return isHtmlPath ? path.join(this.tmpOutputRootDir, 'html', jobId) : path.join(this.tmpOutputRootDir, jobId);
+    return isHtmlPath
+      ? path.join(this.tmpOutputRootDir, 'html', jobId)
+      : path.join(this.tmpOutputRootDir, jobId);
   }
 
   /**
@@ -143,12 +168,17 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
         await pageBulkExportJob.save();
       }
 
-      if (pageBulkExportJob.status === PageBulkExportJobStatus.exporting && pageBulkExportJob.format === PageBulkExportFormat.pdf) {
+      if (
+        pageBulkExportJob.status === PageBulkExportJobStatus.exporting &&
+        pageBulkExportJob.format === PageBulkExportFormat.pdf
+      ) {
         await requestPdfConverter(pageBulkExportJob);
       }
 
       // return if job is still the same status as the previous cron exec
-      if (pageBulkExportJob.status === pageBulkExportJob.statusOnPreviousCronExec) {
+      if (
+        pageBulkExportJob.status === pageBulkExportJob.statusOnPreviousCronExec
+      ) {
         return;
       }
 
@@ -161,17 +191,21 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
       if (pageBulkExportJob.status === PageBulkExportJobStatus.initializing) {
         await createPageSnapshotsAsync.bind(this)(user, pageBulkExportJob);
-      }
-      else if (pageBulkExportJob.status === PageBulkExportJobStatus.exporting) {
+      } else if (
+        pageBulkExportJob.status === PageBulkExportJobStatus.exporting
+      ) {
         await exportPagesToFsAsync.bind(this)(pageBulkExportJob);
-      }
-      else if (pageBulkExportJob.status === PageBulkExportJobStatus.uploading) {
+      } else if (
+        pageBulkExportJob.status === PageBulkExportJobStatus.uploading
+      ) {
         compressAndUpload.bind(this)(user, pageBulkExportJob);
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
-      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED, pageBulkExportJob);
+      await this.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED,
+        pageBulkExportJob,
+      );
     }
   }
 
@@ -180,20 +214,27 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param err error
    * @param pageBulkExportJob PageBulkExportJob executed in the pipeline
    */
-  async handleError(err: Error | null, pageBulkExportJob: PageBulkExportJobDocument) {
+  async handleError(
+    err: Error | null,
+    pageBulkExportJob: PageBulkExportJobDocument,
+  ) {
     if (err == null) return;
 
     if (err instanceof BulkExportJobExpiredError) {
       logger.error(err);
-      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED, pageBulkExportJob);
-    }
-    else if (err instanceof BulkExportJobRestartedError) {
+      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 {
+    } else {
       logger.error(err);
-      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED, pageBulkExportJob);
+      await this.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED,
+        pageBulkExportJob,
+      );
     }
   }
 
@@ -203,17 +244,18 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param pageBulkExportJob the page bulk export job
    */
   async notifyExportResultAndCleanUp(
-      action: SupportedActionType,
-      pageBulkExportJob: PageBulkExportJobDocument,
+    action: SupportedActionType,
+    pageBulkExportJob: PageBulkExportJobDocument,
   ): Promise<void> {
-    pageBulkExportJob.status = action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
-      ? PageBulkExportJobStatus.completed : PageBulkExportJobStatus.failed;
+    pageBulkExportJob.status =
+      action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
+        ? PageBulkExportJobStatus.completed
+        : PageBulkExportJobStatus.failed;
 
     try {
       await pageBulkExportJob.save();
       await this.notifyExportResult(pageBulkExportJob, action);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
     }
     // execute independently of notif process resolve/reject
@@ -225,13 +267,15 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * - delete page snapshots
    * - remove the temporal output directory
    */
-  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument, restarted = false) {
+  async cleanUpExportJobResources(
+    pageBulkExportJob: PageBulkExportJobDocument,
+    restarted = false,
+  ) {
     const streamInExecution = this.getStreamInExecution(pageBulkExportJob._id);
     if (streamInExecution != null) {
       if (restarted) {
         streamInExecution.destroy(new BulkExportJobRestartedError());
-      }
-      else {
+      } else {
         streamInExecution.destroy(new BulkExportJobExpiredError());
       }
       this.removeStreamInExecution(pageBulkExportJob._id);
@@ -239,13 +283,19 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
     const promises = [
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
-      fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
+      fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), {
+        recursive: true,
+        force: true,
+      }),
     ];
 
     // clean up html files exported for PDF conversion
     if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
       promises.push(
-        fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), { recursive: true, force: true }),
+        fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), {
+          recursive: true,
+          force: true,
+        }),
       );
     }
 
@@ -256,7 +306,8 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
   }
 
   private async notifyExportResult(
-      pageBulkExportJob: PageBulkExportJobDocument, action: SupportedActionType,
+    pageBulkExportJob: PageBulkExportJobDocument,
+    action: SupportedActionType,
   ) {
     const activity = await this.crowi.activityService.createActivity({
       action,
@@ -264,18 +315,26 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
       target: pageBulkExportJob,
       user: pageBulkExportJob.user,
       snapshot: {
-        username: isPopulated(pageBulkExportJob.user) ? pageBulkExportJob.user.username : '',
+        username: isPopulated(pageBulkExportJob.user)
+          ? pageBulkExportJob.user.username
+          : '',
       },
     });
-    const getAdditionalTargetUsers = async(activity: ActivityDocument) => [activity.user];
-    const preNotify = preNotifyService.generatePreNotify(activity, getAdditionalTargetUsers);
+    const getAdditionalTargetUsers = async (activity: ActivityDocument) => [
+      activity.user,
+    ];
+    const preNotify = preNotifyService.generatePreNotify(
+      activity,
+      getAdditionalTargetUsers,
+    );
     this.activityEvent.emit('updated', activity, pageBulkExportJob, preNotify);
   }
-
 }
 
 // eslint-disable-next-line import/no-mutable-exports
-export let pageBulkExportJobCronService: PageBulkExportJobCronService | undefined; // singleton instance
+export let pageBulkExportJobCronService:
+  | PageBulkExportJobCronService
+  | undefined; // singleton instance
 export default function instanciate(crowi: Crowi): void {
   pageBulkExportJobCronService = new PageBulkExportJobCronService(crowi);
 }

+ 33 - 17
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -1,4 +1,8 @@
-import { PdfCtrlSyncJobStatus202Status, PdfCtrlSyncJobStatusBodyStatus, pdfCtrlSyncJobStatus } from '@growi/pdf-converter-client';
+import {
+  PdfCtrlSyncJobStatus202Status,
+  PdfCtrlSyncJobStatusBodyStatus,
+  pdfCtrlSyncJobStatus,
+} from '@growi/pdf-converter-client';
 
 import { configManager } from '~/server/service/config-manager';
 
@@ -12,7 +16,9 @@ import { BulkExportJobExpiredError } from './errors';
  * Request PDF converter and start pdf convert for the pageBulkExportJob,
  * or sync pdf convert status if already started.
  */
-export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+export async function requestPdfConverter(
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
   const jobCreatedAt = pageBulkExportJob.createdAt;
   if (jobCreatedAt == null) {
     throw new Error('createdAt is not set');
@@ -20,15 +26,24 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
 
   const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
   const appId = configManager.getConfig('app:growiAppIdForCloud');
-  if (isGrowiCloud && (appId == null)) {
+  if (isGrowiCloud && appId == null) {
     throw new Error('appId is required for bulk export on GROWI.cloud');
   }
 
-  const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
-  const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
-  let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
+  const exportJobExpirationSeconds = configManager.getConfig(
+    'app:bulkExportJobExpirationSeconds',
+  );
+  const bulkExportJobExpirationDate = new Date(
+    jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000,
+  );
+  let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus =
+    PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
 
-  const lastExportPagePath = (await PageBulkExportPageSnapshot.findOne({ pageBulkExportJob }).sort({ path: -1 }))?.path;
+  const lastExportPagePath = (
+    await PageBulkExportPageSnapshot.findOne({ pageBulkExportJob }).sort({
+      path: -1,
+    })
+  )?.path;
   if (lastExportPagePath == null) {
     throw new Error('lastExportPagePath is missing');
   }
@@ -46,22 +61,23 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
       pdfConvertStatus = PdfCtrlSyncJobStatusBodyStatus.FAILED;
     }
 
-    const res = await pdfCtrlSyncJobStatus({
-      appId,
-      jobId: pageBulkExportJob._id.toString(),
-      expirationDate: bulkExportJobExpirationDate.toISOString(),
-      status: pdfConvertStatus,
-    }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') });
+    const res = await pdfCtrlSyncJobStatus(
+      {
+        appId,
+        jobId: pageBulkExportJob._id.toString(),
+        expirationDate: bulkExportJobExpirationDate.toISOString(),
+        status: pdfConvertStatus,
+      },
+      { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') },
+    );
 
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {
       pageBulkExportJob.status = PageBulkExportJobStatus.uploading;
       await pageBulkExportJob.save();
-    }
-    else if (res.data.status === PdfCtrlSyncJobStatus202Status.FAILED) {
+    } else if (res.data.status === PdfCtrlSyncJobStatus202Status.FAILED) {
       throw new Error('PDF export failed');
     }
-  }
-  catch (err) {
+  } catch (err) {
     // Only set as failure when host is ready but failed.
     // If host is not ready, the request should be retried on the next cron execution.
     if (!['ENOTFOUND', 'ECONNREFUSED'].includes(err.code)) {

+ 33 - 11
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -8,11 +8,12 @@ import type { IAttachmentDocument } from '~/server/models/attachment';
 import { Attachment } from '~/server/models/attachment';
 import type { FileUploader } from '~/server/service/file-uploader';
 import loggerFactory from '~/utils/logger';
-
-import type { IPageBulkExportJobCronService } from '..';
 import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
+import type { IPageBulkExportJobCronService } from '..';
 
-const logger = loggerFactory('growi:service:page-bulk-export-job-cron:compress-and-upload-async');
+const logger = loggerFactory(
+  'growi:service:page-bulk-export-job-cron:compress-and-upload-async',
+);
 
 function setUpPageArchiver(): Archiver {
   const pageArchiver = archiver('tar', {
@@ -29,7 +30,10 @@ function setUpPageArchiver(): Archiver {
 }
 
 async function postProcess(
-    this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument, attachment: IAttachmentDocument, fileSize: number,
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+  attachment: IAttachmentDocument,
+  fileSize: number,
 ): Promise<void> {
   attachment.fileSize = fileSize;
   await attachment.save();
@@ -40,18 +44,33 @@ async function postProcess(
   await pageBulkExportJob.save();
 
   this.removeStreamInExecution(pageBulkExportJob._id);
-  await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED, pageBulkExportJob);
+  await this.notifyExportResultAndCleanUp(
+    SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED,
+    pageBulkExportJob,
+  );
 }
 
 /**
  * Execute a pipeline that reads the page files from the temporal fs directory, compresses them, and uploads to the cloud storage
  */
-export async function compressAndUpload(this: IPageBulkExportJobCronService, user, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+export async function compressAndUpload(
+  this: IPageBulkExportJobCronService,
+  user,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
   const pageArchiver = setUpPageArchiver();
 
-  if (pageBulkExportJob.revisionListHash == null) throw new Error('revisionListHash is not set');
+  if (pageBulkExportJob.revisionListHash == null)
+    throw new Error('revisionListHash is not set');
   const originalName = `${pageBulkExportJob.revisionListHash}.${this.compressExtension}`;
-  const attachment = Attachment.createWithoutSave(null, user, originalName, this.compressExtension, 0, AttachmentType.PAGE_BULK_EXPORT);
+  const attachment = Attachment.createWithoutSave(
+    null,
+    user,
+    originalName,
+    this.compressExtension,
+    0,
+    AttachmentType.PAGE_BULK_EXPORT,
+  );
 
   const fileUploadService: FileUploader = this.crowi.fileUploadService;
 
@@ -61,10 +80,13 @@ export async function compressAndUpload(this: IPageBulkExportJobCronService, use
 
   try {
     await fileUploadService.uploadAttachment(pageArchiver, attachment);
-  }
-  catch (e) {
+  } catch (e) {
     logger.error(e);
     this.handleError(e, pageBulkExportJob);
   }
-  await postProcess.bind(this)(pageBulkExportJob, attachment, pageArchiver.pointer());
+  await postProcess.bind(this)(
+    pageBulkExportJob,
+    attachment,
+    pageArchiver.pointer(),
+  );
 }

+ 22 - 17
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts

@@ -1,20 +1,21 @@
-import { createHash } from 'crypto';
-import { Writable, pipeline } from 'stream';
-
-import { getIdForRef, getIdStringForRef } from '@growi/core';
 import type { IPage } from '@growi/core';
+import { getIdForRef, getIdStringForRef } from '@growi/core';
+import { createHash } from 'crypto';
 import mongoose from 'mongoose';
+import { pipeline, Writable } from 'stream';
 
 import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { SupportedAction } from '~/interfaces/activity';
 import type { PageDocument, PageModel } from '~/server/models/page';
-
-import type { IPageBulkExportJobCronService } from '..';
 import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
 import PageBulkExportJob from '../../../models/page-bulk-export-job';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
+import type { IPageBulkExportJobCronService } from '..';
 
-async function reuseDuplicateExportIfExists(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument) {
+async function reuseDuplicateExportIfExists(
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+) {
   const duplicateExportJob = await PageBulkExportJob.findOne({
     user: pageBulkExportJob.user,
     page: pageBulkExportJob.page,
@@ -28,7 +29,10 @@ async function reuseDuplicateExportIfExists(this: IPageBulkExportJobCronService,
     pageBulkExportJob.status = PageBulkExportJobStatus.completed;
     await pageBulkExportJob.save();
 
-    await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED, pageBulkExportJob);
+    await this.notifyExportResultAndCleanUp(
+      SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED,
+      pageBulkExportJob,
+    );
   }
 }
 
@@ -36,7 +40,11 @@ async function reuseDuplicateExportIfExists(this: IPageBulkExportJobCronService,
  * Start a pipeline that creates a snapshot for each page that is to be exported in the pageBulkExportJob.
  * 'revisionListHash' is calulated and saved to the pageBulkExportJob at the end of the pipeline.
  */
-export async function createPageSnapshotsAsync(this: IPageBulkExportJobCronService, user, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+export async function createPageSnapshotsAsync(
+  this: IPageBulkExportJobCronService,
+  user,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
   // if the process of creating snapshots was interrupted, delete the snapshots and create from the start
@@ -54,15 +62,14 @@ export async function createPageSnapshotsAsync(this: IPageBulkExportJobCronServi
   const builder = await new PageQueryBuilder(Page.find())
     .addConditionToListWithDescendants(basePage.path)
     .addViewerCondition(user);
-  const pagesReadable = builder
-    .query
+  const pagesReadable = builder.query
     .lean()
     .cursor({ batchSize: this.pageBatchSize });
 
   // create a Writable that creates a snapshot for each page
   const pageSnapshotsWritable = new Writable({
     objectMode: true,
-    write: async(page: PageDocument, encoding, callback) => {
+    write: async (page: PageDocument, encoding, callback) => {
       try {
         if (page.revision != null) {
           revisionListHash.update(getIdStringForRef(page.revision));
@@ -72,22 +79,20 @@ export async function createPageSnapshotsAsync(this: IPageBulkExportJobCronServi
           path: page.path,
           revision: page.revision,
         });
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }
       callback();
     },
-    final: async(callback) => {
+    final: async (callback) => {
       try {
         pageBulkExportJob.revisionListHash = revisionListHash.digest('hex');
         pageBulkExportJob.status = PageBulkExportJobStatus.exporting;
         await pageBulkExportJob.save();
 
         await reuseDuplicateExportIfExists.bind(this)(pageBulkExportJob);
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }

+ 61 - 34
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts

@@ -1,26 +1,31 @@
-import fs from 'fs';
-import path from 'path';
-import { Writable, pipeline } from 'stream';
-
 import { dynamicImport } from '@cspell/dynamic-import';
 import { isPopulated } from '@growi/core';
-import { getParentPath, normalizePath } from '@growi/core/dist/utils/path-utils';
+import {
+  getParentPath,
+  normalizePath,
+} from '@growi/core/dist/utils/path-utils';
+import fs from 'fs';
 import type { Root } from 'mdast';
+import path from 'path';
 import type * as RemarkHtml from 'remark-html';
 import type * as RemarkParse from 'remark-parse';
+import { pipeline, Writable } from 'stream';
 import type * as Unified from 'unified';
 
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
-
-import type { IPageBulkExportJobCronService } from '..';
+import {
+  PageBulkExportFormat,
+  PageBulkExportJobStatus,
+} from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } 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 type { IPageBulkExportJobCronService } from '..';
 
-async function convertMdToHtml(md: string, htmlConverter: Unified.Processor<Root, undefined, undefined, Root, string>): Promise<string> {
-  const htmlString = (await htmlConverter
-    .process(md))
-    .toString();
+async function convertMdToHtml(
+  md: string,
+  htmlConverter: Unified.Processor<Root, undefined, undefined, Root, string>,
+): Promise<string> {
+  const htmlString = (await htmlConverter.process(md)).toString();
 
   return htmlString;
 }
@@ -28,13 +33,24 @@ async function convertMdToHtml(md: string, htmlConverter: Unified.Processor<Root
 /**
  * Get a Writable that writes the page body temporarily to fs
  */
-async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument): Promise<Writable> {
-  const unified = (await dynamicImport<typeof Unified>('unified', __dirname)).unified;
-  const remarkParse = (await dynamicImport<typeof RemarkParse>('remark-parse', __dirname)).default;
-  const remarkHtml = (await dynamicImport<typeof RemarkHtml>('remark-html', __dirname)).default;
+async function getPageWritable(
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<Writable> {
+  const unified = (await dynamicImport<typeof Unified>('unified', __dirname))
+    .unified;
+  const remarkParse = (
+    await dynamicImport<typeof RemarkParse>('remark-parse', __dirname)
+  ).default;
+  const remarkHtml = (
+    await dynamicImport<typeof RemarkHtml>('remark-html', __dirname)
+  ).default;
 
   const isHtmlPath = pageBulkExportJob.format === PageBulkExportFormat.pdf;
-  const format = pageBulkExportJob.format === PageBulkExportFormat.pdf ? 'html' : pageBulkExportJob.format;
+  const format =
+    pageBulkExportJob.format === PageBulkExportFormat.pdf
+      ? 'html'
+      : pageBulkExportJob.format;
   const outputDir = this.getTmpOutputDir(pageBulkExportJob, isHtmlPath);
   // define before the stream starts to avoid creating multiple instances
   const htmlConverter = unified()
@@ -43,7 +59,11 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
     .use(remarkHtml);
   return new Writable({
     objectMode: true,
-    write: async(page: PageBulkExportPageSnapshotDocument, encoding, callback) => {
+    write: async (
+      page: PageBulkExportPageSnapshotDocument,
+      encoding,
+      callback,
+    ) => {
       try {
         const revision = page.revision;
 
@@ -56,22 +76,23 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
           await fs.promises.mkdir(fileOutputParentPath, { recursive: true });
           if (pageBulkExportJob.format === PageBulkExportFormat.md) {
             await fs.promises.writeFile(fileOutputPath, markdownBody);
-          }
-          else {
-            const htmlString = await convertMdToHtml(markdownBody, htmlConverter);
+          } else {
+            const htmlString = await convertMdToHtml(
+              markdownBody,
+              htmlConverter,
+            );
             await fs.promises.writeFile(fileOutputPath, htmlString);
           }
           pageBulkExportJob.lastExportedPagePath = page.path;
           await pageBulkExportJob.save();
         }
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }
       callback();
     },
-    final: async(callback) => {
+    final: async (callback) => {
       try {
         // If the format is md, the export process ends here.
         // If the format is pdf, pdf conversion in pdf-converter has to finish.
@@ -79,8 +100,7 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
           pageBulkExportJob.status = PageBulkExportJobStatus.uploading;
           await pageBulkExportJob.save();
         }
-      }
-      catch (err) {
+      } catch (err) {
         callback(err);
         return;
       }
@@ -93,14 +113,21 @@ async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExpo
  * Export pages to the file system before compressing and uploading to the cloud storage.
  * The export will resume from the last exported page if the process was interrupted.
  */
-export async function exportPagesToFsAsync(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
-  const findQuery = pageBulkExportJob.lastExportedPagePath != null ? {
-    pageBulkExportJob,
-    path: { $gt: pageBulkExportJob.lastExportedPagePath },
-  } : { pageBulkExportJob };
-  const pageSnapshotsReadable = PageBulkExportPageSnapshot
-    .find(findQuery)
-    .populate('revision').sort({ path: 1 }).lean()
+export async function exportPagesToFsAsync(
+  this: IPageBulkExportJobCronService,
+  pageBulkExportJob: PageBulkExportJobDocument,
+): Promise<void> {
+  const findQuery =
+    pageBulkExportJob.lastExportedPagePath != null
+      ? {
+          pageBulkExportJob,
+          path: { $gt: pageBulkExportJob.lastExportedPagePath },
+        }
+      : { pageBulkExportJob };
+  const pageSnapshotsReadable = PageBulkExportPageSnapshot.find(findQuery)
+    .populate('revision')
+    .sort({ path: 1 })
+    .lean()
     .cursor({ batchSize: this.pageBatchSize });
 
   const pagesWritable = await getPageWritable.bind(this)(pageBulkExportJob);

+ 53 - 26
apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts

@@ -1,78 +1,105 @@
-import {
-  type IPage, SubscriptionStatusType,
-} from '@growi/core';
+import { type IPage, SubscriptionStatusType } from '@growi/core';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
-
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { PageModel } from '~/server/models/page';
 import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import type { PageBulkExportFormat } from '../../interfaces/page-bulk-export';
-import { PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import {
+  PageBulkExportJobInProgressStatus,
+  PageBulkExportJobStatus,
+} from '../../interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 
 const logger = loggerFactory('growi:services:PageBulkExportService');
 
 export class DuplicateBulkExportJobError extends Error {
-
   duplicateJob: HydratedDocument<PageBulkExportJobDocument>;
 
   constructor(duplicateJob: HydratedDocument<PageBulkExportJobDocument>) {
     super('Duplicate bulk export job is in progress');
     this.duplicateJob = duplicateJob;
   }
-
 }
 
 export interface IPageBulkExportService {
-  createOrResetBulkExportJob: (basePagePath: string, currentUser, restartJob?: boolean) => Promise<void>;
+  createOrResetBulkExportJob: (
+    basePagePath: string,
+    currentUser,
+    restartJob?: boolean,
+  ) => Promise<void>;
 }
 
 class PageBulkExportService implements IPageBulkExportService {
-
   /**
    * Create a new page bulk export job or reset the existing one
    */
-  async createOrResetBulkExportJob(basePagePath: string, format: PageBulkExportFormat, currentUser, restartJob = false): Promise<void> {
+  async createOrResetBulkExportJob(
+    basePagePath: string,
+    format: PageBulkExportFormat,
+    currentUser,
+    restartJob = false,
+  ): Promise<void> {
     const Page = mongoose.model<IPage, PageModel>('Page');
-    const basePage = await Page.findByPathAndViewer(basePagePath, currentUser, null, true);
+    const basePage = await Page.findByPathAndViewer(
+      basePagePath,
+      currentUser,
+      null,
+      true,
+    );
 
     if (basePage == null) {
       throw new Error('Base page not found or not accessible');
     }
 
-    const duplicatePageBulkExportJobInProgress: HydratedDocument<PageBulkExportJobDocument> | null = await PageBulkExportJob.findOne({
-      user: { $eq: currentUser },
-      page: basePage,
-      format: { $eq: format },
-      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
-    });
+    const duplicatePageBulkExportJobInProgress: HydratedDocument<PageBulkExportJobDocument> | null =
+      await PageBulkExportJob.findOne({
+        user: { $eq: currentUser },
+        page: basePage,
+        format: { $eq: format },
+        $or: Object.values(PageBulkExportJobInProgressStatus).map((status) => ({
+          status,
+        })),
+      });
     if (duplicatePageBulkExportJobInProgress != null) {
       if (restartJob) {
         this.resetBulkExportJob(duplicatePageBulkExportJobInProgress);
         return;
       }
-      throw new DuplicateBulkExportJobError(duplicatePageBulkExportJobInProgress);
+      throw new DuplicateBulkExportJobError(
+        duplicatePageBulkExportJobInProgress,
+      );
     }
-    const pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument> = await PageBulkExportJob.create({
-      user: currentUser, page: basePage, format, status: PageBulkExportJobStatus.initializing,
-    });
-
-    await Subscription.upsertSubscription(currentUser, SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB, pageBulkExportJob, SubscriptionStatusType.SUBSCRIBE);
+    const pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument> =
+      await PageBulkExportJob.create({
+        user: currentUser,
+        page: basePage,
+        format,
+        status: PageBulkExportJobStatus.initializing,
+      });
+
+    await Subscription.upsertSubscription(
+      currentUser,
+      SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB,
+      pageBulkExportJob,
+      SubscriptionStatusType.SUBSCRIBE,
+    );
   }
 
   /**
    * Reset page bulk export job in progress
    */
-  async resetBulkExportJob(pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument>): Promise<void> {
+  async resetBulkExportJob(
+    pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument>,
+  ): Promise<void> {
     pageBulkExportJob.restartFlag = true;
     await pageBulkExportJob.save();
   }
-
 }
 
-export const pageBulkExportService: PageBulkExportService = new PageBulkExportService(); // singleton instance
+export const pageBulkExportService: PageBulkExportService =
+  new PageBulkExportService(); // singleton instance

+ 0 - 1
biome.json

@@ -30,7 +30,6 @@
       "!apps/app/src/features/growi-plugin/**",
       "!apps/app/src/features/openai/**",
       "!apps/app/src/features/opentelemetry/**",
-      "!apps/app/src/features/page-bulk-export/**",
       "!apps/app/src/features/rate-limiter/**",
       "!apps/app/src/interfaces/**",
       "!apps/app/src/models/**",