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

Merge pull request #8939 from weseek/feat/sync-latest-revision-body-to-yjs-draft

feat: Sync latest revision body to Yjs draft
Yuki Takei 1 год назад
Родитель
Сommit
145b67bea7

+ 6 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -9,6 +9,7 @@
   "delete_all": "Delete all",
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
   "Duplicate": "Duplicate",
   "PathRecovery": "Path recovery",
   "PathRecovery": "Path recovery",
+  "SyncLatestRevisionBody": "Sync editor with latest body",
   "Copy": "Copy",
   "Copy": "Copy",
   "preview": "Preview",
   "preview": "Preview",
   "desktop": "Desktop",
   "desktop": "Desktop",
@@ -849,5 +850,10 @@
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "success-toaster": "Latest text synchronized",
+    "error-toaster": "Synchronization of the latest text failed"
   }
   }
 }
 }

+ 6 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -9,6 +9,7 @@
   "delete_all": "Tout supprimer",
   "delete_all": "Tout supprimer",
   "Duplicate": "Dupliquer",
   "Duplicate": "Dupliquer",
   "PathRecovery": "Récupération de chemin",
   "PathRecovery": "Récupération de chemin",
+  "SyncLatestRevisionBody": "Synchroniser l'éditeur avec le dernier corps",
   "Copy": "Copier",
   "Copy": "Copier",
   "preview": "Prévisualiser",
   "preview": "Prévisualiser",
   "desktop": "Ordinateur",
   "desktop": "Ordinateur",
@@ -840,5 +841,10 @@
     "show_wip_page": "Voir brouillon",
     "show_wip_page": "Voir brouillon",
     "size_s": "Taille: P",
     "size_s": "Taille: P",
     "size_l": "Taille: G"
     "size_l": "Taille: G"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "success-toaster": "Dernier texte synchronisé",
+    "error-toaster": "La synchronisation du dernier texte a échoué"
   }
   }
 }
 }

+ 6 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -9,6 +9,7 @@
   "delete_all": "全て削除",
   "delete_all": "全て削除",
   "Duplicate": "複製",
   "Duplicate": "複製",
   "PathRecovery": "パスを修復",
   "PathRecovery": "パスを修復",
+  "SyncLatestRevisionBody": "エディターを最新の本文に同期",
   "Copy": "コピー",
   "Copy": "コピー",
   "preview": "プレビュー",
   "preview": "プレビュー",
   "desktop": "パソコン",
   "desktop": "パソコン",
@@ -882,5 +883,10 @@
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "無題のページ"
     "untitled": "無題のページ"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "エディターに入力中のドラフトデータを削除して最新の本文を同期します。実行しますか?",
+    "success-toaster": "最新の本文を同期しました",
+    "error-toaster": "最新の本文の同期に失敗しました"
   }
   }
 }
 }

+ 6 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -9,6 +9,7 @@
   "delete_all": "删除所有",
   "delete_all": "删除所有",
   "Duplicate": "复制",
   "Duplicate": "复制",
   "PathRecovery": "路径恢复",
   "PathRecovery": "路径恢复",
+  "SyncLatestRevisionBody": "将编辑器与最新机身同步",
   "Copy": "复制",
   "Copy": "复制",
   "preview": "预览",
   "preview": "预览",
   "desktop": "电脑",
   "desktop": "电脑",
@@ -852,5 +853,10 @@
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "删除输入编辑器的草稿数据,同步最新文本。 您真的想运行它吗?",
+    "success-toaster": "同步最新文本",
+    "error-toaster": "同步最新文本失败"
   }
   }
 }
 }

+ 0 - 1
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -108,7 +108,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRevertMenuItem(pageId);
     await onClickRevertMenuItem(pageId);
   }, [onClickRevertMenuItem, pageId]);
   }, [onClickRevertMenuItem, pageId]);
 
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
   const deleteItemClickedHandler = useCallback(async() => {
     if (pageInfo == null || onClickDeleteMenuItem == null) {
     if (pageInfo == null || onClickDeleteMenuItem == null) {

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

@@ -14,7 +14,8 @@ import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
 import Sticky from 'react-stickynode';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import { toastSuccess, toastError } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
@@ -76,8 +77,30 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
 
 
+  const syncLatestRevisionBodyHandler = useCallback(async() => {
+    // eslint-disable-next-line no-alert
+    const answer = window.confirm(t('sync-latest-reevision-body.confirm'));
+    if (answer) {
+      try {
+        await syncLatestRevisionBody(pageId);
+        toastSuccess(t('sync-latest-reevision-body.success-toaster'));
+      }
+      catch {
+        toastError(t('sync-latest-reevision-body.error-toaster'));
+      }
+    }
+  }, [pageId, t]);
+
   return (
   return (
     <>
     <>
+      <DropdownItem
+        onClick={() => syncLatestRevisionBodyHandler()}
+        className="grw-page-control-dropdown-item"
+      >
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
+        {t('SyncLatestRevisionBody')}
+      </DropdownItem>
+
       {/* Presentation */}
       {/* Presentation */}
       <DropdownItem
       <DropdownItem
         onClick={() => openPresentationModal()}
         onClick={() => openPresentationModal()}

+ 5 - 0
apps/app/src/client/services/page-operation.ts

@@ -174,3 +174,8 @@ export const unpublish = async(pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   return res.data;
   return res.data;
 };
 };
+
+export const syncLatestRevisionBody = async(pageId: string): Promise<void> => {
+  await apiv3Put(`/page/${pageId}/sync-latest-revision-body-to-yjs-draft`);
+  return;
+};

+ 3 - 0
apps/app/src/server/routes/apiv3/page/index.ts

@@ -31,6 +31,7 @@ import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { createPageHandlersFactory } from './create-page';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
 import { publishPageHandlersFactory } from './publish-page';
+import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
 import { updatePageHandlersFactory } from './update-page';
 
 
@@ -953,5 +954,7 @@ module.exports = (crowi) => {
 
 
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
 
 
+  router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi));
+
   return router;
   return router;
 };
 };

+ 59 - 0
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -0,0 +1,59 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import { getYjsService } from '~/server/service/yjs';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:sync-latest-revision-body-to-yjs-draft');
+
+type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  pageId: string,
+}
+interface Req extends Request<ReqParams, ApiV3Response> {
+  user: IUserHasId,
+}
+export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.params
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+
+      // check whether accessible
+      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      }
+
+      try {
+        const yjsService = getYjsService();
+        await yjsService.syncWithTheLatestRevisionForce(pageId);
+        return res.apiv3({ });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};