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

Merge branch 'feat/page-bulk-export' of https://github.com/weseek/growi into feat/page-bulk-export

Futa Arai 2 лет назад
Родитель
Сommit
e0a3d6261f

+ 7 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -589,11 +589,16 @@
     "discription_heading": "Create Account",
     "discription_heading": "Create Account",
     "discription": "Create an your account with the invited email address"
     "discription": "Create an your account with the invited email address"
   },
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "Failed to export",
     "failed_to_export": "Failed to export",
     "failed_to_count_pages": "Failed to count pages",
     "failed_to_count_pages": "Failed to count pages",
     "export_page_markdown": "Export page as Markdown",
     "export_page_markdown": "Export page as Markdown",
-    "export_page_pdf": "Export page as PDF"
+    "export_page_pdf": "Export page as PDF",
+    "bulk_export": "Export page and all child pages",
+    "bulk_export_notice": "Once a download link is ready, a notification will be sent. If the number of pages is large, it may take a while for preparation.",
+    "markdown": "Markdown",
+    "choose_export_format": "Select export format",
+    "bulk_export_started": "Please wait a moment..."
   },
   },
   "message": {
   "message": {
     "successfully_connected": "Successfully Connected!",
     "successfully_connected": "Successfully Connected!",

+ 7 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -622,11 +622,16 @@
     "discription_heading": "アカウント作成",
     "discription_heading": "アカウント作成",
     "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
     "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
   },
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "ページのエクスポートに失敗しました",
     "failed_to_export": "ページのエクスポートに失敗しました",
     "failed_to_count_pages": "ページ数の取得に失敗しました",
     "failed_to_count_pages": "ページ数の取得に失敗しました",
     "export_page_markdown": "マークダウン形式でページをエクスポート",
     "export_page_markdown": "マークダウン形式でページをエクスポート",
-    "export_page_pdf": "PDF形式でページをエクスポート"
+    "export_page_pdf": "PDF形式でページをエクスポート",
+    "bulk_export": "ページとその配下のページを全てエクスポート",
+    "bulk_export_notice": "ダウンロードの準備が完了すると、通知が届きます。ページ数が多いと、準備に時間がかかる場合があります。",
+    "markdown": "マークダウン",
+    "choose_export_format": "エクスポート形式を選択してください",
+    "bulk_export_started": "ただいま準備中です..."
   },
   },
   "message": {
   "message": {
     "successfully_connected": "接続に成功しました!",
     "successfully_connected": "接続に成功しました!",

+ 7 - 2
apps/app/public/static/locales/zh_CN/translation.json

@@ -592,11 +592,16 @@
     "discription_heading": "创建账户",
     "discription_heading": "创建账户",
     "discription": "用被邀请的电子邮件地址创建一个你的账户"
     "discription": "用被邀请的电子邮件地址创建一个你的账户"
   },
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "导出失败",
     "failed_to_export": "导出失败",
     "failed_to_count_pages": "页面计数失败",
     "failed_to_count_pages": "页面计数失败",
     "export_page_markdown": "以Markdown格式导出页面",
     "export_page_markdown": "以Markdown格式导出页面",
-    "export_page_pdf": "以PDF格式导出页面"
+    "export_page_pdf": "以PDF格式导出页面",
+    "bulk_export": "导出页面及其下的所有页面",
+    "bulk_export_notice": "下载链接准备好后,将发送通知。如果页数较多,则可能需要一段时间准备。",
+    "markdown": "Markdown",
+    "choose_export_format": "选择导出格式",
+    "bulk_export_started": "目前我们正在准备..."
   },
   },
 	"message": {
 	"message": {
 		"successfully_connected": "连接成功!",
 		"successfully_connected": "连接成功!",

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -24,6 +24,7 @@ const PagePresentationModal = dynamic(() => import('../PagePresentationModal'),
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
+const PageBulkExportSelectModal = dynamic(() => import('../../features/page-bulk-export/client/components/PageBulkExportSelectModal'), { ssr: false });
 
 
 
 
 type Props = {
 type Props = {
@@ -65,6 +66,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <HotkeysManager />
       <HotkeysManager />
 
 
       <ShortcutsModal />
       <ShortcutsModal />
+      <PageBulkExportSelectModal />
       <SystemVersion showShortcutsButton />
       <SystemVersion showShortcutsButton />
     </RawLayout>
     </RawLayout>
   );
   );

+ 12 - 1
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -14,6 +14,7 @@ import { DropdownItem } from 'reactstrap';
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useShouldExpandContent } from '~/client/services/layout';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import {
 import {
   useCurrentPathname,
   useCurrentPathname,
   useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
   useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
@@ -71,6 +72,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 
 
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
+  const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModal();
 
 
   return (
   return (
     <>
     <>
@@ -92,7 +94,16 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         className="grw-page-control-dropdown-item"
         className="grw-page-control-dropdown-item"
       >
       >
         <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
         <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
-        {t('export_bulk.export_page_markdown')}
+        {t('page_export.export_page_markdown')}
+      </DropdownItem>
+
+      {/* Bulk export */}
+      <DropdownItem
+        onClick={openPageBulkExportSelectModal}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
+        {t('page_export.bulk_export')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem divider />
       <DropdownItem divider />

+ 1 - 1
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -57,7 +57,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       className="grw-page-control-dropdown-item"
       className="grw-page-control-dropdown-item"
     >
     >
       <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
       <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
-      {t('export_bulk.export_page_markdown')}
+      {t('page_export.export_page_markdown')}
     </DropdownItem>
     </DropdownItem>
   );
   );
 };
 };

+ 41 - 0
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -0,0 +1,41 @@
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+
+import { toastSuccess } from '~/client/util/toastr';
+import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
+
+const PageBulkExportSelectModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: status, close } = usePageBulkExportSelectModal();
+
+  const startBulkExport = () => {
+    close();
+    toastSuccess(t('page_export.bulk_export_started'));
+  };
+
+  return (
+    <>
+      {status != null && (
+        <Modal isOpen={status.isOpened} toggle={close}>
+          <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+            {t('page_export.bulk_export')}
+          </ModalHeader>
+          <ModalBody>
+            {t('page_export.choose_export_format')}
+            <div className="my-2">
+              <small className="text-muted">
+                {t('page_export.bulk_export_notice')}
+              </small>
+            </div>
+            <div className="d-flex justify-content-center mt-2">
+              <button className="btn btn-primary" type="button" onClick={startBulkExport}>{t('page_export.markdown')}</button>
+              <button className="btn btn-primary ml-2" type="button" onClick={startBulkExport}>PDF</button>
+            </div>
+          </ModalBody>
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default PageBulkExportSelectModal;

+ 27 - 0
apps/app/src/features/page-bulk-export/client/stores/modal.tsx

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

+ 31 - 0
apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts

@@ -0,0 +1,31 @@
+import type {
+  IAttachment, IPage, IRevision, IUser, Ref,
+} from '@growi/core';
+
+export const PageBulkExportFormat = {
+  markdown: 'markdown',
+  pdf: 'pdf',
+} as const;
+
+type PageBulkExportFormat = typeof PageBulkExportFormat[keyof typeof PageBulkExportFormat]
+
+export interface IPageBulkExportJob {
+  user: Ref<IUser>, // user that started export job
+  page: Ref<IPage>, // the root page of page tree to export
+  lastUploadedPagePath: string, // the path of page that was uploaded last
+  uploadId: string, // upload ID of multipart upload of S3/GCS
+  format: PageBulkExportFormat,
+  expireAt: Date, // the date at which job execution expires
+}
+
+export interface IPageBulkExportResult {
+  attachment: Ref<IAttachment>,
+  expireAt: Date, // the date at which downloading of result expires
+}
+
+// snapshot of page info to upload
+export interface IPageBulkExportPageInfo {
+  pageBulkExportJob: Ref<IPageBulkExportJob>,
+  path: string, // page path when export was stared
+  revision: Ref<IRevision>, // page revision when export was stared
+}

+ 20 - 0
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts

@@ -0,0 +1,20 @@
+import { type Document, type Model, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import { IPageBulkExportJob, PageBulkExportFormat } from '../../interfaces/page-bulk-export';
+
+export interface PageBulkExportJobDocument extends IPageBulkExportJob, Document {}
+
+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 },
+  lastUploadedPagePath: { type: String, required: true },
+  uploadId: { type: String, required: true },
+  format: { type: String, enum: Object.values(PageBulkExportFormat), required: true },
+  expireAt: { type: Date, required: true },
+}, { timestamps: true });
+
+export default getOrCreateModel<PageBulkExportJobDocument, PageBulkExportJobModel>('PageBulkExportJob', pageBulkExportJobSchema);

+ 17 - 0
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-info.ts

@@ -0,0 +1,17 @@
+import { type Document, type Model, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import { IPageBulkExportPageInfo } from '../../interfaces/page-bulk-export';
+
+export interface PageBulkExportPageInfoDocument extends IPageBulkExportPageInfo, Document {}
+
+export type PageBulkExportPageInfoModel = Model<PageBulkExportPageInfoDocument>
+
+const pageBulkExportPageInfoSchema = new Schema<PageBulkExportPageInfoDocument>({
+  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<PageBulkExportPageInfoDocument, PageBulkExportPageInfoModel>('PageBulkExportPageInfo', pageBulkExportPageInfoSchema);

+ 16 - 0
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-result.ts

@@ -0,0 +1,16 @@
+import { type Document, type Model, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import { IPageBulkExportResult } from '../../interfaces/page-bulk-export';
+
+export interface PageBulkExportResultDocument extends IPageBulkExportResult, Document {}
+
+export type PageBulkExportResultModel = Model<PageBulkExportResultDocument>
+
+const pageBulkExportResultSchema = new Schema<PageBulkExportResultDocument>({
+  attachment: { type: Schema.Types.ObjectId, ref: 'Attachment', required: true },
+  expireAt: { type: Date, required: true },
+}, { timestamps: true });
+
+export default getOrCreateModel<PageBulkExportResultDocument, PageBulkExportResultModel>('PageBulkExportResult', pageBulkExportResultSchema);