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

Merge branch 'dev/5.0.x' into imprv/81351-81439-pt-smooth-scroll

* dev/5.0.x: (318 commits)
  dix lint error
  support share link
  add menu items for PageAccessoriesModal to GrowiContextualSubNavigation
  open PageAccessoriesModal from hooks
  BugFix of subnavigation
  adjust view height of ToC
  re-impl DisplaySwitcher and ContentLinkButtons
  typescriptize ContentLinkButtons
  re-impl DisplaySwitcher
  fix HashChanged return type
  typescriptize DisplaySwitcher
  re-impl PageAccessoriesModal with SWR
  typescriptize PageAccessoriesModal
  disable contents if isSharedUser is true
  remove unnecessary code
  disable when isSharedUser is true
  Reconsider try-catch
  Improved to forbid insert for v5 pages collection import
  Fixed import
  Fixed import
  ...
Mao 4 лет назад
Родитель
Сommit
bad7d2042c
100 измененных файлов с 4871 добавлено и 3215 удалено
  1. 3 0
      packages/app/resource/locales/en_US/admin/admin.json
  2. 5 2
      packages/app/resource/locales/en_US/translation.json
  3. 3 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  4. 4 2
      packages/app/resource/locales/ja_JP/translation.json
  5. 3 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  6. 4 2
      packages/app/resource/locales/zh_CN/translation.json
  7. 8 8
      packages/app/src/client/app.jsx
  8. 10 0
      packages/app/src/client/base.jsx
  9. 2 3
      packages/app/src/client/services/AdminAppContainer.js
  10. 2 8
      packages/app/src/client/services/ContextExtractor.tsx
  11. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  12. 0 67
      packages/app/src/client/services/PageContainer.js
  13. 62 0
      packages/app/src/client/services/page-operation.ts
  14. 2 2
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  15. 6 1
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  16. 20 14
      packages/app/src/components/BookmarkButtons.tsx
  17. 11 3
      packages/app/src/components/Common/ClosableTextInput.tsx
  18. 210 88
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  19. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  20. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  21. 62 12
      packages/app/src/components/DescendantsPageList.tsx
  22. 100 0
      packages/app/src/components/DescendantsPageListModal.tsx
  23. 3 3
      packages/app/src/components/EventListeneres/HashChanged.tsx
  24. 3 3
      packages/app/src/components/ForbiddenPage.tsx
  25. 2 1
      packages/app/src/components/Icons/AttachmentIcon.jsx
  26. 2 1
      packages/app/src/components/Icons/HistoryIcon.jsx
  27. 1 1
      packages/app/src/components/Icons/ShareLinkIcon.jsx
  28. 42 16
      packages/app/src/components/IdenticalPathPage.tsx
  29. 7 14
      packages/app/src/components/LikeButtons.tsx
  30. 1 1
      packages/app/src/components/Navbar/AuthorInfo.jsx
  31. 243 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  32. 0 158
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  33. 100 0
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  34. 4 2
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  35. 113 72
      packages/app/src/components/Navbar/SubNavButtons.tsx
  36. 14 4
      packages/app/src/components/NotFoundPage.tsx
  37. 0 89
      packages/app/src/components/Page/DisplaySwitcher.jsx
  38. 134 0
      packages/app/src/components/Page/DisplaySwitcher.tsx
  39. 11 22
      packages/app/src/components/Page/PageManagement.jsx
  40. 0 5
      packages/app/src/components/Page/RenderTagLabels.tsx
  41. 20 24
      packages/app/src/components/Page/TagLabels.tsx
  42. 1 0
      packages/app/src/components/Page/TrashPageAlert.jsx
  43. 0 40
      packages/app/src/components/PageAccessories.jsx
  44. 0 160
      packages/app/src/components/PageAccessoriesModal.jsx
  45. 134 0
      packages/app/src/components/PageAccessoriesModal.tsx
  46. 2 8
      packages/app/src/components/PageAccessoriesModalControl.jsx
  47. 18 12
      packages/app/src/components/PageDeleteModal.tsx
  48. 15 15
      packages/app/src/components/PageDuplicateModal.jsx
  49. 1 1
      packages/app/src/components/PageHistory/RevisionDiff.jsx
  50. 6 5
      packages/app/src/components/PageList/PageList.tsx
  51. 47 56
      packages/app/src/components/PageList/PageListItemL.tsx
  52. 9 7
      packages/app/src/components/PagePathNav.tsx
  53. 0 49
      packages/app/src/components/PageReactionButtons.tsx
  54. 16 17
      packages/app/src/components/PageRenameModal.jsx
  55. 2 20
      packages/app/src/components/SearchPage.jsx
  56. 75 11
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  57. 0 85
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  58. 32 6
      packages/app/src/components/SearchPage/SearchResultList.tsx
  59. 2 23
      packages/app/src/components/Sidebar/PageTree.tsx
  60. 123 105
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  61. 35 32
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  62. 9 41
      packages/app/src/components/SubscribeButton.tsx
  63. 2 2
      packages/app/src/components/TableOfContents.jsx
  64. 8 16
      packages/app/src/components/User/SeenUserInfo.tsx
  65. 3 1
      packages/app/src/interfaces/common.ts
  66. 0 8
      packages/app/src/interfaces/page-info.ts
  67. 59 11
      packages/app/src/interfaces/page.ts
  68. 4 0
      packages/app/src/interfaces/revision.ts
  69. 4 3
      packages/app/src/interfaces/search.ts
  70. 6 0
      packages/app/src/interfaces/subscription.ts
  71. 107 0
      packages/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration.js
  72. 85 0
      packages/app/src/migrations/20220131001218-convert-redirect-to-pages-to-page-redirect-documents.js
  73. 4 2
      packages/app/src/server/crowi/index.js
  74. 3 0
      packages/app/src/server/interfaces/mongoose-utils.ts
  75. 25 0
      packages/app/src/server/middlewares/apiv1-form-validator.ts
  76. 19 61
      packages/app/src/server/models/obsolete-page.js
  77. 29 0
      packages/app/src/server/models/page-redirect.ts
  78. 154 50
      packages/app/src/server/models/page.ts
  79. 5 40
      packages/app/src/server/models/revision.js
  80. 8 9
      packages/app/src/server/models/subscription.ts
  81. 14 1
      packages/app/src/server/routes/apiv3/import.js
  82. 12 0
      packages/app/src/server/routes/apiv3/overwrite-params/pages.js
  83. 52 3
      packages/app/src/server/routes/apiv3/page-listing.ts
  84. 51 71
      packages/app/src/server/routes/apiv3/page.js
  85. 45 37
      packages/app/src/server/routes/apiv3/pages.js
  86. 1 1
      packages/app/src/server/routes/apiv3/revisions.js
  87. 3 2
      packages/app/src/server/routes/index.js
  88. 49 39
      packages/app/src/server/routes/page.js
  89. 12 0
      packages/app/src/server/service/import.js
  90. 4 3
      packages/app/src/server/service/in-app-notification.ts
  91. 1 0
      packages/app/src/server/service/installer.ts
  92. 80 35
      packages/app/src/server/service/page-grant.ts
  93. 0 1319
      packages/app/src/server/service/page.js
  94. 2198 0
      packages/app/src/server/service/page.ts
  95. 6 6
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  96. 1 1
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  97. 1 1
      packages/app/src/server/service/search.ts
  98. 4 11
      packages/app/src/server/util/compare-objectId.ts
  99. 1 9
      packages/app/src/server/util/swigFunctions.js
  100. 1 2
      packages/app/src/server/views/layout-growi/identical-path-page.html

+ 3 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -189,6 +189,9 @@
     "beta_warning": "This function is Beta.",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",

+ 5 - 2
packages/app/resource/locales/en_US/translation.json

@@ -179,7 +179,9 @@
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required."
+    "title_required": "Title is required.",
+    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
+
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "Create Page",
     "Create Page": "Create Page",
@@ -973,7 +975,8 @@
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },
   },
   "pagetree": {
   "pagetree": {
-    "private_legacy_pages": "Private Legacy Pages"
+    "private_legacy_pages": "Private Legacy Pages",
+    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'"
   },
   },
   "duplicated_page_alert" : {
   "duplicated_page_alert" : {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",

+ 3 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -207,6 +207,9 @@
     "beta_warning": "この機能はベータ版です",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
+    "error": {
+      "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",

+ 4 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -181,7 +181,8 @@
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください"
+    "title_required": "タイトルを入力してください",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "ページを作成する",
     "Create Page": "ページを作成する",
@@ -966,7 +967,8 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   },
   "pagetree": {
   "pagetree": {
-    "private_legacy_pages": "待避所"
+    "private_legacy_pages": "待避所",
+    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません"
   },
   },
   "duplicated_page_alert" : {
   "duplicated_page_alert" : {
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",

+ 3 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -199,6 +199,9 @@
     "beta_warning": "这个函数是Beta。",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",

+ 4 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -179,7 +179,8 @@
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。"
+    "title_required": "标题是必需的。",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "创建页面",
     "Create Page": "创建页面",
@@ -976,7 +977,8 @@
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   },
   },
   "pagetree": {
   "pagetree": {
-    "private_legacy_pages": "私人遗留页面"
+    "private_legacy_pages": "私人遗留页面",
+    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题"
   },
   },
   "duplicated_page_alert" : {
   "duplicated_page_alert" : {
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",

+ 8 - 8
packages/app/src/client/app.jsx

@@ -39,7 +39,7 @@ import MyDraftList from '../components/MyDraftList/MyDraftList';
 import BookmarkList from '../components/PageList/BookmarkList';
 import BookmarkList from '../components/PageList/BookmarkList';
 import Fab from '../components/Fab';
 import Fab from '../components/Fab';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import PersonalSettings from '../components/Me/PersonalSettings';
-import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
+import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 
 
@@ -51,9 +51,9 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -70,10 +70,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -101,7 +100,7 @@ Object.assign(componentMappings, {
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
 
 
-  'forbidden-page': <ForbiddenPage isSharePage={appContainer.config.disableLinkSharing} />,
+  'forbidden-page': <ForbiddenPage isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
 
 
   'page-timeline': <PageTimeline />,
   'page-timeline': <PageTimeline />,
 
 
@@ -133,7 +132,8 @@ if (pageContainer.state.pageId != null) {
 
 
   // show the Page accessory modal when query of "compare" is requested
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
+  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
   }
   }
 }
 }
 if (pageContainer.state.creator != null) {
 if (pageContainer.state.creator != null) {
@@ -146,8 +146,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     // eslint-disable-next-line quote-props
     'page': <Page />,
     'page': <Page />,
-    'grw-subnav-container': <GrowiSubNavigation />,
-    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'grw-subnav-container': <GrowiContextualSubNavigation isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
     'display-switcher': <DisplaySwitcher />,
     'display-switcher': <DisplaySwitcher />,
   });
   });
 }
 }

+ 10 - 0
packages/app/src/client/base.jsx

@@ -7,9 +7,14 @@ import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import PageCreateModal from '../components/PageCreateModal';
 import PageCreateModal from '../components/PageCreateModal';
+import PageDeleteModal from '../components/PageDeleteModal';
+import PageDuplicateModal from '../components/PageDuplicateModal';
+import PageRenameModal from '../components/PageRenameModal';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -40,6 +45,11 @@ const componentMappings = {
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
 
 
   'page-create-modal': <PageCreateModal />,
   'page-create-modal': <PageCreateModal />,
+  'page-delete-modal': <PageDeleteModal />,
+  'page-duplicate-modal': <PageDuplicateModal />,
+  'page-rename-modal': <PageRenameModal />,
+  'page-accessories-modal': <PageAccessoriesModal />,
+  'descendants-page-list-modal': <DescendantsPageListModal />,
 
 
   'grw-hotkeys-manager': <HotkeysManager />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
 

+ 2 - 3
packages/app/src/client/services/AdminAppContainer.js

@@ -452,10 +452,9 @@ export default class AdminAppContainer extends Container {
   /**
   /**
    * Start v5 page migration
    * Start v5 page migration
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
-   * @property action takes only 'initialMigration' for now. 'initialMigration' will start or resume migration
    */
    */
-  async v5PageMigrationHandler(action) {
-    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration', { action });
+  async v5PageMigrationHandler() {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
     const { isV5Compatible } = response.data;
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
     return { isV5Compatible };
   }
   }

+ 2 - 8
packages/app/src/client/services/ContextExtractor.tsx

@@ -2,8 +2,8 @@ import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
 import {
 import {
-  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
-  useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
+  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
@@ -55,10 +55,7 @@ const ContextExtractorOnce: FC = () => {
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
   const isTrashPage = _isTrashPage(path);
   const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
   const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
-  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
-  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull) ?? false;
-  const isPageExist = mainContent?.getAttribute('data-page-id') != null;
   const isForbidden = forbiddenContent != null;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
@@ -101,11 +98,8 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
   useIsIdenticalPath(isIdenticalPath);
-  useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
-  useIsDeletable(isDeletable);
   useIsDeleted(isDeleted);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsNotCreatable(isNotCreatable);
-  useIsPageExist(isPageExist);
   useIsForbidden(isForbidden);
   useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useIsUserPage(isUserPage);

+ 0 - 54
packages/app/src/client/services/PageAccessoriesContainer.js

@@ -1,54 +0,0 @@
-import { Container } from 'unstated';
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-export default class PageAccessoriesContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['']),
-    };
-    this.openPageAccessoriesModal = this.openPageAccessoriesModal.bind(this);
-    this.closePageAccessoriesModal = this.closePageAccessoriesModal.bind(this);
-    this.switchActiveTab = this.switchActiveTab.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageAccessoriesContainer';
-  }
-
-
-  openPageAccessoriesModal(activeTab) {
-    this.setState({
-      isPageAccessoriesModalShown: true,
-    });
-    this.switchActiveTab(activeTab);
-  }
-
-  closePageAccessoriesModal() {
-    this.setState({
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-    });
-  }
-
-  switchActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-}

+ 0 - 67
packages/app/src/client/services/PageContainer.js

@@ -62,9 +62,7 @@ export default class PageContainer extends Container {
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       isTrashPage: isTrashPage(path),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
-      isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
-      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
 
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
@@ -140,71 +138,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
     return 'PageContainer';
   }
   }
 
 
-
-  /**
-   * whether to display reaction buttons
-   * ex.) like, bookmark
-   */
-  get isAbleToShowPageReactionButtons() {
-    const { isTrashPage, isPageExist } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isTrashPage && isPageExist && !isSharedUser);
-  }
-
-  /**
-   * whether to display tag labels
-   */
-  get isAbleToShowTagLabel() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display page management
-   * ex.) duplicate, rename
-   */
-  get isAbleToShowPageManagement() {
-    const { isPageExist, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (isPageExist && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageEditorModeManager
-   * ex.) view, edit, hackmd
-   */
-  get isAbleToShowPageEditorModeManager() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isNotCreatable && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageAuthors
-   * ex.) creator, lastUpdateUser
-   */
-  get isAbleToShowPageAuthors() {
-    const { isPageExist, isUserPage } = this.state;
-
-    return (isPageExist && !isUserPage);
-  }
-
-  /**
-   * whether to like button
-   * not displayed on user page
-   */
-  get isAbleToShowLikeButtons() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
   /**
   /**
    * whether to Empty Trash Page
    * whether to Empty Trash Page
    * not displayed when guest user and not on trash page
    * not displayed when guest user and not on trash page

+ 62 - 0
packages/app/src/client/services/page-operation.ts

@@ -0,0 +1,62 @@
+import urljoin from 'url-join';
+
+import { SubscriptionStatusType } from '~/interfaces/subscription';
+
+import { toastError } from '../util/apiNotification';
+import { apiv3Put } from '../util/apiv3-client';
+
+export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
+  try {
+    const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
+      ? SubscriptionStatusType.UNSUBSCRIBE
+      : SubscriptionStatusType.SUBSCRIBE;
+
+    await apiv3Put('/page/subscribe', { pageId, status: newStatus });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleLike = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/page/likes', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleBookmark = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const bookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: true });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const unbookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: false });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const exportAsMarkdown = (pageId: string, revisionId: string, format: string): void => {
+  const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+  url.searchParams.append('format', format);
+  url.searchParams.append('revisionId', revisionId);
+  window.location.href = url.href;
+};

+ 2 - 2
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -6,7 +6,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 
 
 type Props = {
 type Props = {
-  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 }
 
 
 const V5PageMigration: FC<Props> = (props: Props) => {
 const V5PageMigration: FC<Props> = (props: Props) => {
@@ -17,7 +17,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   const onConfirm = async() => {
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     setIsV5PageMigrationModalShown(false);
     try {
     try {
-      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
       if (isV5Compatible) {
       if (isV5Compatible) {
 
 
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));

+ 6 - 1
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -287,7 +287,9 @@ class ImportForm extends React.Component {
   }
   }
 
 
   async import() {
   async import() {
-    const { appContainer, fileName, onPostImport } = this.props;
+    const {
+      appContainer, fileName, onPostImport, t,
+    } = this.props;
     const { selectedCollections, optionsMap } = this.state;
     const { selectedCollections, optionsMap } = this.state;
 
 
     // init progress data
     // init progress data
@@ -312,6 +314,9 @@ class ImportForm extends React.Component {
       toastSuccess(undefined, 'Import process has requested.');
       toastSuccess(undefined, 'Import process has requested.');
     }
     }
     catch (err) {
     catch (err) {
+      if (err.code === 'only_upsert_available') {
+        toastError(t('admin:importer_management.error.only_upsert_available'));
+      }
       toastError(err, 'Import request failed.');
       toastError(err, 'Import request failed.');
     }
     }
   }
   }

+ 20 - 14
packages/app/src/components/BookmarkButtons.tsx

@@ -9,16 +9,20 @@ import UserPictureList from './User/UserPictureList';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 
 
 interface Props {
 interface Props {
+  bookmarkCount?: number
+  isBookmarked?: boolean
+  bookmarkedUsers?: IUser[]
   hideTotalNumber?: boolean
   hideTotalNumber?: boolean
-  isBookmarked: boolean
-  sumOfBookmarks: number
-  bookmarkedUsers: IUser[]
   onBookMarkClicked: ()=>void;
   onBookMarkClicked: ()=>void;
 }
 }
 
 
 const BookmarkButtons: FC<Props> = (props: Props) => {
 const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const {
+    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+  } = props;
+
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
@@ -40,9 +44,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         id="bookmark-button"
         id="bookmark-button"
         onClick={handleClick}
         onClick={handleClick}
         className={`btn btn-bookmark border-0
         className={`btn btn-bookmark border-0
-          ${props.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
-        <i className={`fa ${props.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
       </button>
 
 
       {isGuestUser && (
       {isGuestUser && (
@@ -51,18 +55,20 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       )}
       )}
 
 
-      { !props.hideTotalNumber && (
+      { !hideTotalNumber && (
         <>
         <>
           <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
           <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
-            {props.sumOfBookmarks}
+            {bookmarkCount ?? 0}
           </button>
           </button>
-          <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
-            <PopoverBody className="seen-user-popover">
-              <div className="px-2 text-right user-list-content text-truncate text-muted">
-                {props.bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
-              </div>
-            </PopoverBody>
-          </Popover>
+          { bookmarkedUsers != null && (
+            <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+              <PopoverBody className="user-list-popover">
+                <div className="px-2 text-right user-list-content text-truncate text-muted">
+                  {bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
+                </div>
+              </PopoverBody>
+            </Popover>
+          ) }
         </>
         </>
       ) }
       ) }
     </div>
     </div>

+ 11 - 3
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -17,9 +17,10 @@ export type AlertInfo = {
 
 
 type ClosableTextInputProps = {
 type ClosableTextInputProps = {
   isShown: boolean
   isShown: boolean
+  value?: string
   placeholder?: string
   placeholder?: string
   inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
   inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
-  onPressEnter?(): void
+  onPressEnter?(inputText: string | null): void
   onClickOutside?(): void
   onClickOutside?(): void
 }
 }
 
 
@@ -27,14 +28,18 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const { t } = useTranslation();
   const { t } = useTranslation();
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
 
+  const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
 
 
   const onChangeHandler = async(e) => {
   const onChangeHandler = async(e) => {
     if (props.inputValidator == null) { return }
     if (props.inputValidator == null) { return }
 
 
-    const alertInfo = await props.inputValidator(e.target.value);
+    const inputText = e.target.value;
+
+    const alertInfo = await props.inputValidator(inputText);
 
 
     setAlertInfo(alertInfo);
     setAlertInfo(alertInfo);
+    setInputText(inputText);
   };
   };
 
 
   const onPressEnter = () => {
   const onPressEnter = () => {
@@ -42,7 +47,9 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
       return;
       return;
     }
     }
 
 
-    props.onPressEnter();
+    const text = inputText != null ? inputText.trim() : null;
+
+    props.onPressEnter(text);
   };
   };
 
 
   const onKeyDownHandler = (e) => {
   const onKeyDownHandler = (e) => {
@@ -94,6 +101,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   return (
   return (
     <div className={props.isShown ? 'd-block' : 'd-none'}>
     <div className={props.isShown ? 'd-block' : 'd-none'}>
       <input
       <input
+        value={inputText}
         ref={inputRef}
         ref={inputRef}
         type="text"
         type="text"
         className="form-control"
         className="form-control"

+ 210 - 88
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,4 +1,4 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 import {
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -6,132 +6,254 @@ import {
 import toastr from 'toastr';
 import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { IPageHasId } from '~/interfaces/page';
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
-import { useSWRBookmarkInfo } from '~/stores/bookmark';
-
-type PageItemControlProps = {
-  page: Partial<IPageHasId>
-  isEnableActions?: boolean
-  isDeletable: boolean
-  onClickDeleteButtonHandler?: (pageId: string) => void
-  onClickRenameButtonHandler?: (pageId: string) => void
+import loggerFactory from '~/utils/logger';
+
+import {
+  IPageInfoAll, isIPageInfoForOperation,
+} from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+const logger = loggerFactory('growi:cli:PageItemControl');
+
+
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+
+type CommonProps = {
+  pageInfo?: IPageInfoAll,
+  isEnableActions?: boolean,
+  showBookmarkMenuItem?: boolean,
+  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
+  onClickDuplicateMenuItem?: () => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string) => void,
+
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
 }
 
 
-const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
+
+type DropdownMenuProps = CommonProps & {
+  pageId: string,
+  isLoading?: boolean,
+}
+
+const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
+  const { t } = useTranslation('');
 
 
   const {
   const {
-    page, isEnableActions, onClickDeleteButtonHandler, isDeletable, onClickRenameButtonHandler,
+    pageId, isLoading,
+    pageInfo, isEnableActions, showBookmarkMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
   } = props;
-  const { t } = useTranslation('');
-  const [isOpen, setIsOpen] = useState(false);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id, isOpen);
 
 
-  const deleteButtonClickedHandler = useCallback(() => {
-    if (onClickDeleteButtonHandler != null && page._id != null) {
-      onClickDeleteButtonHandler(page._id);
-    }
-  }, [onClickDeleteButtonHandler, page._id]);
 
 
-  const renameButtonClickedHandler = useCallback(() => {
-    if (onClickRenameButtonHandler != null && page._id != null) {
-      onClickRenameButtonHandler(page._id);
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const bookmarkItemClickedHandler = useCallback(async() => {
+    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+      return;
     }
     }
-  }, [onClickRenameButtonHandler, page._id]);
-
+    await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
+  }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
 
-  const bookmarkToggleHandler = (async() => {
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/bookmarks', { pageId: page._id, bool: !bookmarkInfo!.isBookmarked });
-      mutateBookmarkInfo();
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const duplicateItemClickedHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
     }
     }
-    catch (err) {
-      toastError(err);
-    }
-  });
+    await onClickDuplicateMenuItem();
+  }, [onClickDuplicateMenuItem]);
 
 
-  const renderBookmarkText = () => {
-    if (bookmarkInfoError != null || bookmarkInfo == null) {
-      return '';
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const renameItemClickedHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
     }
     }
-    return bookmarkInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark');
-  };
-
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
 
 
-  const dropdownToggle = () => {
-    setIsOpen(!isOpen);
-  };
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const deleteItemClickedHandler = useCallback(async() => {
+    if (pageInfo == null || onClickDeleteMenuItem == null) {
+      return;
+    }
+    if (!pageInfo.isDeletable) {
+      logger.warn('This page could not be deleted.');
+      return;
+    }
+    await onClickDeleteMenuItem(pageId);
+  }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
 
+  let contents = <></>;
 
 
-  return (
-    <Dropdown isOpen={isOpen} toggle={dropdownToggle}>
-      <DropdownToggle color="transparent" className="btn-link border-0 rounded grw-btn-page-management p-0">
-        <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
-      </DropdownToggle>
-      <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
-
-        {/* TODO: if there is the following button in XD add it here
-        <button
-          type="button"
-          className="btn btn-link p-0"
-          value={page.path}
-          onClick={(e) => {
-            window.location.href = e.currentTarget.value;
-          }}
-        >
-          <i className="icon-login" />
-        </button>
-        */}
-
-        {/*
-          TODO: add function to the following buttons like using modal or others
-          ref: https://estoc.weseek.co.jp/redmine/issues/79026
-        */}
-
-        {/* TODO: show dropdown when permalink section is implemented */}
-
-        {!isEnableActions && (
+  if (isLoading) {
+    contents = (
+      <div className="text-muted text-center my-2">
+        <i className="fa fa-spinner fa-pulse"></i>
+      </div>
+    );
+  }
+  else if (pageId != null && pageInfo != null) {
+    contents = (
+      <>
+        { !isEnableActions && (
           <DropdownItem>
           <DropdownItem>
             <p>
             <p>
               {t('search_result.currently_not_implemented')}
               {t('search_result.currently_not_implemented')}
             </p>
             </p>
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={bookmarkToggleHandler}>
+        ) }
+
+        {/* Bookmark */}
+        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+          <DropdownItem onClick={bookmarkItemClickedHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
             <i className="fa fa-fw fa-bookmark-o"></i>
-            {renderBookmarkText()}
+            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+        ) }
+
+        {/* Duplicate */}
+        { isEnableActions && !pageInfo.isEmpty && (
+          <DropdownItem onClick={duplicateItemClickedHandler}>
             <i className="icon-fw icon-docs"></i>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
             {t('Duplicate')}
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={renameButtonClickedHandler}>
+        ) }
+
+        {/* Move/Rename */}
+        { isEnableActions && pageInfo.isMovable && (
+          <DropdownItem onClick={renameItemClickedHandler}>
             <i className="icon-fw  icon-action-redo"></i>
             <i className="icon-fw  icon-action-redo"></i>
             {t('Move/Rename')}
             {t('Move/Rename')}
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isDeletable && isEnableActions && (
+        ) }
+
+        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+
+        {/* divider */}
+        {/* Delete */}
+        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
           <>
           <>
             <DropdownItem divider />
             <DropdownItem divider />
-            <DropdownItem className="text-danger pt-2" onClick={deleteButtonClickedHandler}>
+            <DropdownItem
+              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              disabled={!pageInfo.isDeletable}
+              onClick={deleteItemClickedHandler}
+            >
               <i className="icon-fw icon-trash"></i>
               <i className="icon-fw icon-trash"></i>
               {t('Delete')}
               {t('Delete')}
             </DropdownItem>
             </DropdownItem>
           </>
           </>
         )}
         )}
-      </DropdownMenu>
+      </>
+    );
+  }
+
+  return (
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+      {contents}
+    </DropdownMenu>
+  );
+});
 
 
 
 
+type PageItemControlSubstanceProps = CommonProps & {
+  pageId: string,
+  fetchOnInit?: boolean,
+  children?: React.ReactNode,
+}
+
+export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
+
+  const {
+    pageId, pageInfo: presetPageInfo, fetchOnInit,
+    children,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem,
+  } = props;
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
+  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+
+  // mutate after handle event
+  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
+    if (onClickBookmarkMenuItem != null) {
+      await onClickBookmarkMenuItem(_pageId, _newValue);
+    }
+
+    if (shouldMutate) {
+      mutatePageInfo();
+    }
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+
+  const isLoading = shouldFetch && fetchedPageInfo == null;
+
+  const duplicateMenuItemClickHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem();
+  }, [onClickDuplicateMenuItem]);
+
+  const renameMenuItemClickHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
+
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
+          <i className="icon-options text-muted"></i>
+        </DropdownToggle>
+      ) }
+
+      <PageItemControlDropdownMenu
+        {...props}
+        isLoading={isLoading}
+        pageInfo={fetchedPageInfo ?? presetPageInfo}
+        onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+        onClickRenameMenuItem={renameMenuItemClickHandler}
+      />
     </Dropdown>
     </Dropdown>
   );
   );
 
 
 };
 };
 
 
-export default PageItemControl;
+
+type PageItemControlProps = CommonProps & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} {...props} />;
+};
+
+
+type AsyncPageItemControlProps = Omit<CommonProps, 'pageInfo'> & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const AsyncPageItemControl = (props: AsyncPageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} fetchOnInit {...props} />;
+};

+ 0 - 96
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,96 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { pagePathUtils } from '@growi/core';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-
-const { isTopPage } = pagePathUtils;
-
-const WIKI_HEADER_LINK = 120;
-
-/**
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- */
-const ContentLinkButtons = (props) => {
-
-  const { appContainer, pageContainer } = props;
-  const { pageUser, path } = pageContainer.state;
-  const { isPageExist } = pageContainer.state;
-  const { isSharedUser } = appContainer;
-
-  const isTopPagePath = isTopPage(path);
-
-  // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const CommentLinkButton = () => {
-    return (
-      <div className="mt-3">
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-        >
-          <i className="mr-2 icon-fw icon-bubbles"></i>
-          <span>Comments</span>
-        </button>
-      </div>
-    );
-  };
-
-  const BookMarkLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="fa fa-fw fa-bookmark-o"></i>
-        <span>Bookmarks</span>
-      </button>
-
-    );
-  };
-
-  const RecentlyCreatedLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-
-    );
-  };
-
-  return (
-    <>
-      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
-
-      <div className="mt-3 d-flex justify-content-between">
-        {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
-      </div>
-    </>
-  );
-
-};
-
-ContentLinkButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 66 - 0
packages/app/src/components/ContentLinkButtons.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback, useMemo } from 'react';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageUser } from '~/stores/context';
+
+const WIKI_HEADER_LINK = 120;
+
+
+const ContentLinkButtons = (): JSX.Element => {
+
+  const { data: pageUser } = usePageUser();
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+
+  const BookMarkLinkButton = useCallback((): JSX.Element => {
+    if (getBookMarkListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    );
+  }, [getBookMarkListHeaderDom]);
+
+  const RecentlyCreatedLinkButton = useCallback(() => {
+    if (getRecentlyCreatedListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    );
+  }, [getRecentlyCreatedListHeaderDom]);
+
+  if (pageUser == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="mt-3 d-flex justify-content-between">
+      <BookMarkLinkButton />
+      <RecentlyCreatedLinkButton />
+    </div>
+  );
+
+};
+
+export default ContentLinkButtons;

+ 62 - 12
packages/app/src/components/DescendantsPageList.tsx

@@ -1,6 +1,11 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
+import {
+  IPageHasId, IPageWithMeta,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 
 
-import { useSWRxPageList } from '~/stores/page';
+import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 
 
 import PageList from './PageList/PageList';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
@@ -9,12 +14,55 @@ type Props = {
   path: string,
   path: string,
 }
 }
 
 
+
+const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
+  return { pageData: page };
+};
+
 const DescendantsPageList = (props: Props): JSX.Element => {
 const DescendantsPageList = (props: Props): JSX.Element => {
   const { path } = props;
   const { path } = props;
 
 
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
 
 
-  const { data, error } = useSWRxPageList(path, activePage);
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: pagingResult, error } = useSWRxPageList(isSharedUser ? null : path, activePage);
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
+
+  let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
+
+  // initial data
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    // convert without meta at first
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pages.map(page => convertToIPageWithEmptyMeta(page)),
+    };
+  }
+
+  // inject data for listing
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    const pageWithMetas = pages.map((page) => {
+      const pageInfo = (idToPageInfo ?? {})[page._id];
+
+      return {
+        pageData: page,
+        pageMeta: pageInfo,
+      } as IPageWithMeta;
+    });
+
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pageWithMetas,
+    };
+  }
 
 
   function setPageNumber(selectedPageNumber) {
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
     setActivePage(selectedPageNumber);
@@ -28,7 +76,7 @@ const DescendantsPageList = (props: Props): JSX.Element => {
     );
     );
   }
   }
 
 
-  if (data === undefined) {
+  if (pagingResult == null || pagingResultWithMeta == null) {
     return (
     return (
       <div className="wiki">
       <div className="wiki">
         <div className="text-muted text-center">
         <div className="text-muted text-center">
@@ -40,15 +88,17 @@ const DescendantsPageList = (props: Props): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
-      <PageList pages={data} />
-
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={setPageNumber}
-        totalItemsCount={data.totalCount}
-        pagingLimit={data.limit}
-        align="center"
-      />
+      <PageList pages={pagingResultWithMeta} isEnableActions={!isGuestUser} />
+
+      <div className="my-4">
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={setPageNumber}
+          totalItemsCount={pagingResult.totalCount}
+          pagingLimit={pagingResult.limit}
+          align="center"
+        />
+      </div>
     </>
     </>
   );
   );
 };
 };

+ 100 - 0
packages/app/src/components/DescendantsPageListModal.tsx

@@ -0,0 +1,100 @@
+
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useIsSharedUser } from '~/stores/context';
+
+import DescendantsPageList from './DescendantsPageList';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import PageTimeline from './PageTimeline';
+
+
+type Props = {
+}
+
+export const DescendantsPageListModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: status, close } = useDescendantsPageListModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: () => {
+          if (status == null || status.path == null || !status.isOpened) {
+            return <></>;
+          }
+          return <DescendantsPageList path={status.path} />;
+        },
+        i18n: t('page_list'),
+        index: 0,
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: TimeLineIcon,
+        Content: () => <PageTimeline />,
+        i18n: t('Timeline View'),
+        index: 1,
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, status, t]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={v => setActiveTab(v)}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody>
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+
+};

+ 3 - 3
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,4 +1,4 @@
-import { FC, useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
 import { useIsEditable } from '~/stores/context';
@@ -6,7 +6,7 @@ import { useIsEditable } from '~/stores/context';
 /**
 /**
  * Change editorMode by browser forward/back operation
  * Change editorMode by browser forward/back operation
  */
  */
-const HashChanged: FC<void> = () => {
+const HashChanged = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
 
@@ -33,7 +33,7 @@ const HashChanged: FC<void> = () => {
 
 
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
 
 
-  return null;
+  return <></>;
 };
 };
 
 
 export default HashChanged;
 export default HashChanged;

+ 3 - 3
packages/app/src/components/ForbiddenPage.tsx

@@ -7,7 +7,7 @@ import DescendantsPageList from './DescendantsPageList';
 
 
 
 
 type Props = {
 type Props = {
-  isSharePage?: boolean,
+  isLinkSharingDisabled?: boolean,
 }
 }
 
 
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
@@ -39,12 +39,12 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
         <div className="col-sm-12">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            { props.isSharePage ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
+            { props.isLinkSharingDisabled ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
           </p>
         </div>
         </div>
       </div>
       </div>
 
 
-      { !props.isSharePage && (
+      { !props.isLinkSharingDisabled && (
         <div className="mt-5">
         <div className="mt-5">
           <CustomNavAndContents navTabMapping={navTabMapping} />
           <CustomNavAndContents navTabMapping={navTabMapping} />
         </div>
         </div>

+ 2 - 1
packages/app/src/components/Icons/AttachmentIcon.jsx

@@ -4,7 +4,8 @@ const Attachment = () => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
   >
     <rect width="14" height="14" fillOpacity="0" />
     <rect width="14" height="14" fillOpacity="0" />
     <g className="cls-1">
     <g className="cls-1">

+ 2 - 1
packages/app/src/components/Icons/HistoryIcon.jsx

@@ -4,7 +4,8 @@ const RecentChanges = () => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
   >
     <rect width="14" height="14" fillOpacity="0" />
     <rect width="14" height="14" fillOpacity="0" />
     <path
     <path

+ 1 - 1
packages/app/src/components/Icons/ShareLinkIcon.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
 const ShareLink = () => (
 const ShareLink = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 20 20">
     <g transform="translate(-142 -502)">
     <g transform="translate(-142 -502)">
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <g transform="translate(16 286.938)">
       <g transform="translate(16 286.938)">

+ 42 - 16
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,12 +1,14 @@
-import React, {
-  FC,
-} from 'react';
+import React, { FC } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 
 
-import { useCurrentPagePath } from '~/stores/context';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useSWRxPageInfoForList } from '~/stores/page';
 
 
+import PageListIcon from './Icons/PageListIcon';
 import { PageListItemL } from './PageList/PageListItemL';
 import { PageListItemL } from './PageList/PageListItemL';
 
 
 
 
@@ -34,7 +36,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
       <p>
       <p>
         {t('duplicated_page_alert.same_page_name_exists_at_path',
         {t('duplicated_page_alert.same_page_name_exists_at_path',
           { path: _path, pageName: _pageName })}<br />
           { path: _path, pageName: _pageName })}<br />
-        <p
+        <span
           // eslint-disable-next-line react/no-danger
           // eslint-disable-next-line react/no-danger
           dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
           dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
         />
         />
@@ -53,21 +55,37 @@ type IdenticalPathPageProps= {
 const jsonNull = 'null';
 const jsonNull = 'null';
 
 
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+  const { t } = useTranslation();
 
 
   const identicalPageDocument = document.getElementById('identical-path-page');
   const identicalPageDocument = document.getElementById('identical-path-page');
-  const pageDataList = JSON.parse(identicalPageDocument?.getAttribute('data-identical-page-data-list') || jsonNull);
-  const shortbodyMap = JSON.parse(identicalPageDocument?.getAttribute('data-shortody-map') || jsonNull);
+  const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
+
+  const pageIds = pages.map(page => page._id) as string[];
+
 
 
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
 
   return (
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
     <div className="d-flex flex-column flex-lg-row-reverse">
 
 
       <div className="grw-side-contents-container">
       <div className="grw-side-contents-container">
-        <div className="grw-side-contents-sticky-container">
-          <div className="border-bottom pb-1">
-            {/* <PageAccessories isNotFoundPage={!isPageExist} /> */}
-          </div>
+        <div className="grw-page-accessories-control pb-1">
+          { currentPath != null && !isSharedUser && (
+            <button
+              type="button"
+              className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between"
+              onClick={() => openDescendantPageListModal(currentPath)}
+            >
+              <PageListIcon />
+              {t('page_list')}
+              <span></span> {/* for a count badge */}
+            </button>
+          ) }
         </div>
         </div>
       </div>
       </div>
 
 
@@ -76,16 +94,24 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
         <IdenticalPathAlert path={currentPath} />
         <IdenticalPathAlert path={currentPath} />
 
 
         <div className="page-list">
         <div className="page-list">
-          <ul className="page-list-ul list-group-flush border px-3">
-            {pageDataList.map((data) => {
+          <ul className="page-list-ul list-group list-group-flush">
+            {pages.map((page) => {
+              const pageId = page._id;
+              const pageInfo = (idToPageInfoMap ?? {})[pageId];
+
+              const pageWithMeta: IPageWithMeta = {
+                pageData: page,
+                pageMeta: pageInfo,
+              };
+
               return (
               return (
                 <PageListItemL
                 <PageListItemL
-                  key={data.pageData._id}
-                  page={data}
+                  key={pageId}
+                  page={pageWithMeta}
                   isSelected={false}
                   isSelected={false}
                   isChecked={false}
                   isChecked={false}
                   isEnableActions
                   isEnableActions
-                  shortBody={shortbodyMap[data.pageData._id]}
+                  showPageUpdatedTime
                 // Todo: add onClickDeleteButton when delete feature implemented
                 // Todo: add onClickDeleteButton when delete feature implemented
                 />
                 />
               );
               );

+ 7 - 14
packages/app/src/components/LikeButtons.tsx

@@ -9,12 +9,13 @@ import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
 
 
 type LikeButtonsProps = {
 type LikeButtonsProps = {
-  appContainer: AppContainer,
 
 
   hideTotalNumber?: boolean,
   hideTotalNumber?: boolean,
   sumOfLikers: number,
   sumOfLikers: number,
-  isLiked: boolean,
   likers: IUser[],
   likers: IUser[],
+
+  isGuestUser?: boolean,
+  isLiked?: boolean,
   onLikeClicked?: ()=>void,
   onLikeClicked?: ()=>void,
 }
 }
 
 
@@ -27,30 +28,22 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
     setIsPopoverOpen(!isPopoverOpen);
   };
   };
 
 
-  const handleClick = () => {
-    if (props.onLikeClicked == null) {
-      return;
-    }
-    props.onLikeClicked();
-  };
-
   const {
   const {
-    appContainer, hideTotalNumber, isLiked, sumOfLikers,
+    hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
   } = props;
   } = props;
-  const { isGuestUser } = appContainer;
 
 
   return (
   return (
     <div className="btn-group" role="group" aria-label="Like buttons">
     <div className="btn-group" role="group" aria-label="Like buttons">
       <button
       <button
         type="button"
         type="button"
         id="like-button"
         id="like-button"
-        onClick={handleClick}
+        onClick={onLikeClicked}
         className={`btn btn-like border-0
         className={`btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
       </button>
-      {isGuestUser && (
+      { isGuestUser && (
         <UncontrolledTooltip placement="top" target="like-button" fade={false}>
         <UncontrolledTooltip placement="top" target="like-button" fade={false}>
           {t('Not available for guest')}
           {t('Not available for guest')}
         </UncontrolledTooltip>
         </UncontrolledTooltip>
@@ -62,7 +55,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
             {sumOfLikers}
             {sumOfLikers}
           </button>
           </button>
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-            <PopoverBody className="seen-user-popover">
+            <PopoverBody className="user-list-popover">
               <div className="px-2 text-right user-list-content text-truncate text-muted">
               <div className="px-2 text-right user-list-content text-truncate text-muted">
                 {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
                 {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
               </div>
               </div>

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -34,7 +34,7 @@ const AuthorInfo = (props) => {
       if (err instanceof RangeError) {
       if (err instanceof RangeError) {
         return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
         return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
       }
       }
-      return;
+      return <></>;
     }
     }
   }
   }
 
 

+ 243 - 0
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -0,0 +1,243 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { DropdownItem } from 'reactstrap';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import EditorContainer from '~/client/services/EditorContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
+} from '~/stores/ui';
+import {
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
+import { useSWRTagsInfo } from '~/stores/page';
+
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import { IPageHasId } from '~/interfaces/page';
+
+import HistoryIcon from '../Icons/HistoryIcon';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { SubNavButtons } from './SubNavButtons';
+import PageEditorModeManager from './PageEditorModeManager';
+import { GrowiSubNavigation } from './GrowiSubNavigation';
+import PresentationIcon from '../Icons/PresentationIcon';
+import { exportAsMarkdown } from '~/client/services/page-operation';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+  isLinkSharingDisabled?: boolean,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { open } = usePageAccessoriesModal();
+
+  return (
+    <>
+      {/* Presentation */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
+        <i className="icon-fw"><PresentationIcon /></i>
+        { t('Presentation Mode') }
+      </DropdownItem>
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/*
+        TODO: show Tooltip when menu is disabled
+        refs: PageAccessoriesModalControl
+      */}
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.PageHistory)}
+        disabled={isGuestUser || isSharedUser}
+      >
+        <span className="mr-1"><HistoryIcon /></span>
+        {t('History')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.Attachment)}
+      >
+        <span className="mr-1"><AttachmentIcon /></span>
+        {t('attachment_data')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.ShareLink)}
+        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+      >
+        <span className="mr-1"><ShareLinkIcon /></span>
+        {t('share_links.share_link_management')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/* Create template */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
+        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      </DropdownItem>
+    </>
+  );
+};
+
+
+const GrowiContextualSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: createdAt } = useCurrentCreatedAt();
+  const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: pageId } = useCurrentPageId();
+  const { data: revisionId } = useRevisionId();
+  const { data: path } = useCurrentPagePath();
+  const { data: creator } = useCreator();
+  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
+  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
+
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
+
+  const {
+    editorContainer, isCompactMode, isLinkSharingDisabled,
+  } = props;
+
+  const isViewMode = editorMode === EditorMode.View;
+
+  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === EditorMode.Editor) {
+      return editorContainer.setState({ tags: newTags });
+    }
+
+    try {
+      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags }) as { tags };
+
+      // revalidate SWRTagsInfo
+      mutateSWRTagsInfo();
+      // update editorContainer.state
+      editorContainer.setState({ tags });
+
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pageId]);
+
+  const ControlComponents = useCallback(() => {
+    function onPageEditorModeButtonClicked(viewType) {
+      mutateEditorMode(viewType);
+    }
+
+    return (
+      <>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          { pageId != null && isViewMode && (
+            <SubNavButtons
+              isCompactMode={isCompactMode}
+              pageId={pageId}
+              shareLinkId={shareLinkId}
+              revisionId={revisionId}
+              disableSeenUserInfoPopover={isSharedUser}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={props => (
+                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+              )}
+            />
+          ) }
+        </div>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          {isAbleToShowPageEditorModeManager && (
+            <PageEditorModeManager
+              onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+              isBtnDisabled={isGuestUser}
+              editorMode={editorMode}
+              isDeviceSmallerThanMd={isDeviceSmallerThanMd}
+            />
+          )}
+        </div>
+      </>
+    );
+  }, [
+    pageId, revisionId,
+    editorMode, mutateEditorMode,
+    isCompactMode, isLinkSharingDisabled,
+    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
+  ]);
+
+
+  if (path == null) {
+    return <></>;
+  }
+
+  const currentPage: Partial<IPageHasId> = {
+    _id: pageId ?? undefined,
+    path,
+    revision: revisionId ?? undefined,
+    creator: creator ?? undefined,
+    lastUpdateUser: revisionAuthor,
+    createdAt: createdAt ?? undefined,
+    updatedAt: updatedAt ?? undefined,
+  };
+
+
+  return (
+    <GrowiSubNavigation
+      page={currentPage}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      showPageAuthors={isAbleToShowPageAuthors}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={tagsInfoData?.tags || []}
+      tagsUpdatedHandler={tagsUpdatedHandler}
+      controls={ControlComponents}
+    />
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiContextualSubNavigationWrapper = withUnstatedContainers(GrowiContextualSubNavigation, [EditorContainer]);
+
+
+GrowiContextualSubNavigation.propTypes = {
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
+  isLinkSharingDisabled: PropTypes.bool,
+};
+
+export default GrowiContextualSubNavigationWrapper;

+ 0 - 158
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,158 +0,0 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import EditorContainer from '~/client/services/EditorContainer';
-import {
-  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
-} from '~/stores/ui';
-import {
-  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath, useIsDeletable,
-  useIsAbleToDeleteCompletely, useCreator, useRevisionAuthor, useIsPageExist, useIsGuestUser,
-} from '~/stores/context';
-import { useSWRTagsInfo } from '~/stores/page';
-
-import TagLabels from '../Page/TagLabels';
-import SubNavButtons from './SubNavButtons';
-import PageEditorModeManager from './PageEditorModeManager';
-
-import AuthorInfo from './AuthorInfo';
-import DrawerToggler from './DrawerToggler';
-
-import PagePathNav from '../PagePathNav';
-
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiPost } from '~/client/util/apiv1-client';
-
-const GrowiSubNavigation = (props) => {
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { data: isDrawerMode } = useDrawerMode();
-  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-  const { data: pageId } = useCurrentPageId();
-  const { data: revisionId } = useRevisionId();
-  const { data: path } = useCurrentPagePath();
-  const { data: isDeletable } = useIsDeletable();
-  const { data: isAbleToDeleteCompletely } = useIsAbleToDeleteCompletely();
-  const { data: creator } = useCreator();
-  const { data: revisionAuthor } = useRevisionAuthor();
-  const { data: isGuestUser } = useIsGuestUser();
-
-  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
-  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
-  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
-  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
-
-  const { mutate: mutateSWRTagsInfo, data: TagsInfoData } = useSWRTagsInfo(pageId);
-
-  const {
-    editorContainer, isCompactMode,
-  } = props;
-
-  const isViewMode = editorMode === EditorMode.View;
-  const isEditorMode = !isViewMode;
-
-  function onPageEditorModeButtonClicked(viewType) {
-    mutateEditorMode(viewType);
-  }
-
-  const tagsUpdatedHandler = useCallback(async(newTags) => {
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === 'edit') {
-      return editorContainer.setState({ tags: newTags });
-    }
-
-    try {
-      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
-
-      // revalidate SWRTagsInfo
-      mutateSWRTagsInfo();
-      // update editorContainer.state
-      editorContainer.setState({ tags });
-
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [pageId]);
-
-  return (
-    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
-
-      {/* Left side */}
-      <div className="d-flex grw-subnav-left-side">
-        { isDrawerMode && (
-          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
-            <DrawerToggler />
-          </div>
-        ) }
-
-        <div className="grw-path-nav-container">
-          { isAbleToShowTagLabel && !isCompactMode && (
-            <div className="grw-taglabels-container">
-              <TagLabels tags={TagsInfoData?.tags || []} tagsUpdateInvoked={tagsUpdatedHandler} />
-            </div>
-          ) }
-          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
-        </div>
-      </div>
-
-      {/* Right side */}
-      <div className="d-flex">
-
-        <div className="d-flex flex-column align-items-end">
-          <SubNavButtons
-            isCompactMode={isCompactMode}
-            pageId={pageId}
-            revisionId={revisionId}
-            path={path}
-            isDeletable={isDeletable}
-            isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-            isViewMode={isViewMode}
-            isAbleToShowPageManagement={isAbleToShowPageManagement}
-          />
-          <div className="mt-2">
-            {isAbleToShowPageEditorModeManager && (
-              <PageEditorModeManager
-                onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
-                isBtnDisabled={isGuestUser}
-                editorMode={editorMode}
-                isDeviceSmallerThanMd={isDeviceSmallerThanMd}
-              />
-            )}
-          </div>
-        </div>
-
-        {/* Page Authors */}
-        { (isAbleToShowPageAuthors && !isCompactMode) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
-            <li className="pb-1">
-              <AuthorInfo user={creator} date={createdAt} locate="subnav" />
-            </li>
-            <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="subnav" />
-            </li>
-          </ul>
-        ) }
-      </div>
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [EditorContainer]);
-
-
-GrowiSubNavigation.propTypes = {
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  isCompactMode: PropTypes.bool,
-};
-
-export default GrowiSubNavigationWrapper;

+ 100 - 0
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -0,0 +1,100 @@
+import React from 'react';
+
+import { IPageHasId } from '~/interfaces/page';
+
+import {
+  EditorMode, useEditorMode,
+} from '~/stores/ui';
+
+import TagLabels from '../Page/TagLabels';
+
+import AuthorInfo from './AuthorInfo';
+import DrawerToggler from './DrawerToggler';
+
+import PagePathNav from '../PagePathNav';
+import { IUser } from '~/interfaces/user';
+
+
+type Props = {
+  page: Partial<IPageHasId>,
+
+  showDrawerToggler?: boolean,
+  showTagLabel?: boolean,
+  showPageAuthors?: boolean,
+
+  isGuestUser?: boolean,
+  isDrawerMode?: boolean,
+  isCompactMode?: boolean,
+
+  tags?: string[],
+  tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
+
+  controls?: React.FunctionComponent,
+}
+
+export const GrowiSubNavigation = (props: Props): JSX.Element => {
+  const { data: editorMode } = useEditorMode();
+
+  const {
+    page,
+    showDrawerToggler, showTagLabel, showPageAuthors,
+    isGuestUser, isDrawerMode, isCompactMode,
+    tags, tagsUpdatedHandler,
+    controls: Controls,
+  } = props;
+
+  const {
+    _id: pageId, path, creator, lastUpdateUser,
+    createdAt, updatedAt,
+  } = page;
+
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
+  if (path == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+
+      {/* Left side */}
+      <div className="d-flex grw-subnav-left-side">
+        { showDrawerToggler && isDrawerMode && (
+          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
+            <DrawerToggler />
+          </div>
+        ) }
+
+        <div className="grw-path-nav-container">
+          { showTagLabel && !isCompactMode && (
+            <div className="grw-taglabels-container">
+              <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+            </div>
+          ) }
+          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+        </div>
+      </div>
+
+      {/* Right side */}
+      <div className="d-flex">
+
+        <div>
+          { Controls && <Controls></Controls> }
+        </div>
+
+        {/* Page Authors */}
+        { (showPageAuthors && !isCompactMode) && (
+          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
+            <li className="pb-1">
+              <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
+            </li>
+            <li className="mt-1 pt-1 border-top">
+              <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
+            </li>
+          </ul>
+        ) }
+      </div>
+    </div>
+  );
+};

+ 4 - 2
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   useMemo, useState, useRef, useEffect, useCallback,
   useMemo, useState, useRef, useEffect, useCallback,
 } from 'react';
 } from 'react';
+import PropTypes from 'prop-types';
 
 
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
@@ -8,7 +9,7 @@ import { debounce } from 'throttle-debounce';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { useSidebarCollapsed } from '~/stores/ui';
 import { useSidebarCollapsed } from '~/stores/ui';
 
 
-import GrowiSubNavigation from './GrowiSubNavigation';
+import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
 
 
 const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
 const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
 
 
@@ -110,13 +111,14 @@ const GrowiSubNavigationSwitcher = (props) => {
   return (
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
       <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
       <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
-        <GrowiSubNavigation isCompactMode />
+        <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
       </div>
       </div>
     </div>
     </div>
   );
   );
 };
 };
 
 
 GrowiSubNavigationSwitcher.propTypes = {
 GrowiSubNavigationSwitcher.propTypes = {
+  isLinkSharingDisabled: PropTypes.bool,
 };
 };
 
 
 export default GrowiSubNavigationSwitcher;
 export default GrowiSubNavigationSwitcher;

+ 113 - 72
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,112 +1,153 @@
-import React, {
-  FC, useCallback,
-} from 'react';
+import React, { useCallback } from 'react';
 
 
-import SubscribeButton from '../SubscribeButton';
-import PageReactionButtons from '../PageReactionButtons';
-import PageManagement from '../Page/PageManagement';
-import { useSWRPageInfo } from '../../stores/page';
+import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
+
+import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
-import { toastError } from '../../client/util/apiNotification';
-import { apiv3Put } from '../../client/util/apiv3-client';
-import { useSWRxLikerList } from '../../stores/user';
+import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 
 
-type SubNavButtonsProps= {
+import SubscribeButton from '../SubscribeButton';
+import LikeButtons from '../LikeButtons';
+import BookmarkButtons from '../BookmarkButtons';
+import SeenUserInfo from '../User/SeenUserInfo';
+import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
+import { AdditionalMenuItemsRendererProps, PageItemControl } from '../Common/Dropdown/PageItemControl';
+
+
+type CommonProps = {
   isCompactMode?: boolean,
   isCompactMode?: boolean,
+  disableSeenUserInfoPopover?: boolean,
+  showPageControlDropdown?: boolean,
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+}
+
+type SubNavButtonsSubstanceProps= CommonProps & {
   pageId: string,
   pageId: string,
+  shareLinkId?: string | null,
   revisionId: string,
   revisionId: string,
-  path: string,
-  isViewMode: boolean
-  isAbleToShowPageManagement: boolean,
-  isDeletable: boolean,
-  isAbleToDeleteCompletely: boolean,
+  pageInfo: IPageInfoAll,
 }
 }
-const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
+
+const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
   const {
-    isCompactMode, pageId, revisionId, path, isViewMode, isAbleToShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+    pageInfo,
+    pageId, shareLinkId,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
   } = props;
   } = props;
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
-  const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
-  const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId, true);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
 
-  const likeClickhandler = useCallback(async() => {
+  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+
+  const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
+  const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
+
+  // Put in a mixture of seenUserIds and likerIds data to make the cache work
+  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
+  const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
+  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+
+  const subscribeClickhandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
     if (isGuestUser == null || isGuestUser) {
       return;
       return;
     }
     }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
 
 
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/page/likes', { pageId, bool: !pageInfo!.isLiked });
-      mutatePageInfo();
+    await toggleSubscribe(pageId, pageInfo.subscriptionStatus);
+    mutatePageInfo();
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const likeClickhandler = useCallback(async() => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
     }
     }
-    catch (err) {
-      toastError(err);
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
     }
     }
+
+    await toggleLike(pageId, pageInfo.isLiked);
+    mutatePageInfo();
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
 
   const bookmarkClickHandler = useCallback(async() => {
   const bookmarkClickHandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
     if (isGuestUser == null || isGuestUser) {
       return;
       return;
     }
     }
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/bookmarks', { pageId, bool: !bookmarkInfo!.isBookmarked });
-      mutateBookmarkInfo();
-    }
-    catch (err) {
-      toastError(err);
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
     }
     }
-  }, [bookmarkInfo, isGuestUser, mutateBookmarkInfo, pageId]);
 
 
-  if (pageInfoError != null || pageInfo == null) {
-    return <></>;
-  }
+    await toggleBookmark(pageId, pageInfo.isBookmarked);
+    mutatePageInfo();
+    mutateBookmarkInfo();
+  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
 
 
-  if (bookmarkInfoError != null || bookmarkInfo == null) {
+  if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
     return <></>;
   }
   }
 
 
-  const { sumOfLikers, isLiked } = pageInfo;
-  const { sumOfBookmarks, isBookmarked, bookmarkedUsers } = bookmarkInfo;
+  const {
+    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+  } = pageInfo;
 
 
   return (
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
     <div className="d-flex" style={{ gap: '2px' }}>
-      {isViewMode && (
-        <>
-          <span>
-            <SubscribeButton pageId={props.pageId} />
-          </span>
-          <PageReactionButtons
-            isCompactMode={isCompactMode}
-            sumOfLikers={sumOfLikers}
-            isLiked={isLiked}
-            likers={likers || []}
-            onLikeClicked={likeClickhandler}
-            sumOfBookmarks={sumOfBookmarks}
-            isBookmarked={isBookmarked}
-            bookmarkedUsers={bookmarkedUsers}
-            onBookMarkClicked={bookmarkClickHandler}
-          >
-          </PageReactionButtons>
-          { isAbleToShowPageManagement && (
-            <PageManagement
-              pageId={pageId}
-              revisionId={revisionId}
-              path={path}
-              isCompactMode={isCompactMode}
-              isDeletable={isDeletable}
-              isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-            >
-            </PageManagement>
-          )}
-        </>
+      <span>
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
+      </span>
+      <LikeButtons
+        hideTotalNumber={isCompactMode}
+        onLikeClicked={likeClickhandler}
+        sumOfLikers={sumOfLikers}
+        isLiked={isLiked}
+        likers={likers}
+      />
+      <BookmarkButtons
+        hideTotalNumber={isCompactMode}
+        bookmarkCount={bookmarkCount}
+        isBookmarked={isBookmarked}
+        bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
+        onBookMarkClicked={bookmarkClickHandler}
+      />
+      <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+      { showPageControlDropdown && (
+        <PageItemControl
+          pageId={pageId}
+          pageInfo={pageInfo}
+          isEnableActions={!isGuestUser}
+          additionalMenuItemRenderer={additionalMenuItemRenderer}
+        />
       )}
       )}
     </div>
     </div>
   );
   );
 };
 };
 
 
-export default SubNavButtons;
+type SubNavButtonsProps= CommonProps & {
+  pageId: string,
+  shareLinkId?: string | null,
+  revisionId?: string | null,
+};
+
+export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
+  const { pageId, shareLinkId, revisionId } = props;
+
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+
+  if (revisionId == null || error != null) {
+    return <></>;
+  }
+
+  if (!isIPageInfoForOperation(pageInfo)) {
+    return <></>;
+  }
+
+  return <SubNavButtonsSubstance {...props} pageInfo={pageInfo} pageId={pageId} revisionId={revisionId} />;
+};

+ 14 - 4
packages/app/src/components/NotFoundPage.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
@@ -6,15 +6,25 @@ import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import DescendantsPageList from './DescendantsPageList';
 import DescendantsPageList from './DescendantsPageList';
 import PageTimeline from './PageTimeline';
 import PageTimeline from './PageTimeline';
+import { useCurrentPagePath } from '~/stores/context';
+
 
 
 const NotFoundPage = (): JSX.Element => {
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const DescendantsPageListForThisPage = useCallback((): JSX.Element => {
+    return currentPagePath != null
+      ? <DescendantsPageList path={currentPagePath} />
+      : <></>;
+  }, [currentPagePath]);
+
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
       pagelist: {
       pagelist: {
         Icon: PageListIcon,
         Icon: PageListIcon,
-        Content: DescendantsPageList,
+        Content: DescendantsPageListForThisPage,
         i18n: t('page_list'),
         i18n: t('page_list'),
         index: 0,
         index: 0,
       },
       },
@@ -25,12 +35,12 @@ const NotFoundPage = (): JSX.Element => {
         index: 1,
         index: 1,
       },
       },
     };
     };
-  }, [t]);
+  }, [DescendantsPageListForThisPage, t]);
 
 
 
 
   return (
   return (
     <div className="d-edit-none">
     <div className="d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />
     </div>
     </div>
   );
   );
 };
 };

+ 0 - 89
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,89 +0,0 @@
-import React from 'react';
-import { TabContent, TabPane } from 'reactstrap';
-import propTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-import Editor from '../PageEditor';
-import Page from '../Page';
-import UserInfo from '../User/UserInfo';
-import TableOfContents from '../TableOfContents';
-import ContentLinkButtons from '../ContentLinkButtons';
-import PageAccessories from '../PageAccessories';
-import PageEditorByHackmd from '../PageEditorByHackmd';
-import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import HashChanged from '../EventListeneres/HashChanged';
-import { useIsEditable } from '~/stores/context';
-
-
-const DisplaySwitcher = (props) => {
-  const {
-    pageContainer,
-  } = props;
-  const { isPageExist, pageUser } = pageContainer.state;
-
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-
-  const isViewMode = editorMode === EditorMode.View;
-
-  return (
-    <>
-      <TabContent activeTab={editorMode}>
-        <TabPane tabId={EditorMode.View}>
-          <div className="d-flex flex-column flex-lg-row-reverse">
-
-            { isPageExist && (
-              <div className="grw-side-contents-container">
-                <div className="grw-side-contents-sticky-container">
-                  <div className="border-bottom pb-1">
-                    <PageAccessories />
-                  </div>
-
-                  <div className="d-none d-lg-block">
-                    <div id="revision-toc" className="revision-toc">
-                      <TableOfContents />
-                    </div>
-                    <ContentLinkButtons />
-                  </div>
-                </div>
-              </div>
-            ) }
-
-            <div className="flex-grow-1 flex-basis-0 mw-0">
-              {pageUser && <UserInfo pageUser={pageUser} />}
-              <Page />
-            </div>
-
-          </div>
-        </TabPane>
-        { isEditable && (
-          <TabPane tabId={EditorMode.Editor}>
-            <div id="page-editor">
-              <Editor />
-            </div>
-          </TabPane>
-        ) }
-        { isEditable && (
-          <TabPane tabId={EditorMode.HackMD}>
-            <div id="page-editor-with-hackmd">
-              <PageEditorByHackmd />
-            </div>
-          </TabPane>
-        ) }
-      </TabContent>
-      { isEditable && !isViewMode && <EditorNavbarBottom /> }
-
-      { isEditable && <HashChanged></HashChanged> }
-    </>
-  );
-};
-
-DisplaySwitcher.propTypes = {
-  pageContainer: propTypes.instanceOf(PageContainer).isRequired,
-};
-
-
-export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 134 - 0
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -0,0 +1,134 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode, useDescendantsPageListModal } from '~/stores/ui';
+import {
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+} from '~/stores/context';
+
+
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+
+import PageListIcon from '../Icons/PageListIcon';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import UserInfo from '../User/UserInfo';
+import TableOfContents from '../TableOfContents';
+import ContentLinkButtons from '../ContentLinkButtons';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+
+
+const WIKI_HEADER_LINK = 120;
+
+const { isTopPage } = pagePathUtils;
+
+
+const DisplaySwitcher = (): JSX.Element => {
+  const { t } = useTranslation();
+
+
+  // get element for smoothScroll
+  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isUserPage } = useIsUserPage();
+  const { data: isEditable } = useIsEditable();
+  const { data: pageUser } = usePageUser();
+
+  const { data: editorMode } = useEditorMode();
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
+  const isPageExist = currentPageId != null;
+  const isViewMode = editorMode === EditorMode.View;
+  const isTopPagePath = isTopPage(currentPath ?? '');
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId={EditorMode.View}>
+          <div className="d-flex flex-column flex-lg-row-reverse">
+
+            { isPageExist && (
+              <div className="grw-side-contents-container">
+                <div className="grw-side-contents-sticky-container">
+
+                  {/* Page list */}
+                  <div className="grw-page-accessories-control">
+                    { currentPath != null && !isSharedUser && (
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => openDescendantPageListModal(currentPath)}
+                      >
+                        <PageListIcon />
+                        {t('page_list')}
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    ) }
+                  </div>
+
+                  {/* Comments */}
+                  { getCommentListDom != null && !isTopPagePath && (
+                    <div className="mt-2">
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                      >
+                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <span>Comments</span>
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    </div>
+                  ) }
+
+                  <div className="d-none d-lg-block">
+                    <div id="revision-toc" className="revision-toc">
+                      <TableOfContents />
+                    </div>
+                    <ContentLinkButtons />
+                  </div>
+
+                </div>
+              </div>
+            ) }
+
+            <div className="flex-grow-1 flex-basis-0 mw-0">
+              { isUserPage && <UserInfo pageUser={pageUser} />}
+              <Page />
+            </div>
+
+          </div>
+        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
+      </TabContent>
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
+    </>
+  );
+};
+
+export default DisplaySwitcher;

+ 11 - 22
packages/app/src/components/Page/PageManagement.jsx

@@ -5,9 +5,10 @@ import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+import { usePageDeleteModalStatus } from '~/stores/ui';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
@@ -22,12 +23,13 @@ const LegacyPageManagemenet = (props) => {
     t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
     t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
   } = props;
   } = props;
 
 
+  const { open: openDeleteModal } = usePageDeleteModalStatus();
+
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
   const isTopPagePath = isTopPage(path);
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
-  const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
   const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
   const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
 
 
@@ -54,14 +56,6 @@ const LegacyPageManagemenet = (props) => {
     setIsPageTempleteModalShown(false);
     setIsPageTempleteModalShown(false);
   }
   }
 
 
-  function openPageDeleteModalHandler() {
-    setIsPageDeleteModalShown(true);
-  }
-
-  function closePageDeleteModalHandler() {
-    setIsPageDeleteModalShown(false);
-  }
-
   function openPagePresentationModalHandler() {
   function openPagePresentationModalHandler() {
     setIsPagePresentationModalShown(true);
     setIsPagePresentationModalShown(true);
   }
   }
@@ -142,26 +136,27 @@ const LegacyPageManagemenet = (props) => {
     );
     );
   }
   }
 
 
+  function generatePageObjectToDelete() {
+    return { pageId, revisionId, path };
+  }
+  const pageToDelete = generatePageObjectToDelete();
+
   function renderDropdownItemForDeletablePage() {
   function renderDropdownItemForDeletablePage() {
     return (
     return (
       <>
       <>
         <div className="dropdown-divider"></div>
         <div className="dropdown-divider"></div>
-        <button className="dropdown-item text-danger" type="button" onClick={openPageDeleteModalHandler}>
+        <button className="dropdown-item text-danger" type="button" onClick={() => openDeleteModal([pageToDelete])}>
           <i className="icon-fw icon-fire"></i> { t('Delete') }
           <i className="icon-fw icon-fire"></i> { t('Delete') }
         </button>
         </button>
       </>
       </>
     );
     );
   }
   }
 
 
-  function generatePageObjectToDelete() {
-    return { pageId, revisionId, path };
-  }
 
 
   function renderModals() {
   function renderModals() {
     if (currentUser == null) {
     if (currentUser == null) {
       return null;
       return null;
     }
     }
-    const pageToDelete = generatePageObjectToDelete();
 
 
     return (
     return (
       <>
       <>
@@ -183,12 +178,6 @@ const LegacyPageManagemenet = (props) => {
           isOpen={isPageTemplateModalShown}
           isOpen={isPageTemplateModalShown}
           onClose={closePageTemplateModalHandler}
           onClose={closePageTemplateModalHandler}
         />
         />
-        <PageDeleteModal
-          isOpen={isPageDeleteModalShown}
-          onClose={closePageDeleteModalHandler}
-          pages={[pageToDelete]}
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        />
         <PagePresentationModal
         <PagePresentationModal
           isOpen={isPagePresentationModalShown}
           isOpen={isPagePresentationModalShown}
           onClose={closePagePresentationModalHandler}
           onClose={closePagePresentationModalHandler}
@@ -203,7 +192,7 @@ const LegacyPageManagemenet = (props) => {
       <>
       <>
         <button
         <button
           type="button"
           type="button"
-          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management"
+          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded btn-page-item-control"
           data-toggle="dropdown"
           data-toggle="dropdown"
         >
         >
           <i className="text-muted icon-options"></i>
           <i className="text-muted icon-options"></i>

+ 0 - 5
packages/app/src/components/Page/RenderTagLabels.tsx

@@ -20,11 +20,6 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
     openEditorModal();
     openEditorModal();
   }
   }
 
 
-  // activate suspense
-  if (tags == null) {
-    throw new Promise(() => {});
-  }
-
   const isTagsEmpty = tags.length === 0;
   const isTagsEmpty = tags.length === 0;
   const tagElements = tags.map((tag) => {
   const tagElements = tags.map((tag) => {
     return (
     return (

+ 20 - 24
packages/app/src/components/Page/TagLabels.tsx

@@ -1,20 +1,17 @@
-import React, { FC, Suspense, useState } from 'react';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
+import React, { FC, useState } from 'react';
 
 
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 import TagEditModal from './TagEditModal';
 
 
-type TagLabels = {
-  tags: string[],
-  appContainer: AppContainer,
-  tagsUpdateInvoked?: () => Promise<void>,
+type Props = {
+  tags?: string[],
+  isGuestUser: boolean,
+  tagsUpdateInvoked?: (tags: string[]) => Promise<void>,
 }
 }
 
 
 
 
-const TagLabels:FC<TagLabels> = (props:TagLabels) => {
-  const { tags, appContainer, tagsUpdateInvoked } = props;
+const TagLabels:FC<Props> = (props: Props) => {
+  const { tags, isGuestUser, tagsUpdateInvoked } = props;
 
 
   const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
   const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
 
 
@@ -30,13 +27,18 @@ const TagLabels:FC<TagLabels> = (props:TagLabels) => {
     <>
     <>
       <form className="grw-tag-labels form-inline">
       <form className="grw-tag-labels form-inline">
         <i className="tag-icon icon-tag mr-2"></i>
         <i className="tag-icon icon-tag mr-2"></i>
-        <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
-          <RenderTagLabels
-            tags={tags}
-            openEditorModal={openEditorModal}
-            isGuestUser={appContainer.isGuestUser}
-          />
-        </Suspense>
+        { tags == null
+          ? (
+            <span className="grw-tag-label badge badge-secondary">―</span>
+          )
+          : (
+            <RenderTagLabels
+              tags={tags}
+              openEditorModal={openEditorModal}
+              isGuestUser={isGuestUser}
+            />
+          )
+        }
       </form>
       </form>
 
 
       <TagEditModal
       <TagEditModal
@@ -45,14 +47,8 @@ const TagLabels:FC<TagLabels> = (props:TagLabels) => {
         onClose={closeEditorModal}
         onClose={closeEditorModal}
         onTagsUpdated={tagsUpdateInvoked}
         onTagsUpdated={tagsUpdateInvoked}
       />
       />
-
     </>
     </>
   );
   );
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer]);
-
-export default TagLabelsUnstatedWrapper;
+export default TagLabels;

+ 1 - 0
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -97,6 +97,7 @@ const TrashPageAlert = (props) => {
           pageId={pageId}
           pageId={pageId}
           path={path}
           path={path}
         />
         />
+        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87567  */}
         <PageDeleteModal
         <PageDeleteModal
           isOpen={isPageDeleteModalShown}
           isOpen={isPageDeleteModalShown}
           onClose={opclosePageDeleteModalHandler}
           onClose={opclosePageDeleteModalHandler}

+ 0 - 40
packages/app/src/components/PageAccessories.jsx

@@ -1,40 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PageAccessoriesModalControl from './PageAccessoriesModalControl';
-import PageAccessoriesModal from './PageAccessoriesModal';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-
-const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer } = props;
-  const { isGuestUser, isSharedUser } = appContainer;
-
-  return (
-    <>
-      <PageAccessoriesModalControl
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-      />
-      <PageAccessoriesModal
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-      />
-    </>
-  );
-};
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
-
-PageAccessories.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-};
-
-export default PageAccessoriesWrapper;

+ 0 - 160
packages/app/src/components/PageAccessoriesModal.jsx

@@ -1,160 +0,0 @@
-import React, { useCallback, useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalBody, ModalHeader, TabContent, TabPane,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import PageAttachment from './PageAttachment';
-import PageTimeline from './PageTimeline';
-import DescendantsPageList from './DescendantsPageList';
-import PageHistory from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import ExpandOrContractButton from './ExpandOrContractButton';
-
-const PageAccessoriesModal = (props) => {
-  const {
-    t, pageContainer, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-  const { switchActiveTab } = pageAccessoriesContainer;
-  const { activeTab, activeComponents } = pageAccessoriesContainer.state;
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        i18n: t('page_list'),
-        index: 0,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      timeline: {
-        Icon: TimeLineIcon,
-        i18n: t('Timeline View'),
-        index: 1,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      pageHistory: {
-        Icon: HistoryIcon,
-        i18n: t('History'),
-        index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser,
-      },
-      attachment: {
-        Icon: AttachmentIcon,
-        i18n: t('attachment_data'),
-        index: 3,
-      },
-      shareLink: {
-        Icon: ShareLinkIcon,
-        i18n: t('share_links.share_link_management'),
-        index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
-      },
-    };
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
-
-  const closeModalHandler = useCallback(() => {
-    if (onClose == null) {
-      return;
-    }
-    onClose();
-  }, [onClose]);
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-  };
-
-  const buttons = (
-    <div className="d-flex flex-nowrap">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    </div>
-  );
-
-  return (
-    <React.Fragment>
-      <Modal
-        size="xl"
-        isOpen={props.isOpen}
-        toggle={closeModalHandler}
-        className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-      >
-        <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
-          <CustomNavTab
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            onNavSelected={switchActiveTab}
-            breakpointToHideInactiveTabsDown="md"
-            hideBorderBottom
-          />
-        </ModalHeader>
-        <ModalBody className="overflow-auto grw-modal-body-style">
-          {/* Do not use CustomTabContent because of performance problem:
-              the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="pagelist">
-              {activeComponents.has('pagelist') && <DescendantsPageList path={pageContainer.state.path} />}
-            </TabPane>
-            <TabPane tabId="timeline">
-              {activeComponents.has('timeline') && <PageTimeline /> }
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="pageHistory">
-                {activeComponents.has('pageHistory') && <PageHistory /> }
-              </TabPane>
-            )}
-            <TabPane tabId="attachment">
-              {activeComponents.has('attachment') && <PageAttachment />}
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="shareLink">
-                {activeComponents.has('shareLink') && <ShareLink />}
-              </TabPane>
-            )}
-          </TabContent>
-        </ModalBody>
-      </Modal>
-    </React.Fragment>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageContainer, PageAccessoriesContainer]);
-
-PageAccessoriesModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-};
-
-export default withTranslation()(PageAccessoriesModalWrapper);

+ 134 - 0
packages/app/src/components/PageAccessoriesModal.tsx

@@ -0,0 +1,134 @@
+import React, { useMemo, useState } from 'react';
+
+import {
+  Modal, ModalBody, ModalHeader, TabContent, TabPane,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/ui';
+import AppContainer from '~/client/services/AppContainer';
+
+import HistoryIcon from './Icons/HistoryIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageAttachment from './PageAttachment';
+import PageHistory from './PageHistory';
+import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import ExpandOrContractButton from './ExpandOrContractButton';
+
+
+type Props = {
+  appContainer: AppContainer,
+  isLinkSharingDisabled: boolean,
+}
+
+const PageAccessoriesModal = (props: Props): JSX.Element => {
+  const {
+    appContainer,
+  } = props;
+
+  const isLinkSharingDisabled = appContainer.config.disableLinkSharing;
+
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState(PageAccessoriesModalContents.PageHistory);
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: status, open, close } = usePageAccessoriesModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [PageAccessoriesModalContents.PageHistory]: {
+        Icon: HistoryIcon,
+        i18n: t('History'),
+        index: 0,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser,
+      },
+      [PageAccessoriesModalContents.Attachment]: {
+        Icon: AttachmentIcon,
+        i18n: t('attachment_data'),
+        index: 1,
+      },
+      [PageAccessoriesModalContents.ShareLink]: {
+        Icon: ShareLinkIcon,
+        i18n: t('share_links.share_link_management'),
+        index: 2,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
+      },
+    };
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened, activatedContents } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={(v) => {
+            setActiveTab(v);
+            open(v);
+          }}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody className="overflow-auto grw-modal-body-style">
+        {/* Do not use CustomTabContent because of performance problem:
+            the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activatedContents */}
+        <TabContent activeTab={activeTab}>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.PageHistory}>
+              {activatedContents.has(PageAccessoriesModalContents.PageHistory) && <PageHistory /> }
+            </TabPane>
+          )}
+          <TabPane tabId={PageAccessoriesModalContents.Attachment}>
+            {activatedContents.has(PageAccessoriesModalContents.Attachment) && <PageAttachment />}
+          </TabPane>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.ShareLink}>
+              {activatedContents.has(PageAccessoriesModalContents.ShareLink) && <ShareLink />}
+            </TabPane>
+          )}
+        </TabContent>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [AppContainer]);
+
+export default PageAccessoriesModalWrapper;

+ 2 - 8
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -4,14 +4,12 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-import SeenUserInfo from './User/SeenUserInfo';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
@@ -91,22 +89,18 @@ const PageAccessoriesModalControl = (props) => {
           </Fragment>
           </Fragment>
         );
         );
       })}
       })}
-      <div className="d-flex align-items-center">
-        <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} pageId={pageId} />
-      </div>
     </div>
     </div>
   );
   );
 };
 };
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 
 PageAccessoriesModalControl.propTypes = {
 PageAccessoriesModalControl.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
 
 
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.any,
 
 
   isGuestUser: PropTypes.bool.isRequired,
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,

+ 18 - 12
packages/app/src/components/PageDeleteModal.tsx

@@ -6,14 +6,10 @@ import {
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 // import { apiPost } from '~/client/util/apiv1-client';
 // import { apiPost } from '~/client/util/apiv1-client';
+import { usePageDeleteModalStatus, usePageDeleteModalOpened } from '~/stores/ui';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
-export type IPageForPageDeleteModal = {
-  pageId: string,
-  revisionId: string,
-  path: string
-}
 
 
 const deleteIconAndKey = {
 const deleteIconAndKey = {
   completely: {
   completely: {
@@ -30,7 +26,6 @@ const deleteIconAndKey = {
 
 
 type Props = {
 type Props = {
   isOpen: boolean,
   isOpen: boolean,
-  pages: IPageForPageDeleteModal[],
   isDeleteCompletelyModal: boolean,
   isDeleteCompletelyModal: boolean,
   isAbleToDeleteCompletely: boolean,
   isAbleToDeleteCompletely: boolean,
   onClose?: () => void,
   onClose?: () => void,
@@ -39,12 +34,18 @@ type Props = {
 const PageDeleteModal: FC<Props> = (props: Props) => {
 const PageDeleteModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('');
   const { t } = useTranslation('');
   const {
   const {
-    isOpen, onClose, isDeleteCompletelyModal, pages, isAbleToDeleteCompletely,
+    isDeleteCompletelyModal, isAbleToDeleteCompletely,
   } = props;
   } = props;
+
+
+  const { data: pagesDataToDelete, close: closeDeleteModal } = usePageDeleteModalStatus();
+  const { data: isOpened } = usePageDeleteModalOpened();
+
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
 
 
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
 
 
   function changeIsDeleteRecursivelyHandler() {
   function changeIsDeleteRecursivelyHandler() {
@@ -142,9 +143,16 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
     );
     );
   }
   }
 
 
+  const renderPagePathsToDelete = () => {
+    if (pagesDataToDelete != null && pagesDataToDelete.pages != null) {
+      return pagesDataToDelete.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    }
+    return <></>;
+  };
+
   return (
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
+    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
       </ModalHeader>
       </ModalHeader>
@@ -153,9 +161,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
-          {pages.map((page) => {
-            return <div key={page.pageId}><code>{ page.path }</code></div>;
-          })}
+          {renderPagePathsToDelete()}
         </div>
         </div>
         {renderDeleteRecursivelyForm()}
         {renderDeleteRecursivelyForm()}
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}

+ 15 - 15
packages/app/src/components/PageDuplicateModal.jsx

@@ -9,6 +9,7 @@ import { withTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageDuplicateModalStatus, usePageDuplicateModalOpened } from '~/stores/ui';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
@@ -20,12 +21,16 @@ const LIMIT_FOR_LIST = 10;
 
 
 const PageDuplicateModal = (props) => {
 const PageDuplicateModal = (props) => {
   const {
   const {
-    t, appContainer, pageId, path,
+    t, appContainer,
   } = props;
   } = props;
 
 
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
+  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModalStatus();
+  const { data: isOpened } = usePageDuplicateModalOpened();
+
+  const { path, pageId } = pagesDataToDuplicate;
 
 
   const [pageNameInput, setPageNameInput] = useState(path);
   const [pageNameInput, setPageNameInput] = useState(path);
 
 
@@ -50,14 +55,14 @@ const PageDuplicateModal = (props) => {
 
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps
   const checkExistPathsDebounce = useCallback(
   const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [],
+    debounce(1000, checkExistPaths), [pageId, path],
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
-    if (pageNameInput !== path) {
+    if (pageId != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
     }
-  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
 
 
   /**
   /**
    * change pageNameInput for PagePathAutoComplete
    * change pageNameInput for PagePathAutoComplete
@@ -94,10 +99,11 @@ const PageDuplicateModal = (props) => {
   }, [appContainer, path, t]);
   }, [appContainer, path, t]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (props.isOpen) {
+    if (isOpened) {
       getSubordinatedList();
       getSubordinatedList();
+      setPageNameInput(path);
     }
     }
-  }, [props.isOpen, getSubordinatedList]);
+  }, [isOpened, getSubordinatedList, path]);
 
 
   function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
   function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
     setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
     setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
@@ -120,8 +126,8 @@ const PageDuplicateModal = (props) => {
   }
   }
 
 
   return (
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-duplicate-page" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
+    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeDuplicateModal} className="bg-primary text-light">
         { t('modal_duplicate.label.Duplicate page') }
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -188,7 +194,7 @@ const PageDuplicateModal = (props) => {
             )}
             )}
           </div>
           </div>
           <div>
           <div>
-            {isDuplicateRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+            {isDuplicateRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           </div>
           </div>
         </div>
         </div>
@@ -219,12 +225,6 @@ const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [A
 PageDuplicateModal.propTypes = {
 PageDuplicateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(PageDuplicateModallWrapper);
 export default withTranslation()(PageDuplicateModallWrapper);

+ 1 - 1
packages/app/src/components/PageHistory/RevisionDiff.jsx

@@ -29,7 +29,7 @@ class RevisionDiff extends React.Component {
       }
       }
 
 
       const patch = createPatch(
       const patch = createPatch(
-        currentRevision.path,
+        currentRevision.pageId, // currentRevision.path is DEPRECATED
         previousText,
         previousText,
         currentRevision.body,
         currentRevision.body,
       );
       );

+ 6 - 5
packages/app/src/components/PageList/PageList.tsx

@@ -1,19 +1,20 @@
 import React from 'react';
 import React from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { IPageHasId } from '~/interfaces/page';
+import { IPageWithMeta } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 
 
 import { PageListItemL } from './PageListItemL';
 import { PageListItemL } from './PageListItemL';
 
 
 
 
 type Props = {
 type Props = {
-  pages: IPagingResult<IPageHasId>,
+  pages: IPagingResult<IPageWithMeta>,
+  isEnableActions?: boolean,
 }
 }
 
 
 const PageList = (props: Props): JSX.Element => {
 const PageList = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { pages } = props;
+  const { pages, isEnableActions } = props;
 
 
   if (pages == null) {
   if (pages == null) {
     return (
     return (
@@ -26,7 +27,7 @@ const PageList = (props: Props): JSX.Element => {
   }
   }
 
 
   const pageList = pages.items.map(page => (
   const pageList = pages.items.map(page => (
-    <PageListItemL page={{ pageData: page }} />
+    <PageListItemL key={page.pageData._id} page={page} isEnableActions={isEnableActions} />
   ));
   ));
 
 
   if (pageList.length === 0) {
   if (pageList.length === 0) {
@@ -39,7 +40,7 @@ const PageList = (props: Props): JSX.Element => {
 
 
   return (
   return (
     <div className="page-list">
     <div className="page-list">
-      <ul className="page-list-ul page-list-ul-flat">
+      <ul className="page-list-ul list-group-flush">
         {pageList}
         {pageList}
       </ul>
       </ul>
     </div>
     </div>

+ 47 - 56
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,57 +1,48 @@
-import React, { FC, memo, useCallback } from 'react';
+import React, { memo, useCallback } from 'react';
 
 
 import Clamp from 'react-multiline-clamp';
 import Clamp from 'react-multiline-clamp';
+import { format } from 'date-fns';
 
 
-import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
-import { pagePathUtils, DevidedPagePath } from '@growi/core';
+import { UserPicture, PageListMeta } from '@growi/ui';
+import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import { IPageWithMeta } from '~/interfaces/page';
+import {
+  IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
+} from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 
 
-import PageItemControl from '../Common/Dropdown/PageItemControl';
-
-const { isTopPage } = pagePathUtils;
+import { PageItemControl } from '../Common/Dropdown/PageItemControl';
+import LinkedPagePath from '~/models/linked-page-path';
+import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
 
 type Props = {
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageSearchMeta>,
+  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isSelected?: boolean, // is item selected(focused)
   isChecked?: boolean, // is checkbox of item checked
   isChecked?: boolean, // is checkbox of item checked
   isEnableActions?: boolean,
   isEnableActions?: boolean,
-  shortBody?: string
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onClickCheckbox?: (pageId: string) => void,
   onClickCheckbox?: (pageId: string) => void,
   onClickItem?: (pageId: string) => void,
   onClickItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
 }
 }
 
 
-export const PageListItemL: FC<Props> = memo((props:Props) => {
+export const PageListItemL = memo((props: Props): JSX.Element => {
   const {
   const {
     // todo: refactoring variable name to clear what changed
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
+    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions,
     showPageUpdatedTime,
     showPageUpdatedTime,
   } = props;
   } = props;
 
 
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
 
 
-  const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
-
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
+  const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
 
-  const pageTitle = (
-    <PagePathLabel
-      path={elasticSearchResult?.highlightedPath || pageData.path}
-      isLatterOnly
-      isPathIncludedHtml={elasticSearchResult?.isHtmlInPath}
-    >
-    </PagePathLabel>
-  );
-  const pagePathElem = (
-    <PagePathLabel
-      path={elasticSearchResult?.highlightedPath || pageData.path}
-      isFormerOnly
-      isPathIncludedHtml={elasticSearchResult?.isHtmlInPath}
-    />
-  );
+  const dPagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
+  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+
+  const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
 
   // click event handler
   // click event handler
   const clickHandler = useCallback(() => {
   const clickHandler = useCallback(() => {
@@ -68,13 +59,11 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
-  const styleBorder = onClickCheckbox != null ? 'border-bottom' : 'list-group-item p-0';
 
 
   return (
   return (
     <li
     <li
       key={pageData._id}
       key={pageData._id}
-      className={`list-group-item p-0 ${styleListGroupItem} ${styleActive} ${styleBorder}}`
-      }
+      className={`list-group-item p-0 ${styleListGroupItem} ${styleActive}`}
     >
     >
       <div
       <div
         className="text-break"
         className="text-break"
@@ -93,51 +82,53 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
               />
               />
             </div>
             </div>
           )}
           )}
+
           <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
           <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
-            {/* page path */}
-            <h6 className="mb-1 py-1 d-flex">
-              <a className="d-inline-block" href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
-                <i className="icon-fw icon-home"></i>
-                {pagePathElem}
-              </a>
-              {showPageUpdatedTime && (<p className="ml-auto mb-0 mr-4 list-item-updated-time">Updated: 0000/00/00 00:00:00</p>)}
-            </h6>
-            <div className="d-flex align-items-center mb-2">
+            <div className="d-flex justify-content-between">
+              {/* page path */}
+              <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+              { showPageUpdatedTime && (
+                <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
+              ) }
+            </div>
+            <div className="d-flex align-items-center mb-1">
               {/* Picture */}
               {/* Picture */}
               <span className="mr-2 d-none d-md-block">
               <span className="mr-2 d-none d-md-block">
                 <UserPicture user={pageData.lastUpdateUser} size="md" />
                 <UserPicture user={pageData.lastUpdateUser} size="md" />
               </span>
               </span>
               {/* page title */}
               {/* page title */}
               <Clamp lines={1}>
               <Clamp lines={1}>
-                <span className="py-1 h5 mr-2 mb-0">
-                  <a href={`/${pageData._id}`}>{pageTitle}</a>
+                <span className="h5 mb-0">
+                  <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />
                 </span>
                 </span>
               </Clamp>
               </Clamp>
 
 
               {/* page meta */}
               {/* page meta */}
-              <div className="d-none d-md-flex py-0 px-1">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
-              </div>
+              { isIPageInfoForEntity(pageMeta) && (
+                <div className="d-none d-md-flex py-0 px-1">
+                  <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} shouldSpaceOutIcon />
+                </div>
+              ) }
+
               {/* doropdown icon includes page control buttons */}
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
               <div className="item-control ml-auto">
                 <PageItemControl
                 <PageItemControl
-                  page={pageData}
-                  onClickDeleteButtonHandler={props.onClickDeleteButton}
+                  pageId={pageData._id}
+                  pageInfo={pageMeta}
+                  onClickDeleteMenuItem={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
                   isEnableActions={isEnableActions}
-                  isDeletable={!isTopPage(pageData.path)}
-                  // Todo: add onClickRenameButtonHandler
                 />
                 />
               </div>
               </div>
             </div>
             </div>
             <div className="page-list-snippet py-1">
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
               <Clamp lines={2}>
-                {
-                  elasticSearchResult != null && elasticSearchResult?.snippet.length !== 0 ? (
-                    <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
-                  ) : (
-                    <div>{ shortBody != null ? shortBody : 'Loading ...' }</div> // TODO: improve indicator
-                  )
-                }
+                { elasticSearchResult != null && elasticSearchResult?.snippet.length > 0 && (
+                  // eslint-disable-next-line react/no-danger
+                  <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
+                ) }
+                { revisionShortBody != null && (
+                  <div>{revisionShortBody}</div>
+                ) }
               </Clamp>
               </Clamp>
             </div>
             </div>
           </div>
           </div>

+ 9 - 7
packages/app/src/components/PagePathNav.tsx

@@ -7,8 +7,8 @@ import LinkedPagePath from '../models/linked-page-path';
 
 
 
 
 type Props = {
 type Props = {
-  pageId :string,
-  pagePath:string,
+  pagePath: string,
+  pageId?: string | null,
   isSingleLineMode?:boolean,
   isSingleLineMode?:boolean,
   isCompactMode?:boolean,
   isCompactMode?:boolean,
 }
 }
@@ -43,11 +43,13 @@ const PagePathNav: FC<Props> = (props: Props) => {
       {formerLink}
       {formerLink}
       <span className="d-flex align-items-center">
       <span className="d-flex align-items-center">
         <h1 className="m-0">{latterLink}</h1>
         <h1 className="m-0">{latterLink}</h1>
-        <div className="mx-2">
-          <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
-            <i className="ti-clipboard"></i>
-          </CopyDropdown>
-        </div>
+        { pageId != null && (
+          <div className="mx-2">
+            <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
+              <i className="ti-clipboard"></i>
+            </CopyDropdown>
+          </div>
+        ) }
       </span>
       </span>
     </div>
     </div>
   );
   );

+ 0 - 49
packages/app/src/components/PageReactionButtons.tsx

@@ -1,49 +0,0 @@
-import React, { FC } from 'react';
-import LikeButtons from './LikeButtons';
-import { IUser } from '../interfaces/user';
-import BookmarkButtons from './BookmarkButtons';
-
-type Props = {
-  isCompactMode?: boolean,
-
-  isLiked: boolean,
-  sumOfLikers: number,
-  likers: IUser[],
-  onLikeClicked?: ()=>void,
-
-  isBookmarked: boolean,
-  sumOfBookmarks: number,
-  bookmarkedUsers: IUser[]
-  onBookMarkClicked: ()=>void,
-}
-
-
-const PageReactionButtons : FC<Props> = (props: Props) => {
-  const {
-    isCompactMode, sumOfLikers, isLiked, likers, onLikeClicked, sumOfBookmarks, isBookmarked, bookmarkedUsers, onBookMarkClicked,
-  } = props;
-
-
-  return (
-    <>
-      <LikeButtons
-        hideTotalNumber={isCompactMode}
-        onLikeClicked={onLikeClicked}
-        sumOfLikers={sumOfLikers}
-        isLiked={isLiked}
-        likers={likers}
-      >
-      </LikeButtons>
-      <BookmarkButtons
-        hideTotalNumber={isCompactMode}
-        sumOfBookmarks={sumOfBookmarks}
-        isBookmarked={isBookmarked}
-        bookmarkedUsers={bookmarkedUsers}
-        onBookMarkClicked={onBookMarkClicked}
-      >
-      </BookmarkButtons>
-    </>
-  );
-};
-
-export default PageReactionButtons;

+ 16 - 17
packages/app/src/components/PageRenameModal.jsx

@@ -10,6 +10,7 @@ import {
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
+import { usePageRenameModalStatus, usePageRenameModalOpened } from '~/stores/ui';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 
 
@@ -24,12 +25,16 @@ import DuplicatedPathsTable from './DuplicatedPathsTable';
 
 
 const PageRenameModal = (props) => {
 const PageRenameModal = (props) => {
   const {
   const {
-    t, appContainer, path, pageId, revisionId,
+    t, appContainer,
   } = props;
   } = props;
 
 
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
+  const { data: isOpened } = usePageRenameModalOpened();
+  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModalStatus();
 
 
-  const [pageNameInput, setPageNameInput] = useState(path);
+  const { path, revisionId, pageId } = pagesDataToRename;
+
+  const [pageNameInput, setPageNameInput] = useState('');
 
 
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
 
 
@@ -70,10 +75,11 @@ const PageRenameModal = (props) => {
   }, [path, t]);
   }, [path, t]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (props.isOpen) {
+    if (isOpened) {
       updateSubordinatedList();
       updateSubordinatedList();
+      setPageNameInput(path);
     }
     }
-  }, [props.isOpen, updateSubordinatedList]);
+  }, [isOpened, path, updateSubordinatedList]);
 
 
 
 
   const checkExistPaths = async(newParentPath) => {
   const checkExistPaths = async(newParentPath) => {
@@ -90,14 +96,14 @@ const PageRenameModal = (props) => {
 
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps
   const checkExistPathsDebounce = useCallback(
   const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [],
+    debounce(1000, checkExistPaths), [path],
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
-    if (pageNameInput !== path) {
+    if (pageId != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
     }
-  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);
 
 
   /**
   /**
    * change pageNameInput
    * change pageNameInput
@@ -137,8 +143,8 @@ const PageRenameModal = (props) => {
   }
   }
 
 
   return (
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} autoFocus={false}>
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
+    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -195,7 +201,7 @@ const PageRenameModal = (props) => {
               </label>
               </label>
             </div>
             </div>
           )}
           )}
-          {isRenameRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
         </div>
 
 
@@ -252,13 +258,6 @@ const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppConta
 PageRenameModal.propTypes = {
 PageRenameModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(PageRenameModalWrapper);
 export default withTranslation()(PageRenameModalWrapper);

+ 2 - 20
packages/app/src/components/SearchPage.jsx

@@ -19,7 +19,6 @@ import SearchControl from './SearchPage/SearchControl';
 import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import PageDeleteModal from './PageDeleteModal';
 import PageDeleteModal from './PageDeleteModal';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
-import { apiv3Get } from '~/client/util/apiv3-client';
 
 
 export const specificPathNames = {
 export const specificPathNames = {
   user: '/user',
   user: '/user',
@@ -40,7 +39,6 @@ class SearchPage extends React.Component {
       focusedSearchResultData: null,
       focusedSearchResultData: null,
       selectedPagesIdList: new Set(),
       selectedPagesIdList: new Set(),
       searchResultCount: 0,
       searchResultCount: 0,
-      shortBodiesMap: null,
       activePage: 1,
       activePage: 1,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       excludeUserPages: true,
       excludeUserPages: true,
@@ -152,11 +150,6 @@ class SearchPage extends React.Component {
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
   }
   }
 
 
-  async fetchShortBodiesMap(pageIds) {
-    const res = await apiv3Get('/page-listing/short-bodies', { pageIds });
-    this.setState({ shortBodiesMap: res.data.shortBodiesMap });
-  }
-
   // todo: refactoring
   // todo: refactoring
   // refs: https://redmine.weseek.co.jp/issues/82139
   // refs: https://redmine.weseek.co.jp/issues/82139
   async search(data) {
   async search(data) {
@@ -195,18 +188,6 @@ class SearchPage extends React.Component {
         order,
         order,
       });
       });
 
 
-      /*
-       * non-await asynchronous short body fetch
-       */
-      const pageIds = res.data.map((page) => {
-        if (page.pageMeta?.elasticSearchResult != null && page.pageMeta?.elasticSearchResult?.snippet.length !== 0) {
-          return null;
-        }
-
-        return page.pageData._id;
-      }).filter(id => id != null);
-      this.fetchShortBodiesMap(pageIds);
-
       this.changeURL(keyword);
       this.changeURL(keyword);
       if (res.data.length > 0) {
       if (res.data.length > 0) {
         this.setState({
         this.setState({
@@ -311,6 +292,7 @@ class SearchPage extends React.Component {
         appContainer={this.props.appContainer}
         appContainer={this.props.appContainer}
         searchingKeyword={this.state.searchingKeyword}
         searchingKeyword={this.state.searchingKeyword}
         focusedSearchResultData={this.state.focusedSearchResultData}
         focusedSearchResultData={this.state.focusedSearchResultData}
+        showPageControlDropdown={!this.props.isGuestUser}
       >
       >
       </SearchResultContent>
       </SearchResultContent>
     );
     );
@@ -324,7 +306,6 @@ class SearchPage extends React.Component {
         focusedSearchResultData={this.state.focusedSearchResultData}
         focusedSearchResultData={this.state.focusedSearchResultData}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         searchResultCount={this.state.searchResultCount}
         searchResultCount={this.state.searchResultCount}
-        shortBodiesMap={this.state.shortBodiesMap}
         activePage={this.state.activePage}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
         pagingLimit={this.state.pagingLimit}
         onClickItem={this.selectPage}
         onClickItem={this.selectPage}
@@ -371,6 +352,7 @@ class SearchPage extends React.Component {
           activePage={this.state.activePage}
           activePage={this.state.activePage}
         >
         >
         </SearchPageLayout>
         </SearchPageLayout>
+        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87569  */}
         <PageDeleteModal
         <PageDeleteModal
           isOpen={this.state.isDeleteConfirmModalShown}
           isOpen={this.state.isDeleteConfirmModalShown}
           onClose={this.closeDeleteConfirmModalHandler}
           onClose={this.closeDeleteConfirmModalHandler}

+ 75 - 11
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,32 +1,96 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
+
+import { DropdownItem } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
 
 
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta } from '~/interfaces/search';
 
 
+import { exportAsMarkdown } from '~/client/services/page-operation';
+
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
-import SearchResultContentSubNavigation from './SearchResultContentSubNavigation';
+import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
+import { SubNavButtons } from '../Navbar/SubNavButtons';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId } = props;
+
+  return (
+    <>
+      <DropdownItem divider />
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+    </>
+  );
+};
+
 
 
 type Props ={
 type Props ={
   appContainer: AppContainer,
   appContainer: AppContainer,
   searchingKeyword:string,
   searchingKeyword:string,
   focusedSearchResultData : IPageWithMeta<IPageSearchMeta>,
   focusedSearchResultData : IPageWithMeta<IPageSearchMeta>,
+  showPageControlDropdown?: boolean,
 }
 }
 
 
-
 const SearchResultContent: FC<Props> = (props: Props) => {
 const SearchResultContent: FC<Props> = (props: Props) => {
-  const page = props.focusedSearchResultData?.pageData;
+  const {
+    appContainer,
+    focusedSearchResultData,
+    showPageControlDropdown,
+  } = props;
+
+  const page = focusedSearchResultData?.pageData;
+
+  const growiRenderer = appContainer.getRenderer('searchresult');
+
+  const ControlComponents = useCallback(() => {
+    if (page == null) {
+      return <></>;
+    }
+
+    const revisionId = typeof page.revision === 'string'
+      ? page.revision
+      : page.revision._id;
+
+    return (
+      <>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          <SubNavButtons
+            pageId={page._id}
+            revisionId={revisionId}
+            showPageControlDropdown={showPageControlDropdown}
+            additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+          />
+        </div>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        </div>
+      </>
+    );
+  }, [page, showPageControlDropdown]);
+
   // return if page is null
   // return if page is null
   if (page == null) return <></>;
   if (page == null) return <></>;
-  const growiRenderer = props.appContainer.getRenderer('searchresult');
+
   return (
   return (
     <div key={page._id} className="search-result-page grw-page-path-text-muted-container d-flex flex-column">
     <div key={page._id} className="search-result-page grw-page-path-text-muted-container d-flex flex-column">
-      <SearchResultContentSubNavigation
-        pageId={page._id}
-        revisionId={page.revision}
-        path={page.path}
-      >
-      </SearchResultContentSubNavigation>
+      <GrowiSubNavigation
+        page={page}
+        controls={ControlComponents}
+      />
       <div className="search-result-page-content">
       <div className="search-result-page-content">
         <RevisionLoader
         <RevisionLoader
           growiRenderer={growiRenderer}
           growiRenderer={growiRenderer}

+ 0 - 85
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -1,85 +0,0 @@
-import React, { FC } from 'react';
-
-import { pagePathUtils } from '@growi/core';
-
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-import PagePathNav from '../PagePathNav';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../client/services/AppContainer';
-import { useSWRTagsInfo } from '../../stores/page';
-import SubNavButtons from '../Navbar/SubNavButtons';
-
-
-type Props = {
-  appContainer:AppContainer
-  pageId: string,
-  revisionId: string,
-  path: string,
-  isSignleLineMode?: boolean,
-  isCompactMode?: boolean,
-}
-
-
-const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
-  const {
-    appContainer, pageId, revisionId, path, isCompactMode, isSignleLineMode,
-  } = props;
-
-  const { isTrashPage, isDeletablePage } = pagePathUtils;
-
-  const { data: editorMode } = useEditorMode();
-
-  const { data: tagInfoData, error: tagInfoError } = useSWRTagsInfo(pageId);
-
-  if (tagInfoError != null || tagInfoData == null) {
-    return <></>;
-  }
-
-  const isViewMode = editorMode === EditorMode.View;
-
-  const isPageDeletable = isDeletablePage(path);
-  const { isSharedUser } = appContainer;
-  const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
-  return (
-    <div className="shadow-sm search-result-content-nav">
-      <div className={`grw-subnav container-fluid d-flex align-items-start justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
-        {/* Left side */}
-        <div className="grw-path-nav-container">
-          <PagePathNav pageId={pageId} pagePath={path} isCompactMode={isCompactMode} isSingleLineMode={isSignleLineMode} />
-        </div>
-        {/* Right side */}
-        {/*
-          DeleteCompletely is currently disabled
-          TODO : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
-          story: https://redmine.weseek.co.jp/issues/82222
-        */}
-        <div className="d-flex">
-          <SubNavButtons
-            isCompactMode={isCompactMode}
-            pageId={pageId}
-            revisionId={revisionId}
-            path={path}
-            isViewMode={isViewMode}
-            isDeletable={isPageDeletable}
-            isAbleToDeleteCompletely={false}
-            isAbleToShowPageManagement={isAbleToShowPageManagement}
-          >
-          </SubNavButtons>
-        </div>
-      </div>
-    </div>
-  );
-};
-
-
-/**
- * Wrapper component for using unstated
- */
-const SearchResultContentSubNavigationUnstatedWrapper = withUnstatedContainers(SearchResultContentSubNavigation, [AppContainer]);
-
-// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
-const SearchResultContentSubNavigationWrapper = (props) => {
-  return <SearchResultContentSubNavigationUnstatedWrapper {...props}></SearchResultContentSubNavigationUnstatedWrapper>;
-};
-export default SearchResultContentSubNavigationWrapper;

+ 32 - 6
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -1,19 +1,19 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageInfoForEntity, IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { useSWRxPageInfoForList } from '~/stores/page';
 
 
 import { PageListItemL } from '../PageList/PageListItemL';
 import { PageListItemL } from '../PageList/PageListItemL';
 import PaginationWrapper from '../PaginationWrapper';
 import PaginationWrapper from '../PaginationWrapper';
 
 
 
 
 type Props = {
 type Props = {
-  pages: IPageWithMeta<IPageSearchMeta>[],
+  pages: IPageWithMeta<IPageInfoForEntity & IPageSearchMeta>[],
   selectedPagesIdList: Set<string>
   selectedPagesIdList: Set<string>
   isEnableActions: boolean,
   isEnableActions: boolean,
   searchResultCount?: number,
   searchResultCount?: number,
   activePage?: number,
   activePage?: number,
   pagingLimit?: number,
   pagingLimit?: number,
-  shortBodiesMap?: Record<string, string>
   focusedSearchResultData?: IPageWithMeta<IPageSearchMeta>,
   focusedSearchResultData?: IPageWithMeta<IPageSearchMeta>,
   onPagingNumberChanged?: (activePage: number) => void,
   onPagingNumberChanged?: (activePage: number) => void,
   onClickItem?: (pageId: string) => void,
   onClickItem?: (pageId: string) => void,
@@ -24,13 +24,40 @@ type Props = {
 
 
 const SearchResultList: FC<Props> = (props:Props) => {
 const SearchResultList: FC<Props> = (props:Props) => {
   const {
   const {
-    focusedSearchResultData, selectedPagesIdList, isEnableActions, shortBodiesMap,
+    pages, focusedSearchResultData, selectedPagesIdList, isEnableActions,
   } = props;
   } = props;
 
 
+  const pageIdsWithNoSnippet = pages
+    .filter(page => (page.pageMeta?.elasticSearchResult?.snippet.length ?? 0) === 0)
+    .map(page => page.pageData._id);
+
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet);
+
+  let injectedPage;
+  // inject data to list
+  if (idToPageInfo != null) {
+    injectedPage = pages.map((page) => {
+      const pageInfo = idToPageInfo[page.pageData._id];
+
+      if (!isIPageInfoForListing(pageInfo)) {
+        // return as is
+        return page;
+      }
+
+      return {
+        pageData: page.pageData,
+        pageMeta: {
+          ...page.pageMeta,
+          revisionShortBody: pageInfo.revisionShortBody,
+        },
+      };
+    });
+  }
+
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
   return (
     <ul className="page-list-ul list-group list-group-flush">
     <ul className="page-list-ul list-group list-group-flush">
-      {Array.isArray(props.pages) && props.pages.map((page) => {
+      { (injectedPage ?? pages).map((page) => {
         const isChecked = selectedPagesIdList.has(page.pageData._id);
         const isChecked = selectedPagesIdList.has(page.pageData._id);
 
 
         return (
         return (
@@ -38,7 +65,6 @@ const SearchResultList: FC<Props> = (props:Props) => {
             key={page.pageData._id}
             key={page.pageData._id}
             page={page}
             page={page}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}
-            shortBody={shortBodiesMap?.[page.pageData._id]}
             onClickItem={props.onClickItem}
             onClickItem={props.onClickItem}
             onClickCheckbox={props.onClickCheckbox}
             onClickCheckbox={props.onClickCheckbox}
             isChecked={isChecked}
             isChecked={isChecked}

+ 2 - 23
packages/app/src/components/Sidebar/PageTree.tsx

@@ -1,4 +1,4 @@
-import React, { FC, memo, useState } from 'react';
+import React, { FC, memo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
@@ -8,8 +8,6 @@ import {
 
 
 import ItemsTree from './PageTree/ItemsTree';
 import ItemsTree from './PageTree/ItemsTree';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
-import { IPageForPageDeleteModal } from '../PageDeleteModal';
-
 
 
 const PageTree: FC = memo(() => {
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -19,13 +17,8 @@ const PageTree: FC = memo(() => {
   const { data: targetId } = useCurrentPageId();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: notFoundTargetPathOrIdData } = useNotFoundTargetPathOrId();
   const { data: notFoundTargetPathOrIdData } = useNotFoundTargetPathOrId();
-
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
 
-  // for delete modal
-  const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
-  const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
-
   const targetPathOrId = targetId || notFoundTargetPathOrIdData?.notFoundTargetPathOrId;
   const targetPathOrId = targetId || notFoundTargetPathOrIdData?.notFoundTargetPathOrId;
 
 
   if (migrationStatus == null) {
   if (migrationStatus == null) {
@@ -56,6 +49,7 @@ const PageTree: FC = memo(() => {
       </>
       </>
     );
     );
   }
   }
+
   /*
   /*
    * dependencies
    * dependencies
    */
    */
@@ -63,15 +57,6 @@ const PageTree: FC = memo(() => {
     return null;
     return null;
   }
   }
 
 
-  const onClickDeleteByPage = (page: IPageForPageDeleteModal) => {
-    setDeleteModalOpen(true);
-    setPagesToDelete([page]);
-  };
-
-  const onCloseDelete = () => {
-    setDeleteModalOpen(false);
-  };
-
   const path = currentPath || '/';
   const path = currentPath || '/';
 
 
   return (
   return (
@@ -86,12 +71,6 @@ const PageTree: FC = memo(() => {
           targetPath={path}
           targetPath={path}
           targetPathOrId={targetPathOrId}
           targetPathOrId={targetPathOrId}
           targetAndAncestorsData={targetAndAncestorsData}
           targetAndAncestorsData={targetAndAncestorsData}
-          isDeleteModalOpen={isDeleteModalOpen}
-          pagesToDelete={pagesToDelete}
-          isAbleToDeleteCompletely={false} // TODO: pass isAbleToDeleteCompletely
-          isDeleteCompletelyModal={false} // TODO: pass isDeleteCompletelyModal
-          onCloseDelete={onCloseDelete}
-          onClickDeleteByPage={onClickDeleteByPage}
         />
         />
       </div>
       </div>
 
 

+ 123 - 105
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,22 +1,23 @@
 import React, {
 import React, {
-  useCallback, useState, FC, useEffect, memo,
+  useCallback, useState, FC, useEffect,
 } from 'react';
 } from 'react';
-import nodePath from 'path';
+import { DropdownToggle } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { pagePathUtils } from '@growi/core';
+
 import { useDrag, useDrop } from 'react-dnd';
 import { useDrag, useDrop } from 'react-dnd';
-import { toastWarning } from '~/client/util/apiNotification';
 
 
-import { ItemNode } from './ItemNode';
-import { IPageHasId } from '~/interfaces/page';
-import { useSWRxPageChildren } from '../../../stores/page-listing';
-import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
-import PageItemControl from '../../Common/Dropdown/PageItemControl';
-import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
+import nodePath from 'path';
+import { toastWarning, toastError } from '~/client/util/apiNotification';
 
 
-import TriangleIcon from '~/components/Icons/TriangleIcon';
+import { useSWRxPageChildren } from '~/stores/page-listing';
+import { IPageForPageDeleteModal } from '~/stores/ui';
+import { apiv3Put } from '~/client/util/apiv3-client';
 
 
-const { isTopPage, isUserPage } = pagePathUtils;
+import TriangleIcon from '~/components/Icons/TriangleIcon';
+import { bookmark, unbookmark } from '~/client/services/page-operation';
+import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import { AsyncPageItemControl } from '../../Common/Dropdown/PageItemControl';
+import { ItemNode } from './ItemNode';
 
 
 
 
 interface ItemProps {
 interface ItemProps {
@@ -24,7 +25,9 @@ interface ItemProps {
   itemNode: ItemNode
   itemNode: ItemNode
   targetPathOrId?: string
   targetPathOrId?: string
   isOpen?: boolean
   isOpen?: boolean
-  onClickDeleteByPage?(page: IPageForPageDeleteModal): void
+  onClickDuplicateMenuItem?(pageId: string, path: string): void
+  onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
+  onClickDeleteByPage?(pageToDelete: IPageForPageDeleteModal | null): void
 }
 }
 
 
 // Utility to mark target
 // Utility to mark target
@@ -41,64 +44,11 @@ const markTarget = (children: ItemNode[], targetPathOrId?: string): void => {
   });
   });
 };
 };
 
 
-type ItemControlProps = {
-  page: Partial<IPageHasId>
-  isEnableActions: boolean
-  isDeletable: boolean
-  onClickPlusButton?(): void
-  onClickDeleteButton?(): void
-  onClickRenameButton?(): void
-}
-
-
-const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
-  const onClickPlusButton = () => {
-    if (props.onClickPlusButton == null) {
-      return;
-    }
-
-    props.onClickPlusButton();
-  };
-
-  const onClickDeleteButtonHandler = () => {
-    if (props.onClickDeleteButton == null) {
-      return;
-    }
-
-    props.onClickDeleteButton();
-  };
-
-  const onClickRenameButtonHandler = () => {
-    if (props.onClickRenameButton == null) {
-      return;
-    }
-
-    props.onClickRenameButton();
-  };
 
 
-  if (props.page == null) {
-    return <></>;
-  }
-
-  return (
-    <>
-      <PageItemControl
-        page={props.page}
-        onClickDeleteButtonHandler={onClickDeleteButtonHandler}
-        isEnableActions={props.isEnableActions}
-        isDeletable={props.isDeletable}
-        onClickRenameButtonHandler={onClickRenameButtonHandler}
-      />
-      <button
-        type="button"
-        className="border-0 rounded grw-btn-page-management p-0"
-        onClick={onClickPlusButton}
-      >
-        <i className="icon-plus text-muted d-block p-1" />
-      </button>
-    </>
-  );
-});
+const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+  const bookmarkOperation = _newValue ? bookmark : unbookmark;
+  await bookmarkOperation(_pageId);
+};
 
 
 
 
 type ItemCountProps = {
 type ItemCountProps = {
@@ -118,22 +68,21 @@ const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDeleteByPage, isEnableActions,
+    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage, isEnableActions,
   } = props;
   } = props;
 
 
   const { page, children } = itemNode;
   const { page, children } = itemNode;
 
 
+  const [pageTitle, setPageTitle] = useState(page.path);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
-  const [isRenameInputShown, setRenameInputShown] = useState(false);
+  // const [isRenameInputShown, setRenameInputShown] = useState(false);
 
 
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
   const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
   const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
 
 
-  const isDeletable = !page.isEmpty && !isTopPage(page.path as string) && !isUserPage(page.path as string);
-
   const [{ isDragging }, drag] = useDrag(() => ({
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     type: 'PAGE_TREE',
     item: { page },
     item: { page },
@@ -178,7 +127,66 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     setNewPageInputShown(true);
     setNewPageInputShown(true);
   }, []);
   }, []);
 
 
-  const onClickDeleteButton = useCallback(() => {
+  const duplicateMenuItemClickHandler = useCallback((): void => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+
+    const { _id: pageId, path } = page;
+
+    if (pageId == null || path == null) {
+      throw Error('Any of _id and path must not be null.');
+    }
+
+    onClickDuplicateMenuItem(pageId, path);
+  }, [onClickDuplicateMenuItem, page]);
+
+
+  /*
+  * Rename: TODO: rename page title on input form by #87757
+  */
+
+  // const onClickRenameButton = useCallback(async(_pageId: string): Promise<void> => {
+  //   setRenameInputShown(true);
+  // }, []);
+
+  // const onPressEnterForRenameHandler = async(inputText: string) => {
+  //   if (inputText == null || inputText === '' || inputText.trim() === '' || inputText.includes('/')) {
+  //     return;
+  //   }
+
+  //   const parentPath = nodePath.dirname(page.path as string);
+  //   const newPagePath = `${parentPath}/${inputText}`;
+
+  //   try {
+  //     setPageTitle(inputText);
+  //     setRenameInputShown(false);
+  //     await apiv3Put('/pages/rename', { newPagePath, pageId: page._id, revisionId: page.revision });
+  //   }
+  //   catch (err) {
+  //     // open ClosableInput and set pageTitle back to the previous title
+  //     setPageTitle(nodePath.basename(pageTitle as string));
+  //     setRenameInputShown(true);
+  //     toastError(err);
+  //   }
+  // };
+
+  const renameMenuItemClickHandler = useCallback((): void => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+
+    const { _id: pageId, revision: revisionId, path } = page;
+
+    if (pageId == null || revisionId == null || path == null) {
+      throw Error('Any of _id and revisionId and path must not be null.');
+    }
+
+    onClickRenameMenuItem(pageId, revisionId as string, path);
+  }, [onClickRenameMenuItem, page]);
+
+
+  const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteByPage == null) {
     if (onClickDeleteByPage == null) {
       return;
       return;
     }
     }
@@ -199,16 +207,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [page, onClickDeleteByPage]);
   }, [page, onClickDeleteByPage]);
 
 
 
 
-  const onClickRenameButton = useCallback(() => {
-    setRenameInputShown(true);
-  }, []);
-
-  // TODO: make a put request to pages/title
-  const onPressEnterForRenameHandler = () => {
-    toastWarning(t('search_result.currently_not_implemented'));
-    setRenameInputShown(false);
-  };
-
   // TODO: go to create page page
   // TODO: go to create page page
   const onPressEnterForCreateHandler = () => {
   const onPressEnterForCreateHandler = () => {
     toastWarning(t('search_result.currently_not_implemented'));
     toastWarning(t('search_result.currently_not_implemented'));
@@ -216,20 +214,27 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   };
   };
 
 
   const inputValidator = (title: string | null): AlertInfo | null => {
   const inputValidator = (title: string | null): AlertInfo | null => {
-    if (title == null || title === '') {
+    if (title == null || title === '' || title.trim() === '') {
       return {
       return {
         type: AlertType.WARNING,
         type: AlertType.WARNING,
         message: t('form_validation.title_required'),
         message: t('form_validation.title_required'),
       };
       };
     }
     }
 
 
+    if (title.includes('/')) {
+      return {
+        type: AlertType.WARNING,
+        message: t('form_validation.slashed_are_not_yet_supported'),
+      };
+    }
+
     return null;
     return null;
   };
   };
 
 
   // didMount
   // didMount
   useEffect(() => {
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
     if (hasChildren()) setIsOpen(true);
-  }, []);
+  }, [hasChildren]);
 
 
   /*
   /*
    * Make sure itemNode.children and currentChildren are synced
    * Make sure itemNode.children and currentChildren are synced
@@ -239,7 +244,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(children, targetPathOrId);
       markTarget(children, targetPathOrId);
       setCurrentChildren(children);
       setCurrentChildren(children);
     }
     }
-  }, []);
+  }, [children, currentChildren.length, targetPathOrId]);
 
 
   /*
   /*
    * When swr fetch succeeded
    * When swr fetch succeeded
@@ -250,7 +255,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(newChildren, targetPathOrId);
       markTarget(newChildren, targetPathOrId);
       setCurrentChildren(newChildren);
       setCurrentChildren(newChildren);
     }
     }
-  }, [data, isOpen]);
+  }, [data, error, isOpen, targetPathOrId]);
 
 
   return (
   return (
     <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
     <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
@@ -271,37 +276,48 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             </button>
             </button>
           )}
           )}
         </div>
         </div>
-        { isRenameInputShown && (
+        {/* TODO: rename page title on input form by 87757 */}
+        {/* { isRenameInputShown && (
           <ClosableTextInput
           <ClosableTextInput
             isShown
             isShown
+            value={nodePath.basename(pageTitle as string)}
             placeholder={t('Input page name')}
             placeholder={t('Input page name')}
             onClickOutside={() => { setRenameInputShown(false) }}
             onClickOutside={() => { setRenameInputShown(false) }}
             onPressEnter={onPressEnterForRenameHandler}
             onPressEnter={onPressEnterForRenameHandler}
             inputValidator={inputValidator}
             inputValidator={inputValidator}
           />
           />
         )}
         )}
-        { !isRenameInputShown && (
-          <a
-            href={page._id}
-            className="grw-pagetree-title-anchor flex-grow-1"
-          >
-            <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path as string) || '/'}</p>
-          </a>
-        )}
+        { !isRenameInputShown && ( */}
+        <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
+          <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
+        </a>
+        {/* )} */}
         {(page.descendantCount != null && page.descendantCount > 0) && (
         {(page.descendantCount != null && page.descendantCount > 0) && (
           <div className="grw-pagetree-count-wrapper">
           <div className="grw-pagetree-count-wrapper">
             <ItemCount descendantCount={page.descendantCount} />
             <ItemCount descendantCount={page.descendantCount} />
           </div>
           </div>
         )}
         )}
         <div className="grw-pagetree-control d-none">
         <div className="grw-pagetree-control d-none">
-          <ItemControl
-            page={page}
-            onClickPlusButton={onClickPlusButton}
-            onClickDeleteButton={onClickDeleteButton}
-            onClickRenameButton={onClickRenameButton}
+          <AsyncPageItemControl
+            pageId={page._id}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}
-            isDeletable={isDeletable}
-          />
+            showBookmarkMenuItem
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+            onClickDeleteMenuItem={onClickDeleteButton}
+            onClickRenameMenuItem={renameMenuItemClickHandler}
+          >
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0">
+              <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
+            </DropdownToggle>
+          </AsyncPageItemControl>
+          <button
+            type="button"
+            className="border-0 rounded btn-page-item-control p-0"
+            onClick={onClickPlusButton}
+          >
+            <i className="icon-plus text-muted d-block p-1" />
+          </button>
         </div>
         </div>
       </li>
       </li>
 
 
@@ -322,6 +338,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               itemNode={node}
               itemNode={node}
               isOpen={false}
               isOpen={false}
               targetPathOrId={targetPathOrId}
               targetPathOrId={targetPathOrId}
+              onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+              onClickRenameMenuItem={onClickRenameMenuItem}
               onClickDeleteByPage={onClickDeleteByPage}
               onClickDeleteByPage={onClickDeleteByPage}
             />
             />
           </div>
           </div>

+ 35 - 32
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -6,7 +6,9 @@ import Item from './Item';
 import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-import PageDeleteModal, { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
+import {
+  IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModalStatus,
+} from '~/stores/ui';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 /*
 /*
@@ -53,20 +55,17 @@ type ItemsTreeProps = {
   targetPath: string
   targetPath: string
   targetPathOrId?: string
   targetPathOrId?: string
   targetAndAncestorsData?: TargetAndAncestors
   targetAndAncestorsData?: TargetAndAncestors
-
-  // for deleteModal
-  isDeleteModalOpen: boolean
-  pagesToDelete: IPageForPageDeleteModal[]
-  isAbleToDeleteCompletely: boolean
-  isDeleteCompletelyModal: boolean
-  onCloseDelete(): void
-  onClickDeleteByPage(page: IPageForPageDeleteModal): void
 }
 }
 
 
 const renderByInitialNode = (
 const renderByInitialNode = (
-    // eslint-disable-next-line max-len
-    initialNode: ItemNode, DeleteModal: JSX.Element, isEnableActions: boolean, targetPathOrId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
+    initialNode: ItemNode,
+    isEnableActions: boolean,
+    targetPathOrId?: string,
+    onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
+    onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
+    onClickDeleteByPage?: (pageToDelete: IPageForPageDeleteModal | null) => void,
 ): JSX.Element => {
 ): JSX.Element => {
+
   return (
   return (
     <ul className="grw-pagetree list-group p-3">
     <ul className="grw-pagetree list-group p-3">
       <Item
       <Item
@@ -75,9 +74,10 @@ const renderByInitialNode = (
         itemNode={initialNode}
         itemNode={initialNode}
         isOpen
         isOpen
         isEnableActions={isEnableActions}
         isEnableActions={isEnableActions}
+        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+        onClickRenameMenuItem={onClickRenameMenuItem}
         onClickDeleteByPage={onClickDeleteByPage}
         onClickDeleteByPage={onClickDeleteByPage}
       />
       />
-      {DeleteModal}
     </ul>
     </ul>
   );
   );
 };
 };
@@ -88,29 +88,26 @@ const renderByInitialNode = (
  */
  */
 const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
 const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
-    onClickDeleteByPage, isEnableActions,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
   } = props;
   } = props;
 
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
+  const { open: openDuplicateModal } = usePageDuplicateModalStatus();
+  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDeleteModal } = usePageDeleteModalStatus();
 
 
-  const startFrom = document.getElementById('grw-sidebar-contents-scroll-target');
-  const targetElem = document.getElementsByClassName('grw-pagetree-is-target');
-  //  targetElem is HTML collection but only one HTML element in it all the time
-  if (targetElem[0] != null && startFrom != null) {
-    smoothScrollIntoView(targetElem[0] as HTMLElement, 0, startFrom);
-  }
+  const onClickDuplicateMenuItem = (pageId: string, path: string) => {
+    openDuplicateModal(pageId, path);
+  };
 
 
-  const DeleteModal = (
-    <PageDeleteModal
-      isOpen={isDeleteModalOpen}
-      pages={pagesToDelete}
-      isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-      isDeleteCompletelyModal={isDeleteCompletelyModal}
-      onClose={onCloseDelete}
-    />
-  );
+  const onClickRenameMenuItem = (pageId: string, revisionId: string, path: string) => {
+    openRenameModal(pageId, revisionId, path);
+  };
+
+  const onClickDeleteByPage = (pageToDelete: IPageForPageDeleteModal) => {
+    openDeleteModal([pageToDelete]);
+  };
 
 
   if (error1 != null || error2 != null) {
   if (error1 != null || error2 != null) {
     // TODO: improve message
     // TODO: improve message
@@ -118,12 +115,19 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     return null;
     return null;
   }
   }
 
 
+  const startFrom = document.getElementById('grw-sidebar-contents-scroll-target');
+  const targetElem = document.getElementsByClassName('grw-pagetree-is-target');
+  //  targetElem is HTML collection but only one HTML element in it all the time
+  if (targetElem[0] != null && startFrom != null) {
+    smoothScrollIntoView(targetElem[0] as HTMLElement, 0, startFrom);
+  }
+
   /*
   /*
    * Render completely
    * Render completely
    */
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetPathOrId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage);
   }
   }
 
 
   /*
   /*
@@ -131,11 +135,10 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
    */
   if (targetAndAncestorsData != null) {
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetPathOrId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage);
   }
   }
 
 
   return null;
   return null;
 };
 };
 
 
-
 export default ItemsTree;
 export default ItemsTree;

+ 9 - 41
packages/app/src/components/SubscribeButton.tsx

@@ -2,62 +2,30 @@ import React, { FC } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
-import { useSWRxSubscriptionStatus } from '../stores/page';
+import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 
 
 
-import { toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { useIsGuestUser } from '~/stores/context';
-
 type Props = {
 type Props = {
-  pageId: string,
+  isGuestUser?: boolean,
+  status?: SubscriptionStatusType,
+  onClick?: () => Promise<void>,
 };
 };
 
 
 const SubscribeButton: FC<Props> = (props: Props) => {
 const SubscribeButton: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { pageId } = props;
-
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: subscriptionData, mutate } = useSWRxSubscriptionStatus(pageId);
-
-  let isSubscribed;
-
-  switch (subscriptionData?.status) {
-    case true:
-      isSubscribed = true;
-      break;
-    case false:
-      isSubscribed = false;
-      break;
-    default:
-      isSubscribed = null;
-  }
-
-  const buttonClass = `${isSubscribed ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
-  const iconClass = isSubscribed || isSubscribed == null ? 'fa fa-eye' : 'fa fa-eye-slash';
+  const { isGuestUser, status } = props;
 
 
-  const handleClick = async() => {
-    if (isGuestUser) {
-      return;
-    }
+  const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
 
-    try {
-      const res = await apiv3Put('/page/subscribe', { pageId, status: !isSubscribed });
-      if (res) {
-        mutate();
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
-  };
+  const buttonClass = `${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
+  const iconClass = isSubscribing === false ? 'fa fa-eye-slash' : 'fa fa-eye';
 
 
   return (
   return (
     <>
     <>
       <button
       <button
         type="button"
         type="button"
         id="subscribe-button"
         id="subscribe-button"
-        onClick={handleClick}
+        onClick={props.onClick}
         className={`btn btn-subscribe border-0 ${buttonClass}`}
         className={`btn btn-subscribe border-0 ${buttonClass}`}
       >
       >
         <i className={iconClass}></i>
         <i className={iconClass}></i>

+ 2 - 2
packages/app/src/components/TableOfContents.jsx

@@ -34,8 +34,8 @@ const TableOfContents = (props) => {
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
     const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
 
 
-    // get smaller bottom line of window height - the height of ContentLinkButtons and .system-version height) and containerTop
-    let bottom = Math.min(window.innerHeight - 41 - 20, parentBottom);
+    // get smaller bottom line of window height - .system-version height - margin 5px) and containerTop
+    let bottom = Math.min(window.innerHeight - 20 - 5, parentBottom);
 
 
     if (isUserPage) {
     if (isUserPage) {
       // raise the bottom line by the height and margin-top of UserContentLinks
       // raise the bottom line by the height and margin-top of UserContentLinks

+ 8 - 16
packages/app/src/components/User/SeenUserInfo.tsx

@@ -3,40 +3,32 @@ import React, { FC, useState } from 'react';
 import { Button, Popover, PopoverBody } from 'reactstrap';
 import { Button, Popover, PopoverBody } from 'reactstrap';
 import { FootstampIcon } from '@growi/ui';
 import { FootstampIcon } from '@growi/ui';
 
 
+import { IUser } from '~/interfaces/user';
+
 import UserPictureList from './UserPictureList';
 import UserPictureList from './UserPictureList';
-import { useSWRxPageInfo } from '~/stores/page';
-import { useSWRxUsersList } from '~/stores/user';
 
 
 interface Props {
 interface Props {
-  pageId: string,
-  disabled: boolean
+  seenUsers: IUser[],
+  disabled?: boolean,
 }
 }
 
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
 const SeenUserInfo: FC<Props> = (props: Props) => {
-  const { pageId, disabled } = props;
-
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
 
-  const { data: pageInfo } = useSWRxPageInfo(pageId);
-  const likerIds = pageInfo?.likerIds != null ? pageInfo.likerIds.slice(0, 15) : [];
-  const seenUserIds = pageInfo?.seenUserIds != null ? pageInfo.seenUserIds.slice(0, 15) : [];
-
-  // Put in a mixture of seenUserIds and likerIds data to make the cache work
-  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
-  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+  const { seenUsers, disabled } = props;
 
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
 
   return (
   return (
     <div className="grw-seen-user-info">
     <div className="grw-seen-user-info">
-      <Button id="po-seen-user" color="link" className="px-2">
+      <Button id="btn-seen-user" color="link" className="btn-seen-user">
         <span className="mr-1 footstamp-icon">
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
           <FootstampIcon />
         </span>
         </span>
         <span className="seen-user-count">{seenUsers.length}</span>
         <span className="seen-user-count">{seenUsers.length}</span>
       </Button>
       </Button>
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
-        <PopoverBody className="seen-user-popover">
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
+        <PopoverBody className="user-list-popover">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
             <UserPictureList users={seenUsers} />
             <UserPictureList users={seenUsers} />
           </div>
           </div>

+ 3 - 1
packages/app/src/interfaces/common.ts

@@ -2,6 +2,8 @@
  * Common types and interfaces
  * Common types and interfaces
  */
  */
 
 
+import { HasObjectId } from './has-object-id';
+
 
 
 // Foreign key field
 // Foreign key field
-export type Ref<T> = string | T;
+export type Ref<T> = string | T & HasObjectId;

+ 0 - 8
packages/app/src/interfaces/page-info.ts

@@ -1,8 +0,0 @@
-export type IPageInfo = {
-  sumOfLikers: number;
-  likerIds: string[];
-  seenUserIds: string[];
-  sumOfSeenUsers: number;
-  isSeen: boolean;
-  isLiked: boolean;
-};

+ 59 - 11
packages/app/src/interfaces/page.ts

@@ -1,10 +1,12 @@
 import { Ref } from './common';
 import { Ref } from './common';
 import { IUser } from './user';
 import { IUser } from './user';
-import { IRevision } from './revision';
+import { IRevision, HasRevisionShortbody } from './revision';
 import { ITag } from './tag';
 import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
 import { HasObjectId } from './has-object-id';
+import { SubscriptionStatusType } from './subscription';
 
 
-export type IPage = {
+
+export interface IPage {
   path: string,
   path: string,
   status: string,
   status: string,
   revision: Ref<IRevision>,
   revision: Ref<IRevision>,
@@ -16,7 +18,6 @@ export type IPage = {
   parent: Ref<IPage> | null,
   parent: Ref<IPage> | null,
   descendantCount: number,
   descendantCount: number,
   isEmpty: boolean,
   isEmpty: boolean,
-  redirectTo: string,
   grant: number,
   grant: number,
   grantedUsers: Ref<IUser>[],
   grantedUsers: Ref<IUser>[],
   grantedGroup: Ref<any>,
   grantedGroup: Ref<any>,
@@ -36,16 +37,63 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 
 
 export type IPageInfo = {
 export type IPageInfo = {
-  bookmarkCount: number,
-  sumOfLikers: number,
-  likerIds: string[],
-  sumOfSeenUsers: number,
-  seenUserIds: string[],
-  isSeen?: boolean,
+  isEmpty: boolean,
+  isMovable: boolean,
+  isDeletable: boolean,
+  isAbleToDeleteCompletely: boolean,
+}
+
+export type IPageInfoForEntity = IPageInfo & {
+  bookmarkCount?: number,
+  sumOfLikers?: number,
+  likerIds?: string[],
+  sumOfSeenUsers?: number,
+  seenUserIds?: string[],
+}
+
+export type IPageInfoForOperation = IPageInfoForEntity & {
+  isBookmarked?: boolean,
   isLiked?: boolean,
   isLiked?: boolean,
+  subscriptionStatus?: SubscriptionStatusType,
 }
 }
 
 
-export type IPageWithMeta<M = Record<string, unknown>> = {
+export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
+
+export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
+
+export const isIPageInfoForEntity = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForEntity => {
+  return pageInfo != null && !pageInfo.isEmpty;
+};
+
+export const isIPageInfoForOperation = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForOperation => {
+  return pageInfo != null
+    && isIPageInfoForEntity(pageInfo)
+    && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
+};
+
+export const isIPageInfoForListing = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForListing => {
+  return pageInfo != null
+    && isIPageInfoForEntity(pageInfo)
+    && 'revisionShortBody' in pageInfo;
+};
+
+// export type IPageInfoTypeResolver<T extends IPageInfo> =
+//   T extends HasRevisionShortbody ? IPageInfoForListing :
+//   T extends { isBookmarked?: boolean } | { isLiked?: boolean } | { subscriptionStatus?: SubscriptionStatusType } ? IPageInfoForOperation :
+//   T extends { bookmarkCount: number } ? IPageInfoForEntity :
+//   T extends { isEmpty: number } ? IPageInfo :
+//   T;
+
+/**
+ * Union Distribution
+ * @param pageInfo
+ * @returns
+ */
+// export const resolvePageInfo = <T extends IPageInfo>(pageInfo: T | undefined): IPageInfoTypeResolver<T> => {
+//   return <IPageInfoTypeResolver<T>>pageInfo;
+// };
+
+export type IPageWithMeta<M = IPageInfoAll> = {
   pageData: IPageHasId,
   pageData: IPageHasId,
-  pageMeta?: Partial<IPageInfo> & M,
+  pageMeta?: M,
 };
 };

+ 4 - 0
packages/app/src/interfaces/revision.ts

@@ -14,3 +14,7 @@ export type IRevisionOnConflict = {
   createdAt: Date,
   createdAt: Date,
   user: IUser
   user: IUser
 }
 }
+
+export type HasRevisionShortbody = {
+  revisionShortBody?: string,
+}

+ 4 - 3
packages/app/src/interfaces/search.ts

@@ -1,4 +1,4 @@
-import { IPageWithMeta } from './page';
+import { IPageInfoAll, IPageWithMeta } from './page';
 
 
 export enum CheckboxType {
 export enum CheckboxType {
   NONE_CHECKED = 'noneChecked',
   NONE_CHECKED = 'noneChecked',
@@ -7,6 +7,7 @@ export enum CheckboxType {
 }
 }
 
 
 export type IPageSearchMeta = {
 export type IPageSearchMeta = {
+  bookmarkCount?: number,
   elasticSearchResult?: {
   elasticSearchResult?: {
     snippet: string;
     snippet: string;
     highlightedPath: string;
     highlightedPath: string;
@@ -14,8 +15,8 @@ export type IPageSearchMeta = {
   };
   };
 }
 }
 
 
-export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
-  return !!(meta as IPageSearchMeta)?.elasticSearchResult;
+export const isIPageSearchMeta = (meta: IPageInfoAll | (IPageInfoAll & IPageSearchMeta) | undefined): meta is IPageInfoAll & IPageSearchMeta => {
+  return meta != null && 'elasticSearchResult' in meta;
 };
 };
 
 
 export type IFormattedSearchResult = {
 export type IFormattedSearchResult = {

+ 6 - 0
packages/app/src/interfaces/subscription.ts

@@ -0,0 +1,6 @@
+export const SubscriptionStatusType = {
+  SUBSCRIBE: 'SUBSCRIBE',
+  UNSUBSCRIBE: 'UNSUBSCRIBE',
+} as const;
+export const AllSubscriptionStatusType = Object.values(SubscriptionStatusType);
+export type SubscriptionStatusType = typeof SubscriptionStatusType[keyof typeof SubscriptionStatusType];

+ 107 - 0
packages/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration.js

@@ -0,0 +1,107 @@
+import mongoose from 'mongoose';
+import { Writable } from 'stream';
+import streamToPromise from 'stream-to-promise';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
+import { createBatchStream } from '~/server/util/batch-stream';
+
+
+const logger = loggerFactory('growi:migrate:revision-path-to-page-id-schema-migration');
+
+const LIMIT = 300;
+
+module.exports = {
+  // path => pageId
+  async up(db, client) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+    const Page = getModelSafely('Page') || getPageModel();
+    const Revision = getModelSafely('Revision') || require('~/server/models/revision')();
+
+    const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, revision: 1 }).cursor({ batch_size: LIMIT });
+    const batchStrem = createBatchStream(LIMIT);
+
+    const migratePagesStream = new Writable({
+      objectMode: true,
+      async write(pages, encoding, callback) {
+        const updateManyOperations = pages.map((page) => {
+          return {
+            updateMany: {
+              filter: { _id: page.revision },
+              update: [
+                {
+                  $unset: ['path'],
+                },
+                {
+                  $set: { pageId: page._id },
+                },
+              ],
+            },
+          };
+        });
+
+        await Revision.bulkWrite(updateManyOperations);
+
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+
+    pagesStream
+      .pipe(batchStrem)
+      .pipe(migratePagesStream);
+
+    await streamToPromise(migratePagesStream);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  // pageId => path
+  async down(db, client) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+    const Page = getModelSafely('Page') || getPageModel();
+    const Revision = getModelSafely('Revision') || require('~/server/models/revision')();
+
+    const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, revision: 1, path: 1 }).cursor({ batch_size: LIMIT });
+    const batchStrem = createBatchStream(LIMIT);
+
+    const migratePagesStream = new Writable({
+      objectMode: true,
+      async write(pages, encoding, callback) {
+        const updateManyOperations = pages.map((page) => {
+          return {
+            updateMany: {
+              filter: { _id: page.revision },
+              update: [
+                {
+                  $unset: ['pageId'],
+                },
+                {
+                  $set: { path: page.path },
+                },
+              ],
+            },
+          };
+        });
+
+        await Revision.bulkWrite(updateManyOperations);
+
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+
+    pagesStream
+      .pipe(batchStrem)
+      .pipe(migratePagesStream);
+
+    await streamToPromise(migratePagesStream);
+
+    logger.info('Migration down has successfully applied');
+  },
+};

+ 85 - 0
packages/app/src/migrations/20220131001218-convert-redirect-to-pages-to-page-redirect-documents.js

@@ -0,0 +1,85 @@
+import mongoose from 'mongoose';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+
+import PageRedirectModel from '~/server/models/page-redirect';
+import loggerFactory from '~/utils/logger';
+import { createBatchStream } from '~/server/util/batch-stream';
+
+const logger = loggerFactory('growi:migrate:convert-redirect-to-pages-to-page-redirect-documents');
+
+const BATCH_SIZE = 100;
+
+
+module.exports = {
+  async up(db, client) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+    const pageCollection = await db.collection('pages');
+    const PageRedirect = getModelSafely('PageRedirect') || PageRedirectModel;
+
+    const cursor = pageCollection.find({ redirectTo: { $exists: true, $ne: null } }, { path: 1, redirectTo: 1, _id: 0 }).stream();
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    // redirectTo => PageRedirect
+    for await (const pages of cursor.pipe(batchStream)) {
+      const insertPageRedirectOperations = pages.map((page) => {
+        return {
+          insertOne: {
+            document: {
+              fromPath: page.path,
+              toPath: page.redirectTo,
+            },
+          },
+        };
+      });
+
+      try {
+        await PageRedirect.bulkWrite(insertPageRedirectOperations);
+      }
+      catch (err) {
+        if (err.code !== 11000) {
+          throw Error(`Failed to migrate: ${err}`);
+        }
+      }
+    }
+
+    await pageCollection.deleteMany({ redirectTo: { $ne: null } });
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+    const pageCollection = await db.collection('pages');
+    const PageRedirect = getModelSafely('PageRedirect') || PageRedirectModel;
+
+    const cursor = PageRedirect.find().lean().cursor();
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    // PageRedirect => redirectTo
+    for await (const pageRedirects of cursor.pipe(batchStream)) {
+      const insertPageOperations = pageRedirects.map((pageRedirect) => {
+        return {
+          insertOne: {
+            document: {
+              path: pageRedirect.fromPath,
+              redirectTo: pageRedirect.toPath,
+            },
+          },
+        };
+      });
+
+      try {
+        await pageCollection.bulkWrite(insertPageOperations);
+      }
+      catch (err) {
+        if (err.code !== 11000) {
+          throw Error(`Failed to migrate: ${err}`);
+        }
+      }
+    }
+
+    await PageRedirect.deleteMany();
+
+    logger.info('Migration down has successfully applied');
+  },
+};

+ 4 - 2
packages/app/src/server/crowi/index.js

@@ -20,12 +20,14 @@ import AppService from '../service/app';
 import AclService from '../service/acl';
 import AclService from '../service/acl';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
+import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
 import Activity from '../models/activity';
 import Activity from '../models/activity';
 import UserGroup from '../models/user-group';
 import UserGroup from '../models/user-group';
+import PageRedirect from '../models/page-redirect';
 
 
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
@@ -279,6 +281,7 @@ Crowi.prototype.setupModels = async function() {
   // include models that independent from crowi
   // include models that independent from crowi
   allModels.Activity = Activity;
   allModels.Activity = Activity;
   allModels.UserGroup = UserGroup;
   allModels.UserGroup = UserGroup;
+  allModels.PageRedirect = PageRedirect;
 
 
   Object.keys(allModels).forEach((key) => {
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
     return this.model(key, models[key](this));
@@ -669,9 +672,8 @@ Crowi.prototype.setupImport = async function() {
 };
 };
 
 
 Crowi.prototype.setupPageService = async function() {
 Crowi.prototype.setupPageService = async function() {
-  const PageEventService = require('../service/page');
   if (this.pageService == null) {
   if (this.pageService == null) {
-    this.pageService = new PageEventService(this);
+    this.pageService = new PageService(this);
   }
   }
   if (this.pageGrantService == null) {
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
     this.pageGrantService = new PageGrantService(this);

+ 3 - 0
packages/app/src/server/interfaces/mongoose-utils.ts

@@ -0,0 +1,3 @@
+import mongoose from 'mongoose';
+
+export type ObjectIdLike = mongoose.Types.ObjectId | string;

+ 25 - 0
packages/app/src/server/middlewares/apiv1-form-validator.ts

@@ -0,0 +1,25 @@
+import { validationResult } from 'express-validator';
+import { NextFunction, Request, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+import ApiResponse from '../util/apiResponse';
+
+const logger = loggerFactory('growi:middlewares:ApiV1FormValidator');
+
+export default (req: Request, res: Response, next: NextFunction): void => {
+  logger.debug('req.query', req.query);
+  logger.debug('req.params', req.params);
+  logger.debug('req.body', req.body);
+
+  const errObjArray = validationResult(req);
+  if (errObjArray.isEmpty()) {
+    return next();
+  }
+
+  const errs = errObjArray.array().map((err) => {
+    logger.error(`${err.location}.${err.param}: ${err.msg}`);
+    return ApiResponse.error(`${err.param}: ${err.msg}`, 'validation_failed');
+  });
+
+  res.json(errs);
+};

+ 19 - 61
packages/app/src/server/models/obsolete-page.js

@@ -15,7 +15,7 @@ const differenceInYears = require('date-fns/differenceInYears');
 const { pathUtils } = require('@growi/core');
 const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
 
 
-const { isTopPage, isTrashPage } = pagePathUtils;
+const { isTopPage, isTrashPage, isUserNamePage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 const { checkTemplatePath } = templateChecker;
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
@@ -104,11 +104,6 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
-  addConditionToExcludeRedirect() {
-    this.query = this.query.and({ redirectTo: null });
-    return this;
-  }
-
   /**
   /**
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * If top page, return without doing anything.
    * If top page, return without doing anything.
@@ -327,6 +322,11 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionToFilteringByParentId(parentId) {
+    this.query = this.query.and({ parent: parentId });
+    return this;
+  }
+
 }
 }
 
 
 export const getPageSchema = (crowi) => {
 export const getPageSchema = (crowi) => {
@@ -564,18 +564,7 @@ export const getPageSchema = (crowi) => {
   };
   };
 
 
   pageSchema.statics.isDeletableName = function(path) {
   pageSchema.statics.isDeletableName = function(path) {
-    const notDeletable = [
-      /^\/user\/[^/]+$/, // user page
-    ];
-
-    for (let i = 0; i < notDeletable.length; i++) {
-      const pattern = notDeletable[i];
-      if (path.match(pattern)) {
-        return false;
-      }
-    }
-
-    return true;
+    return !isTopPage(path) && !isUserNamePage(path);
   };
   };
 
 
   pageSchema.statics.fixToCreatableName = function(path) {
   pageSchema.statics.fixToCreatableName = function(path) {
@@ -632,6 +621,16 @@ export const getPageSchema = (crowi) => {
     return queryBuilder.query.exec();
     return queryBuilder.query.exec();
   };
   };
 
 
+  pageSchema.statics.findByIdAndViewerToEdit = async function(id, user, includeEmpty = false) {
+    const baseQuery = this.findOne({ _id: id });
+    const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
+    // add grant conditions
+    await addConditionToFilteringByViewerToEdit(queryBuilder, user);
+
+    return queryBuilder.query.exec();
+  };
+
   // find page by path
   // find page by path
   pageSchema.statics.findByPath = function(path, includeEmpty = false) {
   pageSchema.statics.findByPath = function(path, includeEmpty = false) {
     if (path == null) {
     if (path == null) {
@@ -675,10 +674,6 @@ export const getPageSchema = (crowi) => {
     return queryBuilder.query.exec();
     return queryBuilder.query.exec();
   };
   };
 
 
-  pageSchema.statics.findByRedirectTo = function(path) {
-    return this.findOne({ redirectTo: path });
-  };
-
   /**
   /**
    * find pages that is match with `path` and its descendants
    * find pages that is match with `path` and its descendants
    */
    */
@@ -699,7 +694,6 @@ export const getPageSchema = (crowi) => {
 
 
     const builder = new PageQueryBuilder(this.find(), includeEmpty);
     const builder = new PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListWithDescendants(page.path, option);
     builder.addConditionToListWithDescendants(page.path, option);
-    builder.addConditionToExcludeRedirect();
 
 
     // add grant conditions
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
     await addConditionToFilteringByViewerToEdit(builder, user);
@@ -750,9 +744,6 @@ export const getPageSchema = (crowi) => {
     const opt = Object.assign({}, option);
     const opt = Object.assign({}, option);
     const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
     const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
 
 
-    if (excludeRedirect) {
-      builder.addConditionToExcludeRedirect();
-    }
     builder.addConditionToPagenate(opt.offset, opt.limit);
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
 
     // count
     // count
@@ -789,10 +780,6 @@ export const getPageSchema = (crowi) => {
     if (!opt.includeTrashed) {
     if (!opt.includeTrashed) {
       builder.addConditionToExcludeTrashed();
       builder.addConditionToExcludeTrashed();
     }
     }
-    // exclude redirect pages
-    if (!opt.includeRedirect) {
-      builder.addConditionToExcludeRedirect();
-    }
 
 
     // add grant conditions
     // add grant conditions
     await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
     await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
@@ -994,7 +981,6 @@ export const getPageSchema = (crowi) => {
     const Page = this;
     const Page = this;
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
     const format = options.format || 'markdown';
-    const redirectTo = options.redirectTo || null;
     const grantUserGroupId = options.grantUserGroupId || null;
     const grantUserGroupId = options.grantUserGroupId || null;
 
 
     // sanitize path
     // sanitize path
@@ -1016,7 +1002,6 @@ export const getPageSchema = (crowi) => {
     page.path = path;
     page.path = path;
     page.creator = user;
     page.creator = user;
     page.lastUpdateUser = user;
     page.lastUpdateUser = user;
-    page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
     page.status = STATUS_PUBLISHED;
 
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     await validateAppliedScope(user, grant, grantUserGroupId);
@@ -1024,8 +1009,7 @@ export const getPageSchema = (crowi) => {
 
 
     let savedPage = await page.save();
     let savedPage = await page.save();
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     pageEvent.emit('create', savedPage, user);
     pageEvent.emit('create', savedPage, user);
@@ -1047,8 +1031,7 @@ export const getPageSchema = (crowi) => {
     // update existing page
     // update existing page
     let savedPage = await pageData.save();
     let savedPage = await pageData.save();
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     if (isSyncRevisionToHackmd) {
     if (isSyncRevisionToHackmd) {
@@ -1064,8 +1047,6 @@ export const getPageSchema = (crowi) => {
     const builder = new PageQueryBuilder(this.find());
     const builder = new PageQueryBuilder(this.find());
     builder.addConditionToListWithDescendants(parentPage.path);
     builder.addConditionToListWithDescendants(parentPage.path);
 
 
-    builder.addConditionToExcludeRedirect();
-
     // add grant conditions
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
     await addConditionToFilteringByViewerToEdit(builder, user);
 
 
@@ -1090,29 +1071,6 @@ export const getPageSchema = (crowi) => {
     return this.findOneAndRemove({ path }).exec();
     return this.findOneAndRemove({ path }).exec();
   };
   };
 
 
-  /**
-   * remove the page that is redirecting to specified `pagePath` recursively
-   *  ex: when
-   *    '/page1' redirects to '/page2' and
-   *    '/page2' redirects to '/page3'
-   *    and given '/page3',
-   *    '/page1' and '/page2' will be removed
-   *
-   * @param {string} pagePath
-   */
-  pageSchema.statics.removeRedirectOriginPageByPath = async function(pagePath) {
-    const redirectPage = await this.findByRedirectTo(pagePath);
-
-    if (redirectPage == null) {
-      return;
-    }
-
-    // remove
-    await this.findByIdAndRemove(redirectPage.id);
-    // remove recursive
-    await this.removeRedirectOriginPageByPath(redirectPage.path);
-  };
-
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
     const queryBuilder = new PageQueryBuilder(this.find(), includeEmpty);
     const queryBuilder = new PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
     queryBuilder.addConditionToListByPathsArray(paths);

+ 29 - 0
packages/app/src/server/models/page-redirect.ts

@@ -0,0 +1,29 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import {
+  Schema, Model, Document,
+} from 'mongoose';
+import { getOrCreateModel } from '@growi/core';
+
+export interface IPageRedirect {
+  fromPath: string,
+  toPath: string,
+}
+
+export interface PageRedirectDocument extends IPageRedirect, Document {}
+
+export interface PageRedirectModel extends Model<PageRedirectDocument> {
+  [x:string]: any // TODO: improve type
+}
+
+/**
+ * This is the setting for notify to 3rd party tool (like Slack).
+ */
+const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
+  fromPath: {
+    type: String, required: true, unique: true, index: true,
+  },
+  toPath: { type: String, required: true },
+});
+
+export default getOrCreateModel<PageRedirectDocument, PageRedirectModel>('PageRedirect', schema);

+ 154 - 50
packages/app/src/server/models/page.ts

@@ -6,12 +6,14 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 import nodePath from 'path';
 import nodePath from 'path';
-
 import { getOrCreateModel, pagePathUtils } from '@growi/core';
 import { getOrCreateModel, pagePathUtils } from '@growi/core';
+
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 import { IPage } from '../../interfaces/page';
 import { IPage } from '../../interfaces/page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import { PageRedirectModel } from './page-redirect';
 
 
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 
@@ -32,14 +34,18 @@ const STATUS_DELETED = 'deleted';
 
 
 export interface PageDocument extends IPage, Document {}
 export interface PageDocument extends IPage, Document {}
 
 
+
 type TargetAndAncestorsResult = {
 type TargetAndAncestorsResult = {
   targetAndAncestors: PageDocument[]
   targetAndAncestors: PageDocument[]
   rootPage: PageDocument
   rootPage: PageDocument
 }
 }
+
+export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
   createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
-  getParentIdAndFillAncestors(path: string, parent: (PageDocument & { _id: any }) | null): Promise<string | null>
+  getParentAndFillAncestors(path: string): Promise<PageDocument & { _id: any }>
+  findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -70,7 +76,6 @@ const schema = new Schema<PageDocument, PageModel>({
     type: String, required: true, index: true,
     type: String, required: true, index: true,
   },
   },
   revision: { type: ObjectId, ref: 'Revision' },
   revision: { type: ObjectId, ref: 'Revision' },
-  redirectTo: { type: String, index: true },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
@@ -139,18 +144,76 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicO
   }
   }
 };
 };
 
 
-/*
- * Find the parent and update if the parent exists.
- * If not,
- *   - first   run createEmptyPagesByPaths with ancestor's paths to ensure all the ancestors exist
- *   - second  update ancestor pages' parent
- *   - finally return the target's parent page id
+schema.statics.createEmptyPage = async function(
+    path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
+): Promise<PageDocument & { _id: any }> {
+  if (parent == null) {
+    throw Error('parent must not be null');
+  }
+
+  const Page = this;
+  const page = new Page();
+  page.path = path;
+  page.isEmpty = true;
+  page.parent = parent;
+  page.descendantCount = descendantCount;
+
+  return page.save();
+};
+
+/**
+ * Replace an existing page with an empty page.
+ * It updates the children's parent to the new empty page's _id.
+ * @param exPage a page document to be replaced
+ * @returns Promise<void>
  */
  */
-schema.statics.getParentIdAndFillAncestors = async function(path: string, parent: PageDocument | null): Promise<Schema.Types.ObjectId> {
-  const parentPath = nodePath.dirname(path);
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
+  // find parent
+  const parent = await this.findOne({ _id: exPage.parent });
+  if (parent == null) {
+    throw Error('parent to update does not exist. Prepare parent first.');
+  }
+
+  // create empty page at path
+  const newTarget = pageToReplaceWith == null ? await this.createEmptyPage(exPage.path, parent, exPage.descendantCount) : pageToReplaceWith;
+
+  // find children by ex-page _id
+  const children = await this.find({ parent: exPage._id });
+
+  // bulkWrite
+  const operationForNewTarget = {
+    updateOne: {
+      filter: { _id: newTarget._id },
+      update: {
+        parent: parent._id,
+      },
+    },
+  };
+  const operationsForChildren = {
+    updateMany: {
+      filter: {
+        _id: { $in: children.map(d => d._id) },
+      },
+      update: {
+        parent: newTarget._id,
+      },
+    },
+  };
+
+  await this.bulkWrite([operationForNewTarget, operationsForChildren]);
+};
 
 
+/**
+ * Find parent or create parent if not exists.
+ * It also updates parent of ancestors
+ * @param path string
+ * @returns Promise<PageDocument>
+ */
+schema.statics.getParentAndFillAncestors = async function(path: string): Promise<PageDocument> {
+  const parentPath = nodePath.dirname(path);
+  const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
   if (parent != null) {
   if (parent != null) {
-    return parent._id;
+    return parent;
   }
   }
 
 
   /*
   /*
@@ -162,16 +225,15 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string, parent
   await this.createEmptyPagesByPaths(ancestorPaths);
   await this.createEmptyPagesByPaths(ancestorPaths);
 
 
   // find ancestors
   // find ancestors
-  const builder = new PageQueryBuilder(this.find({}, { _id: 1, path: 1 }), true);
+  const builder = new PageQueryBuilder(this.find(), true);
   const ancestors = await builder
   const ancestors = await builder
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToSortPagesByDescPath()
     .addConditionToSortPagesByDescPath()
     .query
     .query
-    .lean()
     .exec();
     .exec();
 
 
-  const ancestorsMap = new Map(); // Map<path, _id>
-  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page._id)); // the earlier element should be the true ancestor
+  const ancestorsMap = new Map(); // Map<path, page>
+  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
 
 
   // bulkWrite to update ancestors
   // bulkWrite to update ancestors
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
@@ -191,8 +253,9 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string, parent
   });
   });
   await this.bulkWrite(operations);
   await this.bulkWrite(operations);
 
 
-  const parentId = ancestorsMap.get(parentPath);
-  return parentId;
+  const createdParent = ancestorsMap.get(parentPath);
+
+  return createdParent;
 };
 };
 
 
 // Utility function to add viewer condition to PageQueryBuilder instance
 // Utility function to add viewer condition to PageQueryBuilder instance
@@ -206,6 +269,18 @@ const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroup
   queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
   queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
 };
 };
 
 
+/*
+ * Find pages by ID and viewer.
+ */
+schema.statics.findByIdsAndViewer = async function(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]> {
+  const baseQuery = this.find({ _id: { $in: pageIds } });
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
+  await addViewerCondition(queryBuilder, user, userGroups);
+
+  return queryBuilder.query.exec();
+};
+
 /*
 /*
  * Find a page by path and viewer. Pass false to useFindOne to use findOne method.
  * Find a page by path and viewer. Pass false to useFindOne to use findOne method.
  */
  */
@@ -279,7 +354,7 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
   }
   }
   else {
   else {
     const parentId = parentPathOrId;
     const parentId = parentPathOrId;
-    queryBuilder = new PageQueryBuilder(this.find({ parent: parentId }), true);
+    queryBuilder = new PageQueryBuilder(this.find({ parent: parentId } as any), true); // TODO: improve type
   }
   }
   await addViewerCondition(queryBuilder, user, userGroups);
   await addViewerCondition(queryBuilder, user, userGroups);
 
 
@@ -380,21 +455,14 @@ schema.statics.getAggrConditionForPageWithProvidedPathAndDescendants = function(
  * add/subtract descendantCount of pages with provided paths by increment.
  * add/subtract descendantCount of pages with provided paths by increment.
  * increment can be negative number
  * increment can be negative number
  */
  */
-schema.statics.incrementDescendantCountOfPaths = async function(paths:string[], increment: number):Promise<void> {
-  const pages = await this.aggregate([{ $match: { path: { $in: paths } } }]);
-  const operations = pages.map((page) => {
-    return {
-      updateOne: {
-        filter: { path: page.path },
-        update: { descendantCount: page.descendantCount + increment },
-      },
-    };
-  });
-  await this.bulkWrite(operations);
+schema.statics.incrementDescendantCountOfPageIds = async function(pageIds: ObjectIdLike[], increment: number): Promise<void> {
+  await this.updateMany({ _id: { $in: pageIds } }, { $inc: { descendantCount: increment } });
 };
 };
 
 
-// update descendantCount of a page with provided id
-schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mongoose.Types.ObjectId):Promise<void> {
+/**
+ * recount descendantCount of a page with the provided id and return it
+ */
+schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise<number> {
   const res = await this.aggregate(
   const res = await this.aggregate(
     [
     [
       {
       {
@@ -404,8 +472,8 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mo
       },
       },
       {
       {
         $project: {
         $project: {
-          path: 1,
           parent: 1,
           parent: 1,
+          isEmpty: 1,
           descendantCount: 1,
           descendantCount: 1,
         },
         },
       },
       },
@@ -416,7 +484,9 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mo
             $sum: '$descendantCount',
             $sum: '$descendantCount',
           },
           },
           sumOfDocsCount: {
           sumOfDocsCount: {
-            $sum: 1,
+            $sum: {
+              $cond: { if: { $eq: ['$isEmpty', true] }, then: 0, else: 1 }, // exclude isEmpty true page from sumOfDocsCount
+            },
           },
           },
         },
         },
       },
       },
@@ -430,10 +500,31 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mo
     ],
     ],
   );
   );
 
 
-  const query = { descendantCount: res.length === 0 ? 0 : res[0].descendantCount };
-  await this.findByIdAndUpdate(id, query);
+  return res.length === 0 ? 0 : res[0].descendantCount;
 };
 };
 
 
+schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
+  const self = this;
+  const target = await this.findById(pageId);
+
+  async function findAncestorsRecursively(target, ancestors = shouldIncludeTarget ? [target] : []) {
+    const parent = await self.findOne({ _id: target.parent });
+    if (parent == null) {
+      return ancestors;
+    }
+
+    return findAncestorsRecursively(parent, [...ancestors, parent]);
+  }
+
+  return findAncestorsRecursively(target);
+};
+
+export type PageCreateOptions = {
+  format?: string
+  grantUserGroupId?: ObjectIdLike
+  grant?: number
+}
+
 /*
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
  */
@@ -443,8 +534,8 @@ export default (crowi: Crowi): any => {
     pageEvent = crowi.event('page');
     pageEvent = crowi.event('page');
   }
   }
 
 
-  schema.statics.create = async function(path, body, user, options = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null) {
+  schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
+    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
       throw Error('Crowi is not setup');
       throw Error('Crowi is not setup');
     }
     }
 
 
@@ -457,7 +548,7 @@ export default (crowi: Crowi): any => {
     const Page = this;
     const Page = this;
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
     const {
     const {
-      format = 'markdown', redirectTo, grantUserGroupId,
+      format = 'markdown', grantUserGroupId,
     } = options;
     } = options;
     let grant = options.grant;
     let grant = options.grant;
 
 
@@ -503,23 +594,24 @@ export default (crowi: Crowi): any => {
     let page;
     let page;
     if (emptyPage != null) {
     if (emptyPage != null) {
       page = emptyPage;
       page = emptyPage;
+      const descendantCount = await this.recountDescendantCount(page._id);
+
+      page.descendantCount = descendantCount;
       page.isEmpty = false;
       page.isEmpty = false;
     }
     }
     else {
     else {
       page = new Page();
       page = new Page();
     }
     }
 
 
-    let parentId: string | null = null;
-    const parentPath = nodePath.dirname(path);
-    const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
+    let parentId: IObjectId | string | null = null;
+    const parent = await Page.getParentAndFillAncestors(path);
     if (!isTopPage(path)) {
     if (!isTopPage(path)) {
-      parentId = await Page.getParentIdAndFillAncestors(path, parent);
+      parentId = parent._id;
     }
     }
 
 
     page.path = path;
     page.path = path;
     page.creator = user;
     page.creator = user;
     page.lastUpdateUser = user;
     page.lastUpdateUser = user;
-    page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
     page.status = STATUS_PUBLISHED;
 
 
     // set parent to null when GRANT_RESTRICTED
     // set parent to null when GRANT_RESTRICTED
@@ -534,12 +626,24 @@ export default (crowi: Crowi): any => {
 
 
     let savedPage = await page.save();
     let savedPage = await page.save();
 
 
+    await crowi.pageService.updateDescendantCountOfAncestors(page._id, 1, false);
+
     /*
     /*
      * After save
      * After save
      */
      */
+    // Delete PageRedirect if exists
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+    try {
+      await PageRedirect.deleteOne({ from: path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+    }
+    catch (err) {
+      // no throw
+      logger.error('Failed to delete PageRedirect');
+    }
+
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     pageEvent.emit('create', savedPage, user);
     pageEvent.emit('create', savedPage, user);
@@ -552,8 +656,9 @@ export default (crowi: Crowi): any => {
       throw Error('Crowi is not set up');
       throw Error('Crowi is not set up');
     }
     }
 
 
+    const isPageMigrated = pageData.parent != null;
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    if (!isV5Compatible) {
+    if (!isV5Compatible || !isPageMigrated) {
       // v4 compatible process
       // v4 compatible process
       return this.updatePageV4(pageData, body, previousBody, user, options);
       return this.updatePageV4(pageData, body, previousBody, user, options);
     }
     }
@@ -593,8 +698,7 @@ export default (crowi: Crowi): any => {
     // update existing page
     // update existing page
     let savedPage = await newPageData.save();
     let savedPage = await newPageData.save();
     const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
     const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
     await savedPage.populateDataToShowRevision();
 
 
     if (isSyncRevisionToHackmd) {
     if (isSyncRevisionToHackmd) {
@@ -611,7 +715,7 @@ export default (crowi: Crowi): any => {
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
 
 
-  return getOrCreateModel<PageDocument, PageModel>('Page', schema);
+  return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
 };
 };
 
 
 /*
 /*

+ 5 - 40
packages/app/src/server/models/revision.js

@@ -12,7 +12,8 @@ module.exports = function(crowi) {
 
 
   const ObjectId = mongoose.Schema.Types.ObjectId;
   const ObjectId = mongoose.Schema.Types.ObjectId;
   const revisionSchema = new mongoose.Schema({
   const revisionSchema = new mongoose.Schema({
-    path: { type: String, required: true, index: true },
+    // OBSOLETE path: { type: String, required: true, index: true }
+    pageId: { type: ObjectId, required: true, index: true },
     body: {
     body: {
       type: String,
       type: String,
       required: true,
       required: true,
@@ -29,25 +30,8 @@ module.exports = function(crowi) {
   });
   });
   revisionSchema.plugin(mongoosePaginate);
   revisionSchema.plugin(mongoosePaginate);
 
 
-  revisionSchema.statics.findRevisionIdList = function(path) {
-    return this.find({ path })
-      .select('_id author createdAt hasDiffToPrev')
-      .sort({ createdAt: -1 })
-      .exec();
-  };
-
-  revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options) {
-    const Revision = this;
-
-    return new Promise(((resolve, reject) => {
-      Revision.update({ path }, { $set: updateData }, { multi: true }, (err, data) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(data);
-      });
-    }));
+  revisionSchema.statics.updateRevisionListByPageId = async function(pageId, updateData) {
+    return this.updateMany({ pageId }, { $set: updateData });
   };
   };
 
 
   revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, options) {
   revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, options) {
@@ -64,7 +48,7 @@ module.exports = function(crowi) {
     }
     }
 
 
     const newRevision = new Revision();
     const newRevision = new Revision();
-    newRevision.path = pageData.path;
+    newRevision.pageId = pageData._id;
     newRevision.body = body;
     newRevision.body = body;
     newRevision.format = format;
     newRevision.format = format;
     newRevision.author = user._id;
     newRevision.author = user._id;
@@ -76,24 +60,5 @@ module.exports = function(crowi) {
     return newRevision;
     return newRevision;
   };
   };
 
 
-  revisionSchema.statics.removeRevisionsByPath = function(path) {
-    const Revision = this;
-
-    return new Promise(((resolve, reject) => {
-      Revision.remove({ path }, (err, data) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(data);
-      });
-    }));
-  };
-
-  revisionSchema.statics.findAuthorsByPage = async function(page) {
-    const result = await this.distinct('author', { path: page.path }).exec();
-    return result;
-  };
-
   return mongoose.model('Revision', revisionSchema);
   return mongoose.model('Revision', revisionSchema);
 };
 };

+ 8 - 9
packages/app/src/server/models/subscription.ts

@@ -3,11 +3,10 @@ import {
 } from 'mongoose';
 } from 'mongoose';
 
 
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
-import ActivityDefine from '../util/activityDefine';
 
 
-export const STATUS_SUBSCRIBE = 'SUBSCRIBE';
-export const STATUS_UNSUBSCRIBE = 'UNSUBSCRIBE';
-const STATUSES = [STATUS_SUBSCRIBE, STATUS_UNSUBSCRIBE];
+import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
+
+import ActivityDefine from '../util/activityDefine';
 
 
 export interface ISubscription {
 export interface ISubscription {
   user: Types.ObjectId
   user: Types.ObjectId
@@ -50,17 +49,17 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   status: {
   status: {
     type: String,
     type: String,
     require: true,
     require: true,
-    enum: STATUSES,
+    enum: AllSubscriptionStatusType,
   },
   },
   createdAt: { type: Date, default: new Date() },
   createdAt: { type: Date, default: new Date() },
 });
 });
 
 
 subscriptionSchema.methods.isSubscribing = function() {
 subscriptionSchema.methods.isSubscribing = function() {
-  return this.status === STATUS_SUBSCRIBE;
+  return this.status === SubscriptionStatusType.SUBSCRIBE;
 };
 };
 
 
 subscriptionSchema.methods.isUnsubscribing = function() {
 subscriptionSchema.methods.isUnsubscribing = function() {
-  return this.status === STATUS_UNSUBSCRIBE;
+  return this.status === SubscriptionStatusType.UNSUBSCRIBE;
 };
 };
 
 
 subscriptionSchema.statics.findByUserIdAndTargetId = function(userId, targetId) {
 subscriptionSchema.statics.findByUserIdAndTargetId = function(userId, targetId) {
@@ -81,11 +80,11 @@ subscriptionSchema.statics.subscribeByPageId = function(user, pageId, status) {
 };
 };
 
 
 subscriptionSchema.statics.getSubscription = async function(target) {
 subscriptionSchema.statics.getSubscription = async function(target) {
-  return this.find({ target, status: STATUS_SUBSCRIBE }).distinct('user');
+  return this.find({ target, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
 };
 };
 
 
 subscriptionSchema.statics.getUnsubscription = async function(target) {
 subscriptionSchema.statics.getUnsubscription = async function(target) {
-  return this.find({ target, status: STATUS_UNSUBSCRIBE }).distinct('user');
+  return this.find({ target, status: SubscriptionStatusType.UNSUBSCRIBE }).distinct('user');
 };
 };
 
 
 export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);
 export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);

+ 14 - 1
packages/app/src/server/routes/apiv3/import.js

@@ -1,3 +1,5 @@
+import mongoose from 'mongoose';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
@@ -204,8 +206,19 @@ module.exports = (crowi) => {
    */
    */
   router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
   router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
-
     const { fileName, collections, optionsMap } = req.body;
     const { fileName, collections, optionsMap } = req.body;
+
+    // pages collection can only be imported by upsert if isV5Compatible is true
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isImportPagesCollection = collections.includes('pages');
+    if (isV5Compatible && isImportPagesCollection) {
+      const option = new GrowiArchiveImportOption(null, optionsMap.pages);
+      if (option.mode !== 'upsert') {
+        return res.apiv3Err(new ErrorV3('Upsert is only available for importing pages collection.', 'only_upsert_available'));
+      }
+    }
+
+
     const zipFile = importService.getFile(fileName);
     const zipFile = importService.getFile(fileName);
 
 
     // return response first
     // return response first

+ 12 - 0
packages/app/src/server/routes/apiv3/overwrite-params/pages.js

@@ -1,4 +1,8 @@
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
+const { format } = require('date-fns');
+const { pagePathUtils } = require('@growi/core');
+
+const { isTopPage } = pagePathUtils;
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
 const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
@@ -41,6 +45,14 @@ class PageOverwriteParamsFactory {
       return value;
       return value;
     };
     };
 
 
+    params.parent = (value, { document, schema, propertyName }) => {
+      return null;
+    };
+
+    params.descendantCount = (value, { document, schema, propertyName }) => {
+      return 0;
+    };
+
     if (option.initPageMetadatas) {
     if (option.initPageMetadatas) {
       params.liker = [];
       params.liker = [];
       params.seenUsers = [];
       params.seenUsers = [];

+ 52 - 3
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -1,11 +1,15 @@
 import express, { Request, Router } from 'express';
 import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
 import { query, oneOf } from 'express-validator';
 
 
-import { PageDocument, PageModel } from '../../models/page';
+import mongoose from 'mongoose';
+
+import { PageModel } from '../../models/page';
 import ErrorV3 from '../../models/vo/error-apiv3';
 import ErrorV3 from '../../models/vo/error-apiv3';
 import loggerFactory from '../../../utils/logger';
 import loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 import Crowi from '../../crowi';
 import { ApiV3Response } from './interfaces/apiv3-response';
 import { ApiV3Response } from './interfaces/apiv3-response';
+import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import PageService from '../../service/page';
 
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
 
@@ -93,13 +97,58 @@ export default (crowi: Crowi): Router => {
     }
     }
   });
   });
 
 
+  // eslint-disable-next-line max-len
+  router.get('/info', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { pageIds } = req.query;
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Bookmark = crowi.model('Bookmark');
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const pageService: PageService = crowi.pageService!;
+
+    try {
+      const pages = await Page.findByIdsAndViewer(pageIds as string[], req.user, null, true);
+
+      const foundIds = pages.map(page => page._id);
+
+      const shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
+      const bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+
+      const idToPageInfoMap: Record<string, IPageInfoAll> = {};
+
+      for (const page of pages) {
+        // construct isIPageInfoForListing
+        const basicPageInfo = pageService.constructBasicPageInfo(page);
+
+        const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
+          ? basicPageInfo
+          // create IPageInfoForList
+          : {
+            ...basicPageInfo,
+            bookmarkCount: bookmarkCountMap[page._id],
+            revisionShortBody: shortBodiesMap[page._id],
+          } as IPageInfoForListing;
+
+        idToPageInfoMap[page._id] = pageInfo;
+      }
+
+      return res.apiv3(idToPageInfoMap);
+    }
+    catch (err) {
+      logger.error('Error occurred while fetching page informations.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while fetching page informations.'));
+    }
+  });
+
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.get('/short-bodies', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/short-bodies', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { pageIds } = req.query;
     const { pageIds } = req.query;
 
 
     try {
     try {
-      const shortBodiesMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds, req.user);
-      return res.apiv3({ shortBodiesMap });
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      // const shortBodiesMap = await crowi.pageService!.shortBodiesMapByPageIds(pageIds as string[], req.user);
+      // return res.apiv3({ shortBodiesMap });
+      return res.apiv3();
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error occurred while fetching shortBodiesMap.', err);
       logger.error('Error occurred while fetching shortBodiesMap.', err);

+ 51 - 71
packages/app/src/server/routes/apiv3/page.js

@@ -1,7 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import Subscription, { STATUS_SUBSCRIBE, STATUS_UNSUBSCRIBE } from '~/server/models/subscription';
+import { AllSubscriptionStatusType } from '~/interfaces/subscription';
+import Subscription from '~/server/models/subscription';
 
 
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 
@@ -9,7 +10,7 @@ const express = require('express');
 const { body, query } = require('express-validator');
 const { body, query } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
-const { convertToNewAffiliationPath } = pagePathUtils;
+const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 
 
@@ -74,10 +75,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: string
  *            type: string
  *            description: page path
  *            description: page path
  *            example: /
  *            example: /
- *          redirectTo:
- *            type: string
- *            description: redirect path
- *            example: ""
  *          revision:
  *          revision:
  *            type: string
  *            type: string
  *            description: page revision
  *            description: page revision
@@ -118,15 +115,11 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *        description: PageInfo
  *        description: PageInfo
  *        type: object
  *        type: object
  *        required:
  *        required:
- *          - isSeen
  *          - sumOfLikers
  *          - sumOfLikers
  *          - likerIds
  *          - likerIds
  *          - sumOfSeenUsers
  *          - sumOfSeenUsers
  *          - seenUserIds
  *          - seenUserIds
  *        properties:
  *        properties:
- *          isSeen:
- *            type: boolean
- *            description: Whether the page has ever been seen
  *          isLiked:
  *          isLiked:
  *            type: boolean
  *            type: boolean
  *            description: Whether the page is liked by the logged in user
  *            description: Whether the page is liked by the logged in user
@@ -166,10 +159,11 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
 
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
   const socketIoService = crowi.socketIoService;
-  const { Page, GlobalNotificationSetting } = crowi.models;
+  const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
   const { pageService, exportService } = crowi;
 
 
   const validator = {
   const validator = {
@@ -203,7 +197,7 @@ module.exports = (crowi) => {
     ],
     ],
     subscribe: [
     subscribe: [
       body('pageId').isString(),
       body('pageId').isString(),
-      body('status').isBoolean(),
+      body('status').isIn(AllSubscriptionStatusType),
     ],
     ],
     subscribeStatus: [
     subscribeStatus: [
       query('pageId').isString(),
       query('pageId').isString(),
@@ -361,27 +355,57 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.get('/info', loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+  router.get('/info', certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+    const { user, isSharedPage } = req;
     const { pageId } = req.query;
     const { pageId } = req.query;
 
 
     try {
     try {
-      const page = await Page.findById(pageId);
-
-      const guestUserResponse = {
-        sumOfLikers: page.liker.length,
-        likerIds: page.liker.slice(0, 15),
-        seenUserIds: page.seenUsers.slice(0, 15),
-        sumOfSeenUsers: page.seenUsers.length,
-        isSeen: page.seenUsers.length > 0,
-      };
+      const page = await Page.findByIdAndViewer(pageId, user, null, true);
+
+      if (page == null) {
+        return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
+      }
+
+      if (isSharedPage) {
+        return {
+          isEmpty: page.isEmpty,
+          isMovable: false,
+          isDeletable: false,
+          isAbleToDeleteCompletely: false,
+        };
+      }
 
 
       const isGuestUser = !req.user;
       const isGuestUser = !req.user;
+      const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
+
+      const bookmarkCount = await Bookmark.countByPageId(pageId);
+
+      const responseBodyForGuest = {
+        ...pageInfo,
+        bookmarkCount,
+      };
+
       if (isGuestUser) {
       if (isGuestUser) {
-        return res.apiv3(guestUserResponse);
+        return res.apiv3(responseBodyForGuest);
       }
       }
 
 
-      const userResponse = { ...guestUserResponse, isLiked: page.isLiked(req.user) };
-      return res.apiv3(userResponse);
+      const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
+      const isLiked = page.isLiked(user);
+      const isMovable = !isTopPage(page.path);
+      const isAbleToDeleteCompletely = pageService.canDeleteCompletely(page.creator?._id, user);
+
+      const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
+
+      const responseBody = {
+        ...responseBodyForGuest,
+        isMovable,
+        isAbleToDeleteCompletely,
+        isBookmarked,
+        isLiked,
+        subscriptionStatus: subscription?.status,
+      };
+
+      return res.apiv3(responseBody);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('get-page-info', err);
       logger.error('get-page-info', err);
@@ -612,9 +636,9 @@ module.exports = (crowi) => {
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
   router.put('/subscribe', accessTokenParser, loginRequiredStrictly, csrf, validator.subscribe, apiV3FormValidator, async(req, res) => {
   router.put('/subscribe', accessTokenParser, loginRequiredStrictly, csrf, validator.subscribe, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.body;
+    const { pageId, status } = req.body;
     const userId = req.user._id;
     const userId = req.user._id;
-    const status = req.body.status ? STATUS_SUBSCRIBE : STATUS_UNSUBSCRIBE;
+
     try {
     try {
       const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
       const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
       return res.apiv3({ subscription });
       return res.apiv3({ subscription });
@@ -625,49 +649,5 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  /**
-   * @swagger
-   *
-   *    /page/subscribe:
-   *      get:
-   *        tags: [Page]
-   *        summary: /page/subscribe
-   *        description: Get subscription status
-   *        operationId: getSubscriptionStatus
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  pageId:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get subscription status.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/Page'
-   *          500:
-   *            description: Internal server error.
-   */
-  router.get('/subscribe', loginRequiredStrictly, validator.subscribeStatus, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.query;
-    const userId = req.user._id;
-
-    const page = await Page.findById(pageId);
-    if (!page) throw new Error('Page not found');
-
-    try {
-      const subscription = await Subscription.findByUserIdAndTargetId(userId, pageId);
-      const subscribing = subscription ? subscription.isSubscribing() : null;
-      return res.apiv3({ subscribing });
-    }
-    catch (err) {
-      logger.error('Failed to ge subscribe status', err);
-      return res.apiv3(err, 500);
-    }
-  });
-
   return router;
   return router;
 };
 };

+ 45 - 37
packages/app/src/server/routes/apiv3/pages.js

@@ -110,10 +110,6 @@ const LIMIT_FOR_LIST = 10;
  *            type: string
  *            type: string
  *            description: page path
  *            description: page path
  *            example: /Sandbox/Math
  *            example: /Sandbox/Math
- *          redirectTo:
- *            type: string
- *            description: redirect path
- *            example: ""
  *          revision:
  *          revision:
  *            type: string
  *            type: string
  *            description: revision ID
  *            description: revision ID
@@ -174,20 +170,19 @@ module.exports = (crowi) => {
     ],
     ],
     renamePage: [
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
-      body('revisionId').isMongoId().withMessage('revisionId is required'),
+      body('revisionId').optional().isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
-      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
     ],
-
     duplicatePage: [
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
     ],
-    v5PageMigration: [
-      body('action').isString().withMessage('action is required'),
+    legacyPagesMigration: [
+      body('pageIds').isArray().withMessage('pageIds is required'),
+      body('isRecursively').isBoolean().withMessage('isRecursively is required'),
     ],
     ],
   };
   };
 
 
@@ -456,7 +451,7 @@ module.exports = (crowi) => {
    *            description: page path is already existed
    *            description: page path is already existed
    */
    */
   router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
   router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
-    const { pageId, isRecursively, revisionId } = req.body;
+    const { pageId, revisionId } = req.body;
 
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
 
 
@@ -466,7 +461,7 @@ module.exports = (crowi) => {
     };
     };
 
 
     if (!isCreatablePage(newPagePath)) {
     if (!isCreatablePage(newPagePath)) {
-      return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath})'`, 'invalid_path'), 409);
+      return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
     }
     }
 
 
     // check whether path starts slash
     // check whether path starts slash
@@ -481,16 +476,21 @@ module.exports = (crowi) => {
     let page;
     let page;
 
 
     try {
     try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
 
       if (page == null) {
       if (page == null) {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
       }
       }
 
 
-      if (!page.isUpdatable(revisionId)) {
+      // empty page does not require revisionId validation
+      if (!page.isEmpty && revisionId == null) {
+        return res.apiv3Err(new ErrorV3('revisionId must be a mongoId', 'invalid_body'), 400);
+      }
+
+      if (!page.isEmpty && !page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       }
-      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
+      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -527,7 +527,7 @@ module.exports = (crowi) => {
     const options = {};
     const options = {};
 
 
     try {
     try {
-      const pages = await crowi.pageService.deleteCompletelyDescendantsWithStream({ path: '/trash' }, req.user, options);
+      const pages = await crowi.pageService.emptyTrashPage(req.user, options);
       return res.apiv3({ pages });
       return res.apiv3({ pages });
     }
     }
     catch (err) {
     catch (err) {
@@ -627,21 +627,22 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
       return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
     }
     }
 
 
-    const page = await Page.findByIdAndViewer(pageId, req.user);
+    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
 
-    // null check
     if (page == null) {
     if (page == null) {
       res.code = 'Page is not found';
       res.code = 'Page is not found';
       logger.error('Failed to find the pages');
       logger.error('Failed to find the pages');
-      return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
+      return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
     }
     }
 
 
     const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
     const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
     const result = { page: serializePageSecurely(newParentPage) };
     const result = { page: serializePageSecurely(newParentPage) };
 
 
-    page.path = newPagePath;
+    // copy the page since it's used and updated in crowi.pageService.duplicate
+    const copyPage = { ...page };
+    copyPage.path = newPagePath;
     try {
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Create grobal notification failed', err);
       logger.error('Create grobal notification failed', err);
@@ -707,26 +708,13 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
-  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.v5PageMigration, apiV3FormValidator, async(req, res) => {
-    const { action, pageIds } = req.body;
+  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const Page = crowi.model('Page');
 
 
     try {
     try {
-      switch (action) {
-        case 'initialMigration':
-          if (!isV5Compatible) {
-            // this method throws and emit socketIo event when error occurs
-            crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
-          }
-          break;
-        case 'privateLegacyPages':
-          crowi.pageService.v5MigrationByPageIds(pageIds);
-          break;
-
-        default:
-          logger.error(`${action} action is not supported.`);
-          return res.apiv3Err(new ErrorV3('This action is not supported.', 'not_supported'), 400);
+      if (!isV5Compatible) {
+        // this method throws and emit socketIo event when error occurs
+        crowi.pageService.normalizeAllPublicPages(); // not await
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -736,6 +724,26 @@ module.exports = (crowi) => {
     return res.apiv3({ isV5Compatible });
     return res.apiv3({ isV5Compatible });
   });
   });
 
 
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds, isRecursively } = req.body;
+
+    if (isRecursively) {
+      // this method innerly uses socket to send message
+      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+    }
+    else {
+      try {
+        await crowi.pageService.normalizeParentByPageIds(pageIds);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+      }
+    }
+
+    return res.apiv3({});
+  });
+
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
     try {
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');

+ 1 - 1
packages/app/src/server/routes/apiv3/revisions.js

@@ -124,7 +124,7 @@ module.exports = (crowi) => {
       const page = await Page.findOne({ _id: pageId });
       const page = await Page.findOne({ _id: pageId });
 
 
       const paginateResult = await Revision.paginate(
       const paginateResult = await Revision.paginate(
-        { path: page.path },
+        { pageId: page._id },
         {
         {
           page: selectedPage,
           page: selectedPage,
           limit,
           limit,

+ 3 - 2
packages/app/src/server/routes/index.js

@@ -2,6 +2,7 @@ import express from 'express';
 
 
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
+import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 
 
 import * as loginFormValidator from '../middlewares/login-form-validator';
 import * as loginFormValidator from '../middlewares/login-form-validator';
 import * as registerFormValidator from '../middlewares/register-form-validator';
 import * as registerFormValidator from '../middlewares/register-form-validator';
@@ -166,8 +167,8 @@ module.exports = function(crowi, app) {
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   // allow posting to guests because the client doesn't know whether the user logged in
-  app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.api.remove); // (Avoid from API Token)
-  app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.api.revertRemove); // (Avoid from API Token)
+  app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
   app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
   app.get('/tags'                     , loginRequired, tag.showPage);
   app.get('/tags'                     , loginRequired, tag.showPage);

+ 49 - 39
packages/app/src/server/routes/page.js

@@ -1,8 +1,12 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
-import loggerFactory from '~/utils/logger';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
 
 
+import loggerFactory from '~/utils/logger';
+import { PageQueryBuilder } from '../models/obsolete-page';
 import UpdatePost from '../models/update-post';
 import UpdatePost from '../models/update-post';
+import { PageRedirectModel } from '../models/page-redirect';
 
 
 const { isCreatablePage, isTopPage } = pagePathUtils;
 const { isCreatablePage, isTopPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
@@ -70,10 +74,6 @@ const { serializeUserSecurely } = require('../models/serializers/user-serializer
  *            type: string
  *            type: string
  *            description: page path
  *            description: page path
  *            example: /
  *            example: /
- *          redirectTo:
- *            type: string
- *            description: redirect path
- *            example: ""
  *          revision:
  *          revision:
  *            $ref: '#/components/schemas/Revision'
  *            $ref: '#/components/schemas/Revision'
  *          status:
  *          status:
@@ -146,6 +146,7 @@ module.exports = function(crowi, app) {
   const PageTagRelation = crowi.model('PageTagRelation');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
   const ShareLink = crowi.model('ShareLink');
+  const PageRedirect = mongoose.model('PageRedirect');
 
 
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
   const getToday = require('../util/getToday');
@@ -283,25 +284,6 @@ module.exports = function(crowi, app) {
     renderVars.notFoundTargetPathOrId = pathOrId;
     renderVars.notFoundTargetPathOrId = pathOrId;
   }
   }
 
 
-  async function addRenderVarsForIdenticalPage(renderVars, pages) {
-    const pageIds = pages.map(p => p._id);
-    const shortBodyMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds);
-
-    const identicalPageDataList = await Promise.all(pages.map(async(page) => {
-      const bookmarkCount = await Bookmark.countByPageId(page._id);
-      page._doc.seenUserCount = (page.seenUsers && page.seenUsers.length) || 0;
-      return {
-        pageData: page,
-        pageMeta: {
-          bookmarkCount,
-        },
-      };
-    }));
-
-    renderVars.identicalPageDataList = identicalPageDataList;
-    renderVars.shortBodyMap = shortBodyMap;
-  }
-
   function replacePlaceholdersOfTemplate(template, req) {
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
     if (req.user == null) {
       return '';
       return '';
@@ -356,6 +338,7 @@ module.exports = function(crowi, app) {
     const offset = parseInt(req.query.offset) || 0;
     const offset = parseInt(req.query.offset) || 0;
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
+
     addRenderVarsWhenNotFound(renderVars, pathOrId);
     addRenderVarsWhenNotFound(renderVars, pathOrId);
 
 
     return res.render(view, renderVars);
     return res.render(view, renderVars);
@@ -442,11 +425,6 @@ module.exports = function(crowi, app) {
 
 
     const { path } = page; // this must exist
     const { path } = page; // this must exist
 
 
-    if (page.redirectTo) {
-      debug(`Redirect to '${page.redirectTo}'`);
-      return res.redirect(`${encodeURI(page.redirectTo)}?redirectFrom=${encodeURIComponent(path)}`);
-    }
-
     logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, path);
     logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, path);
 
 
     const limit = 50;
     const limit = 50;
@@ -622,17 +600,21 @@ module.exports = function(crowi, app) {
    * redirector
    * redirector
    */
    */
   async function redirector(req, res, next, path) {
   async function redirector(req, res, next, path) {
-    const pages = await Page.findByPathAndViewer(path, req.user, null, false, true);
     const { redirectFrom } = req.query;
     const { redirectFrom } = req.query;
 
 
-    if (pages.length >= 2) {
+    const builder = new PageQueryBuilder(Page.find({ path }));
+    await Page.addConditionToFilteringByViewerForList(builder, req.user);
 
 
-      const renderVars = {};
+    const pages = await builder.query.lean().clone().exec('find');
 
 
-      await addRenderVarsForIdenticalPage(renderVars, pages);
+    if (pages.length >= 2) {
+
+      // populate to list
+      builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+      const identicalPathPages = await builder.query.lean().exec('find');
 
 
       return res.render('layout-growi/identical-path-page', {
       return res.render('layout-growi/identical-path-page', {
-        ...renderVars,
+        identicalPathPages,
         redirectFrom,
         redirectFrom,
         path,
         path,
       });
       });
@@ -651,7 +633,18 @@ module.exports = function(crowi, app) {
       return res.safeRedirect(urljoin(url.pathname, url.search));
       return res.safeRedirect(urljoin(url.pathname, url.search));
     }
     }
 
 
-    req.isForbidden = await Page.count({ path }) > 0;
+    const isForbidden = await Page.exists({ path });
+    if (isForbidden) {
+      req.isForbidden = true;
+      return _notFound(req, res);
+    }
+
+    // redirect by PageRedirect
+    const pageRedirect = await PageRedirect.findOne({ fromPath: path });
+    if (pageRedirect != null) {
+      return res.safeRedirect(`${encodeURI(pageRedirect.toPath)}?redirectFrom=${encodeURIComponent(path)}`);
+    }
+
     return _notFound(req, res);
     return _notFound(req, res);
   }
   }
 
 
@@ -670,7 +663,10 @@ module.exports = function(crowi, app) {
 
 
 
 
   const api = {};
   const api = {};
+  const validator = {};
+
   actions.api = api;
   actions.api = api;
+  actions.validator = validator;
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -1163,6 +1159,11 @@ module.exports = function(crowi, app) {
       });
       });
   };
   };
 
 
+  validator.remove = [
+    body('completely').optional().custom(v => v === 'true' || v === true).withMessage('The body property "completely" must be "true" or true.'),
+    body('recursively').optional().custom(v => v === 'true' || v === true).withMessage('The body property "recursively" must be "true" or true.'),
+  ];
+
   /**
   /**
    * @api {post} /pages.remove Remove page
    * @api {post} /pages.remove Remove page
    * @apiName RemovePage
    * @apiName RemovePage
@@ -1176,13 +1177,13 @@ module.exports = function(crowi, app) {
     const previousRevision = req.body.revision_id || null;
     const previousRevision = req.body.revision_id || null;
 
 
     // get completely flag
     // get completely flag
-    const isCompletely = (req.body.completely != null);
+    const isCompletely = req.body.completely;
     // get recursively flag
     // get recursively flag
-    const isRecursively = (req.body.recursively != null);
+    const isRecursively = req.body.recursively;
 
 
     const options = {};
     const options = {};
 
 
-    const page = await Page.findByIdAndViewer(pageId, req.user);
+    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
 
     if (page == null) {
     if (page == null) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
@@ -1198,6 +1199,11 @@ module.exports = function(crowi, app) {
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
       }
       else {
       else {
+        const notRecursivelyAndEmpty = page.isEmpty && !isRecursively;
+        if (notRecursivelyAndEmpty) {
+          return res.json(ApiResponse.error(`Page '${pageId}' is not found.`, 'notfound'));
+        }
+
         if (!page.isUpdatable(previousRevision)) {
         if (!page.isUpdatable(previousRevision)) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
         }
@@ -1225,6 +1231,10 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  validator.revertRemove = [
+    body('recursively').optional().custom(v => v === 'true' || v === true).withMessage('The body property "recursively" must be "true" or true.'),
+  ];
+
   /**
   /**
    * @api {post} /pages.revertRemove Revert removed page
    * @api {post} /pages.revertRemove Revert removed page
    * @apiName RevertRemovePage
    * @apiName RevertRemovePage
@@ -1236,7 +1246,7 @@ module.exports = function(crowi, app) {
     const pageId = req.body.page_id;
     const pageId = req.body.page_id;
 
 
     // get recursively flag
     // get recursively flag
-    const isRecursively = (req.body.recursively != null);
+    const isRecursively = req.body.recursively;
 
 
     let page;
     let page;
     try {
     try {

+ 12 - 0
packages/app/src/server/service/import.js

@@ -182,6 +182,13 @@ class ImportService {
     // init status object
     // init status object
     this.currentProgressingStatus = new CollectionProgressingStatus(collections);
     this.currentProgressingStatus = new CollectionProgressingStatus(collections);
 
 
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isImportPagesCollection = collections.includes('pages');
+    const shouldNormalizePages = isV5Compatible && isImportPagesCollection;
+
+    // set isV5Compatible to false
+    if (shouldNormalizePages) await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': false });
+
     // process serially so as not to waste memory
     // process serially so as not to waste memory
     const promises = collections.map((collectionName) => {
     const promises = collections.map((collectionName) => {
       const importSettings = importSettingsMap[collectionName];
       const importSettings = importSettingsMap[collectionName];
@@ -199,6 +206,9 @@ class ImportService {
       }
       }
     }
     }
 
 
+    // run normalizeAllPublicPages
+    if (shouldNormalizePages) await this.crowi.pageService.normalizeAllPublicPages();
+
     this.currentProgressingStatus = null;
     this.currentProgressingStatus = null;
     this.emitTerminateEvent();
     this.emitTerminateEvent();
   }
   }
@@ -333,6 +343,8 @@ class ImportService {
 
 
     // upsert
     // upsert
     switch (collectionName) {
     switch (collectionName) {
+      case 'pages':
+        return bulk.find({ path: document.path }).upsert().replaceOne(document);
       default:
       default:
         return bulk.find({ _id: document._id }).upsert().replaceOne(document);
         return bulk.find({ _id: document._id }).upsert().replaceOne(document);
     }
     }

+ 4 - 3
packages/app/src/server/service/in-app-notification.ts

@@ -1,6 +1,6 @@
 import { Types } from 'mongoose';
 import { Types } from 'mongoose';
 import { subDays } from 'date-fns';
 import { subDays } from 'date-fns';
-import { InAppNotificationStatuses, PaginateResult, IInAppNotification } from '~/interfaces/in-app-notification';
+import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 import {
 import {
   InAppNotification,
   InAppNotification,
@@ -9,13 +9,14 @@ import {
 
 
 import { ActivityDocument } from '~/server/models/activity';
 import { ActivityDocument } from '~/server/models/activity';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
-import Subscription, { STATUS_SUBSCRIBE } from '~/server/models/subscription';
+import Subscription from '~/server/models/subscription';
 
 
 import { IUser } from '~/interfaces/user';
 import { IUser } from '~/interfaces/user';
 
 
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 
@@ -167,7 +168,7 @@ export default class InAppNotificationService {
     if (inAppNotificationSettings != null) {
     if (inAppNotificationSettings != null) {
       const subscribeRule = inAppNotificationSettings.subscribeRules.find(subscribeRule => subscribeRule.name === targetRuleName);
       const subscribeRule = inAppNotificationSettings.subscribeRules.find(subscribeRule => subscribeRule.name === targetRuleName);
       if (subscribeRule != null && subscribeRule.isEnabled) {
       if (subscribeRule != null && subscribeRule.isEnabled) {
-        await Subscription.subscribeByPageId(userId, pageId, STATUS_SUBSCRIBE);
+        await Subscription.subscribeByPageId(userId, pageId, SubscriptionStatusType.SUBSCRIBE);
       }
       }
     }
     }
 
 

+ 1 - 0
packages/app/src/server/service/installer.ts

@@ -115,6 +115,7 @@ export class InstallerService {
     const rootPage = await Page.findOne({ path: '/' });
     const rootPage = await Page.findOne({ path: '/' });
     const rootRevision = await Revision.findOne({ path: '/' });
     const rootRevision = await Revision.findOne({ path: '/' });
     rootPage.creator = adminUser._id;
     rootPage.creator = adminUser._id;
+    rootPage.lastUpdateUser = adminUser._id;
     rootRevision.creator = adminUser._id;
     rootRevision.creator = adminUser._id;
     await Promise.all([rootPage.save(), rootRevision.save()]);
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
 

+ 80 - 35
packages/app/src/server/service/page-grant.ts

@@ -3,34 +3,34 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 
 
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
-import { PageModel } from '~/server/models/page';
+import { PageDocument, PageModel } from '~/server/models/page';
 import { PageQueryBuilder } from '../models/obsolete-page';
 import { PageQueryBuilder } from '../models/obsolete-page';
-import { isIncludesObjectId, removeDuplicates, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
-type ObjectId = mongoose.Types.ObjectId;
+type ObjectIdLike = mongoose.Types.ObjectId | string;
 
 
 type ComparableTarget = {
 type ComparableTarget = {
   grant: number,
   grant: number,
-  grantedUserIds?: ObjectId[],
-  grantedGroupId: ObjectId,
-  applicableUserIds?: ObjectId[],
-  applicableGroupIds?: ObjectId[],
+  grantedUserIds?: ObjectIdLike[],
+  grantedGroupId?: ObjectIdLike,
+  applicableUserIds?: ObjectIdLike[],
+  applicableGroupIds?: ObjectIdLike[],
 };
 };
 
 
 type ComparableAncestor = {
 type ComparableAncestor = {
   grant: number,
   grant: number,
-  grantedUserIds: ObjectId[],
-  applicableUserIds?: ObjectId[],
-  applicableGroupIds?: ObjectId[],
+  grantedUserIds: ObjectIdLike[],
+  applicableUserIds?: ObjectIdLike[],
+  applicableGroupIds?: ObjectIdLike[],
 };
 };
 
 
 type ComparableDescendants = {
 type ComparableDescendants = {
   isPublicExist: boolean,
   isPublicExist: boolean,
-  grantedUserIds: ObjectId[],
-  grantedGroupIds: ObjectId[],
+  grantedUserIds: ObjectIdLike[],
+  grantedGroupIds: ObjectIdLike[],
 };
 };
 
 
 class PageGrantService {
 class PageGrantService {
@@ -42,7 +42,7 @@ class PageGrantService {
   }
   }
 
 
   private validateComparableTarget(comparable: ComparableTarget) {
   private validateComparableTarget(comparable: ComparableTarget) {
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const { grant, grantedUserIds, grantedGroupId } = comparable;
     const { grant, grantedUserIds, grantedGroupId } = comparable;
 
 
@@ -61,7 +61,7 @@ class PageGrantService {
   private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
   private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
     this.validateComparableTarget(target);
     this.validateComparableTarget(target);
 
 
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     /*
     /*
      * ancestor side
      * ancestor side
@@ -80,7 +80,7 @@ class PageGrantService {
         return false;
         return false;
       }
       }
 
 
-      if (!ancestor.grantedUserIds[0].equals(target.grantedUserIds[0])) { // the grantedUser must be the same as parent's under the GRANT_OWNER page
+      if (ancestor.grantedUserIds[0].toString() !== target.grantedUserIds[0].toString()) { // the grantedUser must be the same as parent's under the GRANT_OWNER page
         return false;
         return false;
       }
       }
     }
     }
@@ -105,6 +105,10 @@ class PageGrantService {
       }
       }
 
 
       if (target.grant === Page.GRANT_USER_GROUP) {
       if (target.grant === Page.GRANT_USER_GROUP) {
+        if (target.grantedGroupId == null) {
+          throw Error('grantedGroupId must not be null');
+        }
+
         if (!isIncludesObjectId(ancestor.applicableGroupIds, target.grantedGroupId)) { // only child groups or the same group can exist under GRANT_USER_GROUP page
         if (!isIncludesObjectId(ancestor.applicableGroupIds, target.grantedGroupId)) { // only child groups or the same group can exist under GRANT_USER_GROUP page
           return false;
           return false;
         }
         }
@@ -136,7 +140,7 @@ class PageGrantService {
         return false;
         return false;
       }
       }
 
 
-      if (descendants.grantedUserIds.length === 1 && !descendants.grantedUserIds[0].equals(target.grantedUserIds[0])) { // if Only me page exists, then all of them must be owned by the same user as the target page
+      if (descendants.grantedUserIds.length === 1 && descendants.grantedUserIds[0].toString() !== target.grantedUserIds[0].toString()) { // if Only me page exists, then all of them must be owned by the same user as the target page
         return false;
         return false;
       }
       }
     }
     }
@@ -165,14 +169,14 @@ class PageGrantService {
    * @returns Promise<ComparableAncestor>
    * @returns Promise<ComparableAncestor>
    */
    */
   private async generateComparableTarget(
   private async generateComparableTarget(
-      grant, grantedUserIds: ObjectId[] | undefined, grantedGroupId: ObjectId, includeApplicable: boolean,
+      grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupId: ObjectIdLike | undefined, includeApplicable: boolean,
   ): Promise<ComparableTarget> {
   ): Promise<ComparableTarget> {
     if (includeApplicable) {
     if (includeApplicable) {
-      const Page = mongoose.model('Page') as PageModel;
+      const Page = mongoose.model('Page') as unknown as PageModel;
       const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
       const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
 
-      let applicableUserIds: ObjectId[] | undefined;
-      let applicableGroupIds: ObjectId[] | undefined;
+      let applicableUserIds: ObjectIdLike[] | undefined;
+      let applicableGroupIds: ObjectIdLike[] | undefined;
 
 
       if (grant === Page.GRANT_USER_GROUP) {
       if (grant === Page.GRANT_USER_GROUP) {
         const targetUserGroup = await UserGroup.findOne({ _id: grantedGroupId });
         const targetUserGroup = await UserGroup.findOne({ _id: grantedGroupId });
@@ -208,17 +212,20 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @param targetPath string of the target path
    * @returns Promise<ComparableAncestor>
    * @returns Promise<ComparableAncestor>
    */
    */
-  private async generateComparableAncestor(targetPath: string): Promise<ComparableAncestor> {
-    const Page = mongoose.model('Page') as PageModel;
+  private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
 
-    let applicableUserIds: ObjectId[] | undefined;
-    let applicableGroupIds: ObjectId[] | undefined;
+    let applicableUserIds: ObjectIdLike[] | undefined;
+    let applicableGroupIds: ObjectIdLike[] | undefined;
 
 
     /*
     /*
      * make granted users list of ancestor's
      * make granted users list of ancestor's
      */
      */
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
+    if (!includeNotMigratedPages) {
+      builderForAncestors.addConditionAsMigrated();
+    }
     const ancestors = await builderForAncestors
     const ancestors = await builderForAncestors
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToSortPagesByDescPath()
       .addConditionToSortPagesByDescPath()
@@ -234,7 +241,7 @@ class PageGrantService {
       const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
       const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
       const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
       const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
       applicableGroupIds = grantedGroups.map(g => g._id);
       applicableGroupIds = grantedGroups.map(g => g._id);
-      applicableUserIds = Array.from(new Set(grantedRelations.map(r => r.relatedUser))) as ObjectId[];
+      applicableUserIds = Array.from(new Set(grantedRelations.map(r => r.relatedUser))) as ObjectIdLike[];
     }
     }
 
 
     return {
     return {
@@ -250,8 +257,8 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @param targetPath string of the target path
    * @returns ComparableDescendants
    * @returns ComparableDescendants
    */
    */
-  private async generateComparableDescendants(targetPath: string): Promise<ComparableDescendants> {
-    const Page = mongoose.model('Page') as PageModel;
+  private async generateComparableDescendants(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     /*
     /*
      * make granted users list of descendant's
      * make granted users list of descendant's
@@ -259,12 +266,17 @@ class PageGrantService {
     const pathWithTrailingSlash = addTrailingSlash(targetPath);
     const pathWithTrailingSlash = addTrailingSlash(targetPath);
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
 
 
+    const $match: any = {
+      path: new RegExp(`^${startsPattern}`),
+      isEmpty: { $ne: true },
+    };
+    if (includeNotMigratedPages) {
+      $match.parent = { $ne: null };
+    }
+
     const result = await Page.aggregate([
     const result = await Page.aggregate([
       { // match to descendants excluding empty pages
       { // match to descendants excluding empty pages
-        $match: {
-          path: new RegExp(`^${startsPattern}`),
-          isEmpty: { $ne: true },
-        },
+        $match,
       },
       },
       {
       {
         $project: {
         $project: {
@@ -292,7 +304,7 @@ class PageGrantService {
     const isPublicExist = result.some(r => r._id === Page.GRANT_PUBLIC);
     const isPublicExist = result.some(r => r._id === Page.GRANT_PUBLIC);
     // GRANT_OWNER group
     // GRANT_OWNER group
     const grantOwnerResult = result.filter(r => r._id === Page.GRANT_OWNER)[0]; // users of GRANT_OWNER
     const grantOwnerResult = result.filter(r => r._id === Page.GRANT_OWNER)[0]; // users of GRANT_OWNER
-    const grantedUserIds: ObjectId[] = grantOwnerResult?.grantedUsersSet ?? [];
+    const grantedUserIds: ObjectIdLike[] = grantOwnerResult?.grantedUsersSet ?? [];
     // GRANT_USER_GROUP group
     // GRANT_USER_GROUP group
     const grantUserGroupResult = result.filter(r => r._id === Page.GRANT_USER_GROUP)[0]; // users of GRANT_OWNER
     const grantUserGroupResult = result.filter(r => r._id === Page.GRANT_USER_GROUP)[0]; // users of GRANT_OWNER
     const grantedGroupIds = grantUserGroupResult?.grantedGroupSet ?? [];
     const grantedGroupIds = grantUserGroupResult?.grantedGroupSet ?? [];
@@ -306,16 +318,18 @@ class PageGrantService {
 
 
   /**
   /**
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * Only v5 schema pages will be used to compare.
    * @returns Promise<boolean>
    * @returns Promise<boolean>
    */
    */
   async isGrantNormalized(
   async isGrantNormalized(
-      targetPath: string, grant, grantedUserIds: ObjectId[] | undefined, grantedGroupId: ObjectId, shouldCheckDescendants = false,
+      // eslint-disable-next-line max-len
+      targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupId?: ObjectIdLike, shouldCheckDescendants = false, includeNotMigratedPages = false,
   ): Promise<boolean> {
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
     if (isTopPage(targetPath)) {
       return true;
       return true;
     }
     }
 
 
-    const comparableAncestor = await this.generateComparableAncestor(targetPath);
+    const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
 
     if (!shouldCheckDescendants) { // checking the parent is enough
     if (!shouldCheckDescendants) { // checking the parent is enough
       const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
       const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
@@ -323,11 +337,42 @@ class PageGrantService {
     }
     }
 
 
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
-    const comparableDescendants = await this.generateComparableDescendants(targetPath);
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, includeNotMigratedPages);
 
 
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
   }
   }
 
 
+  async separateNormalizedAndNonNormalizedPages(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+    const shouldCheckDescendants = true;
+    const shouldIncludeNotMigratedPages = true;
+
+    const normalizedPages: (PageDocument & { _id: any })[] = [];
+    const nonNormalizedPages: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
+
+    const builder = new PageQueryBuilder(Page.find());
+    builder.addConditionToListByPageIdsArray(pageIds);
+
+    const pages = await builder.query.exec();
+
+    for await (const page of pages) {
+      const {
+        path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+      } = page;
+
+      const isNormalized = await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages);
+      if (isNormalized) {
+        normalizedPages.push(page);
+      }
+      else {
+        nonNormalizedPages.push(page);
+      }
+    }
+
+    return [normalizedPages, nonNormalizedPages];
+  }
+
 }
 }
 
 
 export default PageGrantService;
 export default PageGrantService;

+ 0 - 1319
packages/app/src/server/service/page.js

@@ -1,1319 +0,0 @@
-import { pagePathUtils } from '@growi/core';
-
-import loggerFactory from '~/utils/logger';
-import { generateGrantCondition } from '~/server/models/page';
-
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
-
-import ActivityDefine from '../util/activityDefine';
-
-const mongoose = require('mongoose');
-const escapeStringRegexp = require('escape-string-regexp');
-const streamToPromise = require('stream-to-promise');
-const pathlib = require('path');
-
-const logger = loggerFactory('growi:services:page');
-const debug = require('debug')('growi:services:page');
-const { Writable } = require('stream');
-const { createBatchStream } = require('~/server/util/batch-stream');
-
-const {
-  isCreatablePage, isDeletablePage, isTrashPage, collectAncestorPaths,
-} = pagePathUtils;
-const { serializePageSecurely } = require('../models/serializers/page-serializer');
-
-const BULK_REINDEX_SIZE = 100;
-
-class PageService {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.pageEvent = crowi.event('page');
-    this.tagEvent = crowi.event('tag');
-
-    // init
-    this.initPageEvent();
-  }
-
-  initPageEvent() {
-    // create
-    this.pageEvent.on('create', this.pageEvent.onCreate);
-
-    // createMany
-    this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
-    this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
-
-    // update
-    this.pageEvent.on('update', async(page, user) => {
-
-      this.pageEvent.onUpdate();
-
-      try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_UPDATE);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // rename
-    this.pageEvent.on('rename', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_RENAME);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // delete
-    this.pageEvent.on('delete', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // delete completely
-    this.pageEvent.on('deleteCompletely', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE_COMPLETELY);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // likes
-    this.pageEvent.on('like', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_LIKE);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // bookmark
-    this.pageEvent.on('bookmark', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_BOOKMARK);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-  }
-
-  canDeleteCompletely(creatorId, operator) {
-    const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
-    if (operator.admin) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
-      const operatorId = operator?._id;
-      return (operatorId != null && operatorId.equals(creatorId));
-    }
-
-    return false;
-  }
-
-  async findPageAndMetaDataByViewer({ pageId, path, user }) {
-
-    const Page = this.crowi.model('Page');
-
-    let page;
-    if (pageId != null) { // prioritized
-      page = await Page.findByIdAndViewer(pageId, user);
-    }
-    else {
-      page = await Page.findByPathAndViewer(path, user);
-    }
-
-    const result = {};
-
-    if (page == null) {
-      const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
-      result.isForbidden = isExist;
-      result.isNotFound = !isExist;
-      result.isCreatable = isCreatablePage(path);
-      result.isDeletable = false;
-      result.canDeleteCompletely = false;
-      result.page = page;
-
-      return result;
-    }
-
-    result.page = page;
-    result.isForbidden = false;
-    result.isNotFound = false;
-    result.isCreatable = false;
-    result.isDeletable = isDeletablePage(path);
-    result.isDeleted = page.isDeleted();
-    result.canDeleteCompletely = user != null && this.canDeleteCompletely(page.creator, user);
-
-    return result;
-  }
-
-  /**
-   * go back by using redirectTo and return the paths
-   *  ex: when
-   *    '/page1' redirects to '/page2' and
-   *    '/page2' redirects to '/page3'
-   *    and given '/page3',
-   *    '/page1' and '/page2' will be return
-   *
-   * @param {string} redirectTo
-   * @param {object} redirectToPagePathMapping
-   * @param {array} pagePaths
-   */
-  prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths = []) {
-    const pagePath = redirectToPagePathMapping[redirectTo];
-
-    if (pagePath == null) {
-      return pagePaths;
-    }
-
-    pagePaths.push(pagePath);
-    return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
-  }
-
-  /**
-   * Generate read stream to operate descendants of the specified page path
-   * @param {string} targetPagePath
-   * @param {User} viewer
-   */
-  async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
-
-    const builder = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPagePath);
-
-    await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
-
-    return builder
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
-  }
-
-  async renamePage(page, newPagePath, user, options, isRecursively = false) {
-
-    const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
-    const path = page.path;
-    const createRedirectPage = options.createRedirectPage || false;
-    const updateMetadata = options.updateMetadata || false;
-
-    // sanitize path
-    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
-
-    // create descendants first
-    if (isRecursively) {
-      await this.renameDescendantsWithStream(page, newPagePath, user, options);
-    }
-
-    const update = {};
-    // update Page
-    update.path = newPagePath;
-    if (updateMetadata) {
-      update.lastUpdateUser = user;
-      update.updatedAt = Date.now();
-    }
-    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
-
-    // update Rivisions
-    await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
-
-    if (createRedirectPage) {
-      const body = `redirect ${newPagePath}`;
-      await Page.create(path, body, user, { redirectTo: newPagePath });
-    }
-
-    this.pageEvent.emit('rename', page, user);
-
-    return renamedPage;
-  }
-
-
-  async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
-    const Page = this.crowi.model('Page');
-
-    const pageCollection = mongoose.connection.collection('pages');
-    const revisionCollection = mongoose.connection.collection('revisions');
-    const { updateMetadata, createRedirectPage } = options;
-
-    const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
-    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
-
-    pages.forEach((page) => {
-      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
-      const revisionId = new mongoose.Types.ObjectId();
-
-      if (updateMetadata) {
-        unorderedBulkOp
-          .find({ _id: page._id })
-          .update({ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() } });
-      }
-      else {
-        unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
-      }
-      if (createRedirectPage) {
-        createRediectPageBulkOp.insert({
-          path: page.path, revision: revisionId, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
-        });
-        createRediectRevisionBulkOp.insert({
-          _id: revisionId, path: page.path, body: `redirect ${newPagePath}`, author: user._id, format: 'markdown',
-        });
-      }
-      revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
-    });
-
-    try {
-      await unorderedBulkOp.execute();
-      await revisionUnorderedBulkOp.execute();
-      // Execute after unorderedBulkOp to prevent duplication
-      if (createRedirectPage) {
-        await createRediectPageBulkOp.execute();
-        await createRediectRevisionBulkOp.execute();
-      }
-    }
-    catch (err) {
-      if (err.code !== 11000) {
-        throw new Error('Failed to rename pages: ', err);
-      }
-    }
-
-    this.pageEvent.emit('updateMany', pages, user);
-  }
-
-  /**
-   * Create rename stream
-   */
-  async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
-
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
-
-    const newPagePathPrefix = newPagePath;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
-
-    const renameDescendants = this.renameDescendants.bind(this);
-    const pageEvent = this.pageEvent;
-    let count = 0;
-    const writeStream = new Writable({
-      objectMode: true,
-      async write(batch, encoding, callback) {
-        try {
-          count += batch.length;
-          await renameDescendants(batch, user, options, pathRegExp, newPagePathPrefix);
-          logger.debug(`Reverting pages progressing: (count=${count})`);
-        }
-        catch (err) {
-          logger.error('revertPages error on add anyway: ', err);
-        }
-
-        callback();
-      },
-      final(callback) {
-        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
-        // update  path
-        targetPage.path = newPagePath;
-        pageEvent.emit('syncDescendantsUpdate', targetPage, user);
-        callback();
-      },
-    });
-
-    readStream
-      .pipe(createBatchStream(BULK_REINDEX_SIZE))
-      .pipe(writeStream);
-
-    await streamToPromise(readStream);
-  }
-
-
-  async deleteCompletelyOperation(pageIds, pagePaths) {
-    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    const Bookmark = this.crowi.model('Bookmark');
-    const Comment = this.crowi.model('Comment');
-    const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
-    const ShareLink = this.crowi.model('ShareLink');
-    const Revision = this.crowi.model('Revision');
-    const Attachment = this.crowi.model('Attachment');
-
-    const { attachmentService } = this.crowi;
-    const attachments = await Attachment.find({ page: { $in: pageIds } });
-
-    const pages = await Page.find({ redirectTo: { $ne: null } });
-    const redirectToPagePathMapping = {};
-    pages.forEach((page) => {
-      redirectToPagePathMapping[page.redirectTo] = page.path;
-    });
-
-    const redirectedFromPagePaths = [];
-    pagePaths.forEach((pagePath) => {
-      redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
-    });
-
-    return Promise.all([
-      Bookmark.deleteMany({ page: { $in: pageIds } }),
-      Comment.deleteMany({ page: { $in: pageIds } }),
-      PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
-      ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
-      Revision.deleteMany({ path: { $in: pagePaths } }),
-      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } }] }),
-      attachmentService.removeAllAttachments(attachments),
-    ]);
-  }
-
-  async duplicate(page, newPagePath, user, isRecursively) {
-    const Page = this.crowi.model('Page');
-    const PageTagRelation = mongoose.model('PageTagRelation');
-    // populate
-    await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
-
-    // create option
-    const options = { page };
-    options.grant = page.grant;
-    options.grantUserGroupId = page.grantedGroup;
-    options.grantedUserIds = page.grantedUsers;
-
-    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
-
-    const createdPage = await Page.create(
-      newPagePath, page.revision.body, user, options,
-    );
-
-    if (isRecursively) {
-      this.duplicateDescendantsWithStream(page, newPagePath, user);
-    }
-
-    // take over tags
-    const originTags = await page.findRelatedTagsById();
-    let savedTags = [];
-    if (originTags != null) {
-      await PageTagRelation.updatePageTags(createdPage.id, originTags);
-      savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
-      this.tagEvent.emit('update', createdPage, savedTags);
-    }
-
-    const result = serializePageSecurely(createdPage);
-    result.tags = savedTags;
-
-    return result;
-  }
-
-  /**
-   * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
-   * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
-   */
-  async duplicateTags(pageIdMapping) {
-    const PageTagRelation = mongoose.model('PageTagRelation');
-
-    // convert pageId from string to ObjectId
-    const pageIds = Object.keys(pageIdMapping);
-    const stage = { $or: pageIds.map((pageId) => { return { relatedPage: mongoose.Types.ObjectId(pageId) } }) };
-
-    const pagesAssociatedWithTag = await PageTagRelation.aggregate([
-      {
-        $match: stage,
-      },
-      {
-        $group: {
-          _id: '$relatedTag',
-          relatedPages: { $push: '$relatedPage' },
-        },
-      },
-    ]);
-
-    const newPageTagRelation = [];
-    pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
-      // relatedPages
-      relatedPages.forEach((pageId) => {
-        newPageTagRelation.push({
-          relatedPage: pageIdMapping[pageId], // newPageId
-          relatedTag: _id,
-        });
-      });
-    });
-
-    return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
-  }
-
-  async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix) {
-    const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
-
-    const paths = pages.map(page => (page.path));
-    const revisions = await Revision.find({ path: { $in: paths } });
-
-    // Mapping to set to the body of the new revision
-    const pathRevisionMapping = {};
-    revisions.forEach((revision) => {
-      pathRevisionMapping[revision.path] = revision;
-    });
-
-    // key: oldPageId, value: newPageId
-    const pageIdMapping = {};
-    const newPages = [];
-    const newRevisions = [];
-
-    pages.forEach((page) => {
-      const newPageId = new mongoose.Types.ObjectId();
-      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
-      const revisionId = new mongoose.Types.ObjectId();
-      pageIdMapping[page._id] = newPageId;
-
-      newPages.push({
-        _id: newPageId,
-        path: newPagePath,
-        creator: user._id,
-        grant: page.grant,
-        grantedGroup: page.grantedGroup,
-        grantedUsers: page.grantedUsers,
-        lastUpdateUser: user._id,
-        redirectTo: null,
-        revision: revisionId,
-      });
-
-      newRevisions.push({
-        _id: revisionId, path: newPagePath, body: pathRevisionMapping[page.path].body, author: user._id, format: 'markdown',
-      });
-
-    });
-
-    await Page.insertMany(newPages, { ordered: false });
-    await Revision.insertMany(newRevisions, { ordered: false });
-    await this.duplicateTags(pageIdMapping);
-  }
-
-  async duplicateDescendantsWithStream(page, newPagePath, user) {
-
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
-
-    const newPagePathPrefix = newPagePath;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
-
-    const duplicateDescendants = this.duplicateDescendants.bind(this);
-    const pageEvent = this.pageEvent;
-    let count = 0;
-    const writeStream = new Writable({
-      objectMode: true,
-      async write(batch, encoding, callback) {
-        try {
-          count += batch.length;
-          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
-          logger.debug(`Adding pages progressing: (count=${count})`);
-        }
-        catch (err) {
-          logger.error('addAllPages error on add anyway: ', err);
-        }
-
-        callback();
-      },
-      final(callback) {
-        logger.debug(`Adding pages has completed: (totalCount=${count})`);
-        // update  path
-        page.path = newPagePath;
-        pageEvent.emit('syncDescendantsUpdate', page, user);
-        callback();
-      },
-    });
-
-    readStream
-      .pipe(createBatchStream(BULK_REINDEX_SIZE))
-      .pipe(writeStream);
-
-  }
-
-
-  async deletePage(page, user, options = {}, isRecursively = false) {
-    const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
-    const Revision = this.crowi.model('Revision');
-
-    const newPath = Page.getDeletedPageName(page.path);
-    const isTrashed = isTrashPage(page.path);
-
-    if (isTrashed) {
-      throw new Error('This method does NOT support deleting trashed pages.');
-    }
-
-    if (!Page.isDeletableName(page.path)) {
-      throw new Error('Page is not deletable.');
-    }
-
-    if (isRecursively) {
-      this.deleteDescendantsWithStream(page, user, options);
-    }
-
-    // update Rivisions
-    await Revision.updateRevisionListByPath(page.path, { path: newPath }, {});
-    const deletedPage = await Page.findByIdAndUpdate(page._id, {
-      $set: {
-        path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
-      },
-    }, { new: true });
-    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
-    const body = `redirect ${newPath}`;
-    await Page.create(page.path, body, user, { redirectTo: newPath });
-
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', deletedPage, user);
-
-    return deletedPage;
-  }
-
-  async deleteDescendants(pages, user) {
-    const Page = this.crowi.model('Page');
-
-    const pageCollection = mongoose.connection.collection('pages');
-    const revisionCollection = mongoose.connection.collection('revisions');
-
-    const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
-    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
-    const newPagesForRedirect = [];
-
-    pages.forEach((page) => {
-      const newPath = Page.getDeletedPageName(page.path);
-      const revisionId = new mongoose.Types.ObjectId();
-      const body = `redirect ${newPath}`;
-
-      deletePageBulkOp.find({ _id: page._id }).update({
-        $set: {
-          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
-        },
-      });
-      updateRevisionListOp.find({ path: page.path }).update({ $set: { path: newPath } });
-      createRediectRevisionBulkOp.insert({
-        _id: revisionId, path: page.path, body, author: user._id, format: 'markdown',
-      });
-
-      newPagesForRedirect.push({
-        path: page.path,
-        creator: user._id,
-        grant: page.grant,
-        grantedGroup: page.grantedGroup,
-        grantedUsers: page.grantedUsers,
-        lastUpdateUser: user._id,
-        redirectTo: newPath,
-        revision: revisionId,
-      });
-    });
-
-    try {
-      await deletePageBulkOp.execute();
-      await updateRevisionListOp.execute();
-      await createRediectRevisionBulkOp.execute();
-      await Page.insertMany(newPagesForRedirect, { ordered: false });
-    }
-    catch (err) {
-      if (err.code !== 11000) {
-        throw new Error('Failed to revert pages: ', err);
-      }
-    }
-    finally {
-      this.pageEvent.emit('syncDescendantsDelete', pages, user);
-    }
-  }
-
-  /**
-   * Create delete stream
-   */
-  async deleteDescendantsWithStream(targetPage, user, options = {}) {
-
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
-
-    const deleteDescendants = this.deleteDescendants.bind(this);
-    let count = 0;
-    const writeStream = new Writable({
-      objectMode: true,
-      async write(batch, encoding, callback) {
-        try {
-          count += batch.length;
-          deleteDescendants(batch, user);
-          logger.debug(`Reverting pages progressing: (count=${count})`);
-        }
-        catch (err) {
-          logger.error('revertPages error on add anyway: ', err);
-        }
-
-        callback();
-      },
-      final(callback) {
-        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
-
-        callback();
-      },
-    });
-
-    readStream
-      .pipe(createBatchStream(BULK_REINDEX_SIZE))
-      .pipe(writeStream);
-  }
-
-  // delete multiple pages
-  async deleteMultipleCompletely(pages, user, options = {}) {
-    const ids = pages.map(page => (page._id));
-    const paths = pages.map(page => (page.path));
-
-    logger.debug('Deleting completely', paths);
-
-    await this.deleteCompletelyOperation(ids, paths);
-
-    this.pageEvent.emit('syncDescendantsDelete', pages, user); // update as renamed page
-
-    return;
-  }
-
-  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
-    const ids = [page._id];
-    const paths = [page.path];
-
-    logger.debug('Deleting completely', paths);
-
-    await this.deleteCompletelyOperation(ids, paths);
-
-    if (isRecursively) {
-      this.deleteCompletelyDescendantsWithStream(page, user, options);
-    }
-
-    if (!preventEmitting) {
-      this.pageEvent.emit('deleteCompletely', page, user);
-    }
-
-    return;
-  }
-
-  /**
-   * Create delete completely stream
-   */
-  async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
-
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
-
-    const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
-    let count = 0;
-    const writeStream = new Writable({
-      objectMode: true,
-      async write(batch, encoding, callback) {
-        try {
-          count += batch.length;
-          await deleteMultipleCompletely(batch, user, options);
-          logger.debug(`Adding pages progressing: (count=${count})`);
-        }
-        catch (err) {
-          logger.error('addAllPages error on add anyway: ', err);
-        }
-
-        callback();
-      },
-      final(callback) {
-        logger.debug(`Adding pages has completed: (totalCount=${count})`);
-
-        callback();
-      },
-    });
-
-    readStream
-      .pipe(createBatchStream(BULK_REINDEX_SIZE))
-      .pipe(writeStream);
-  }
-
-  async revertDeletedDescendants(pages, user) {
-    const Page = this.crowi.model('Page');
-    const pageCollection = mongoose.connection.collection('pages');
-    const revisionCollection = mongoose.connection.collection('revisions');
-
-    const removePageBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const revertPageBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const revertRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
-
-    // e.g. key: '/test'
-    const pathToPageMapping = {};
-    const toPaths = pages.map(page => Page.getRevertDeletedPageName(page.path));
-    const toPages = await Page.find({ path: { $in: toPaths } });
-    toPages.forEach((toPage) => {
-      pathToPageMapping[toPage.path] = toPage;
-    });
-
-    pages.forEach((page) => {
-
-      // e.g. page.path = /trash/test, toPath = /test
-      const toPath = Page.getRevertDeletedPageName(page.path);
-
-      if (pathToPageMapping[toPath] != null) {
-      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
-      // So, it's ok to delete the page
-      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
-        if (pathToPageMapping[toPath].redirectTo === page.path) {
-          removePageBulkOp.find({ path: toPath }).delete();
-        }
-      }
-      revertPageBulkOp.find({ _id: page._id }).update({
-        $set: {
-          path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
-        },
-      });
-      revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } }, { multi: true });
-    });
-
-    try {
-      await removePageBulkOp.execute();
-      await revertPageBulkOp.execute();
-      await revertRevisionBulkOp.execute();
-    }
-    catch (err) {
-      if (err.code !== 11000) {
-        throw new Error('Failed to revert pages: ', err);
-      }
-    }
-  }
-
-  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
-    const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
-    const Revision = this.crowi.model('Revision');
-
-    const newPath = Page.getRevertDeletedPageName(page.path);
-    const originPage = await Page.findByPath(newPath);
-    if (originPage != null) {
-      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
-      // So, it's ok to delete the page
-      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
-      if (originPage.redirectTo !== page.path) {
-        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
-      }
-
-      await this.deleteCompletely(originPage, user, options, false, true);
-      this.pageEvent.emit('revert', page, user);
-    }
-
-    if (isRecursively) {
-      this.revertDeletedDescendantsWithStream(page, user, options);
-    }
-
-    page.status = Page.STATUS_PUBLISHED;
-    page.lastUpdateUser = user;
-    debug('Revert deleted the page', page, newPath);
-    const updatedPage = await Page.findByIdAndUpdate(page._id, {
-      $set: {
-        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
-      },
-    }, { new: true });
-    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
-    await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
-
-    return updatedPage;
-  }
-
-  /**
-   * Create revert stream
-   */
-  async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
-
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
-
-    const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
-    let count = 0;
-    const writeStream = new Writable({
-      objectMode: true,
-      async write(batch, encoding, callback) {
-        try {
-          count += batch.length;
-          revertDeletedDescendants(batch, user);
-          logger.debug(`Reverting pages progressing: (count=${count})`);
-        }
-        catch (err) {
-          logger.error('revertPages error on add anyway: ', err);
-        }
-
-        callback();
-      },
-      final(callback) {
-        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
-
-        callback();
-      },
-    });
-
-    readStream
-      .pipe(createBatchStream(BULK_REINDEX_SIZE))
-      .pipe(writeStream);
-  }
-
-
-  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user) {
-    const Page = this.crowi.model('Page');
-    const pages = await Page.find({ grantedGroup: { $in: groupsToDelete } });
-
-    let operationsToPublicize;
-    switch (action) {
-      case 'public':
-        await Page.publicizePages(pages);
-        break;
-      case 'delete':
-        return this.deleteMultipleCompletely(pages, user);
-      case 'transfer':
-        await Page.transferPagesToGroup(pages, transferToUserGroupId);
-        break;
-      default:
-        throw new Error('Unknown action for private pages');
-    }
-  }
-
-  async shortBodiesMapByPageIds(pageIds = [], user) {
-    const Page = mongoose.model('Page');
-    const MAX_LENGTH = 350;
-
-    // aggregation options
-    const viewerCondition = await generateGrantCondition(user, null);
-    const filterByIds = {
-      _id: { $in: pageIds.map(id => mongoose.Types.ObjectId(id)) },
-    };
-
-    let pages;
-    try {
-      pages = await Page
-        .aggregate([
-          // filter by pageIds
-          {
-            $match: filterByIds,
-          },
-          // filter by viewer
-          viewerCondition,
-          // lookup: https://docs.mongodb.com/v4.4/reference/operator/aggregation/lookup/
-          {
-            $lookup: {
-              from: 'revisions',
-              let: { localRevision: '$revision' },
-              pipeline: [
-                {
-                  $match: {
-                    $expr: {
-                      $eq: ['$_id', '$$localRevision'],
-                    },
-                  },
-                },
-                {
-                  $project: {
-                    // What is $substrCP?
-                    // see: https://stackoverflow.com/questions/43556024/mongodb-error-substrbytes-invalid-range-ending-index-is-in-the-middle-of-a-ut/43556249
-                    revision: { $substrCP: ['$body', 0, MAX_LENGTH] },
-                  },
-                },
-              ],
-              as: 'revisionData',
-            },
-          },
-          // projection
-          {
-            $project: {
-              _id: 1,
-              revisionData: 1,
-            },
-          },
-        ]).exec();
-    }
-    catch (err) {
-      logger.error('Error occurred while generating shortBodiesMap');
-      throw err;
-    }
-
-    const shortBodiesMap = {};
-    pages.forEach((page) => {
-      shortBodiesMap[page._id] = page.revisionData?.[0]?.revision;
-    });
-
-    return shortBodiesMap;
-  }
-
-  validateCrowi() {
-    if (this.crowi == null) {
-      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
-    }
-  }
-
-  createAndSendNotifications = async function(page, user, action) {
-    const { activityService, inAppNotificationService } = this.crowi;
-
-    const snapshot = stringifySnapshot(page);
-
-    // Create activity
-    const parameters = {
-      user: user._id,
-      targetModel: ActivityDefine.MODEL_PAGE,
-      target: page,
-      action,
-    };
-    const activity = await activityService.createByParameters(parameters);
-
-    // Get user to be notified
-    const targetUsers = await activity.getNotificationTargetUsers();
-
-    // Create and send notifications
-    await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
-    await inAppNotificationService.emitSocketIo(targetUsers);
-  };
-
-  async v5MigrationByPageIds(pageIds) {
-    const Page = mongoose.model('Page');
-
-    if (pageIds == null || pageIds.length === 0) {
-      logger.error('pageIds is null or 0 length.');
-      return;
-    }
-
-    // generate regexps
-    const regexps = await this._generateRegExpsByPageIds(pageIds);
-
-    // migrate recursively
-    try {
-      await this._v5RecursiveMigration(null, regexps);
-    }
-    catch (err) {
-      logger.error('V5 initial miration failed.', err);
-      // socket.emit('v5InitialMirationFailed', { error: err.message }); TODO: use socket to tell user
-
-      throw err;
-    }
-  }
-
-  async _isPagePathIndexUnique() {
-    const Page = this.crowi.model('Page');
-    const now = (new Date()).toString();
-    const path = `growi_check_is_path_index_unique_${now}`;
-
-    let isUnique = false;
-
-    try {
-      await Page.insertMany([
-        { path },
-        { path },
-      ]);
-    }
-    catch (err) {
-      if (err?.code === 11000) { // Error code 11000 indicates the index is unique
-        isUnique = true;
-        logger.info('Page path index is unique.');
-      }
-      else {
-        throw err;
-      }
-    }
-    finally {
-      await Page.deleteMany({ path: { $regex: new RegExp('growi_check_is_path_index_unique', 'g') } });
-    }
-
-
-    return isUnique;
-  }
-
-  // TODO: use socket to send status to the client
-  async v5InitialMigration(grant) {
-    // const socket = this.crowi.socketIoService.getAdminSocket();
-
-    let isUnique;
-    try {
-      isUnique = await this._isPagePathIndexUnique();
-    }
-    catch (err) {
-      logger.error('Failed to check path index status', err);
-      throw err;
-    }
-
-    // drop unique index first
-    if (isUnique) {
-      try {
-        await this._v5NormalizeIndex();
-      }
-      catch (err) {
-        logger.error('V5 index normalization failed.', err);
-        // socket.emit('v5IndexNormalizationFailed', { error: err.message });
-        throw err;
-      }
-    }
-
-    // then migrate
-    try {
-      await this._v5RecursiveMigration(grant, null, true);
-    }
-    catch (err) {
-      logger.error('V5 initial miration failed.', err);
-      // socket.emit('v5InitialMirationFailed', { error: err.message });
-
-      throw err;
-    }
-
-    // update descendantCount of all public pages
-    try {
-      await this.updateDescendantCountOfSelfAndDescendants('/');
-      logger.info('Successfully updated all descendantCount of public pages.');
-    }
-    catch (err) {
-      logger.error('Failed updating descendantCount of public pages.', err);
-      throw err;
-    }
-
-    await this._setIsV5CompatibleTrue();
-  }
-
-  /*
-   * returns an array of js RegExp instance instead of RE2 instance for mongo filter
-   */
-  async _generateRegExpsByPageIds(pageIds) {
-    const Page = mongoose.model('Page');
-
-    let result;
-    try {
-      result = await Page.findListByPageIds(pageIds, null, false);
-    }
-    catch (err) {
-      logger.error('Failed to find pages by ids', err);
-      throw err;
-    }
-
-    const { pages } = result;
-    const regexps = pages.map(page => new RegExp(`^${page.path}`));
-
-    return regexps;
-  }
-
-  async _setIsV5CompatibleTrue() {
-    try {
-      await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
-        'app:isV5Compatible': true,
-      });
-      logger.info('Successfully migrated all public pages.');
-    }
-    catch (err) {
-      logger.warn('Failed to update app:isV5Compatible to true.');
-      throw err;
-    }
-  }
-
-  // TODO: use websocket to show progress
-  async _v5RecursiveMigration(grant, regexps, publicOnly = false) {
-    const BATCH_SIZE = 100;
-    const PAGES_LIMIT = 1000;
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
-
-    // generate filter
-    let filter = {
-      parent: null,
-      path: { $ne: '/' },
-    };
-    if (grant != null) {
-      filter = {
-        ...filter,
-        grant,
-      };
-    }
-    if (regexps != null && regexps.length !== 0) {
-      filter = {
-        ...filter,
-        path: {
-          $in: regexps,
-        },
-      };
-    }
-
-    const total = await Page.countDocuments(filter);
-
-    let baseAggregation = Page
-      .aggregate([
-        {
-          $match: filter,
-        },
-        {
-          $project: { // minimize data to fetch
-            _id: 1,
-            path: 1,
-          },
-        },
-      ]);
-
-    // limit pages to get
-    if (total > PAGES_LIMIT) {
-      baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
-    }
-
-    const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
-
-    // use batch stream
-    const batchStream = createBatchStream(BATCH_SIZE);
-
-    let countPages = 0;
-    let shouldContinue = true;
-
-    // migrate all siblings for each page
-    const migratePagesStream = new Writable({
-      objectMode: true,
-      async write(pages, encoding, callback) {
-        // make list to create empty pages
-        const parentPathsSet = new Set(pages.map(page => pathlib.dirname(page.path)));
-        const parentPaths = Array.from(parentPathsSet);
-
-        // fill parents with empty pages
-        await Page.createEmptyPagesByPaths(parentPaths, publicOnly);
-
-        // find parents again
-        const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
-        const parents = await builder
-          .addConditionToListByPathsArray(parentPaths)
-          .query
-          .lean()
-          .exec();
-
-        // bulkWrite to update parent
-        const updateManyOperations = parents.map((parent) => {
-          const parentId = parent._id;
-
-          // modify to adjust for RegExp
-          let parentPath = parent.path === '/' ? '' : parent.path;
-          parentPath = escapeStringRegexp(parentPath);
-
-          const filter = {
-            // regexr.com/6889f
-            // ex. /parent/any_child OR /any_level1
-            path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'i') },
-          };
-          if (grant != null) {
-            filter.grant = grant;
-          }
-
-          return {
-            updateMany: {
-              filter,
-              update: {
-                parent: parentId,
-              },
-            },
-          };
-        });
-        try {
-          const res = await Page.bulkWrite(updateManyOperations);
-          countPages += res.result.nModified;
-          logger.info(`Page migration processing: (count=${countPages})`);
-
-          // throw
-          if (res.result.writeErrors.length > 0) {
-            logger.error('Failed to migrate some pages', res.result.writeErrors);
-            throw Error('Failed to migrate some pages');
-          }
-
-          // finish migration
-          if (res.result.nModified === 0 && res.result.nMatched === 0) {
-            shouldContinue = false;
-            logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
-          }
-        }
-        catch (err) {
-          logger.error('Failed to update page.parent.', err);
-          throw err;
-        }
-
-        callback();
-      },
-      final(callback) {
-        callback();
-      },
-    });
-
-    pagesStream
-      .pipe(batchStream)
-      .pipe(migratePagesStream);
-
-    await streamToPromise(migratePagesStream);
-
-    if (await Page.exists(filter) && shouldContinue) {
-      return this._v5RecursiveMigration(grant, regexps, publicOnly);
-    }
-
-  }
-
-  async _v5NormalizeIndex() {
-    const collection = mongoose.connection.collection('pages');
-
-    try {
-      // drop pages.path_1 indexes
-      await collection.dropIndex('path_1');
-      logger.info('Succeeded to drop unique indexes from pages.path.');
-    }
-    catch (err) {
-      logger.warn('Failed to drop unique indexes from pages.path.', err);
-      throw err;
-    }
-
-    try {
-      // create indexes without
-      await collection.createIndex({ path: 1 }, { unique: false });
-      logger.info('Succeeded to create non-unique indexes on pages.path.');
-    }
-    catch (err) {
-      logger.warn('Failed to create non-unique indexes on pages.path.', err);
-      throw err;
-    }
-  }
-
-  async v5MigratablePrivatePagesCount(user) {
-    if (user == null) {
-      throw Error('user is required');
-    }
-    const Page = this.crowi.model('Page');
-    return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
-  }
-
-  /**
-   * update descendantCount of the following pages
-   * - page that has the same path as the provided path
-   * - pages that are descendants of the above page
-   */
-  async updateDescendantCountOfSelfAndDescendants(path = '/') {
-    const BATCH_SIZE = 200;
-    const Page = this.crowi.model('Page');
-
-    const aggregateCondition = Page.getAggrConditionForPageWithProvidedPathAndDescendants(path);
-    const aggregatedPages = await Page.aggregate(aggregateCondition).cursor({ batchSize: BATCH_SIZE });
-
-    const recountWriteStream = new Writable({
-      objectMode: true,
-      async write(pageDocuments, encoding, callback) {
-        for (const document of pageDocuments) {
-          // eslint-disable-next-line no-await-in-loop
-          await Page.recountDescendantCountOfSelfAndDescendants(document._id);
-        }
-        callback();
-      },
-      final(callback) {
-        callback();
-      },
-    });
-    aggregatedPages
-      .pipe(createBatchStream(BATCH_SIZE))
-      .pipe(recountWriteStream);
-
-    await streamToPromise(recountWriteStream);
-  }
-
-  // update descendantCount of all pages that are ancestors of a provided path by count
-  async updateDescendantCountOfAncestors(path = '/', count = 0) {
-    const Page = this.crowi.model('Page');
-    const ancestors = collectAncestorPaths(path);
-    await Page.incrementDescendantCountOfPaths(ancestors, count);
-  }
-
-}
-
-module.exports = PageService;

+ 2198 - 0
packages/app/src/server/service/page.ts

@@ -0,0 +1,2198 @@
+import { pagePathUtils } from '@growi/core';
+import mongoose, { ObjectId, QueryCursor } from 'mongoose';
+import escapeStringRegexp from 'escape-string-regexp';
+import streamToPromise from 'stream-to-promise';
+import pathlib from 'path';
+import { Readable, Writable } from 'stream';
+
+import { serializePageSecurely } from '../models/serializers/page-serializer';
+import { createBatchStream } from '~/server/util/batch-stream';
+import loggerFactory from '~/utils/logger';
+import {
+  CreateMethod, generateGrantCondition, PageCreateOptions, PageModel,
+} from '~/server/models/page';
+import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import ActivityDefine from '../util/activityDefine';
+import {
+  IPage, IPageInfo, IPageInfoForEntity,
+} from '~/interfaces/page';
+import { PageRedirectModel } from '../models/page-redirect';
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { IUserHasId } from '~/interfaces/user';
+import { Ref } from '~/interfaces/common';
+import { HasObjectId } from '~/interfaces/has-object-id';
+
+const debug = require('debug')('growi:services:page');
+
+const logger = loggerFactory('growi:services:page');
+const {
+  isCreatablePage, isTrashPage, collectAncestorPaths, isTopPage,
+} = pagePathUtils;
+
+const BULK_REINDEX_SIZE = 100;
+
+// TODO: improve type
+class PageCursorsForDescendantsFactory {
+
+  private user: any; // TODO: Typescriptize model
+
+  private rootPage: any; // TODO: wait for mongoose update
+
+  private shouldIncludeEmpty: boolean;
+
+  private initialCursor: QueryCursor<any>; // TODO: wait for mongoose update
+
+  private Page: PageModel;
+
+  constructor(user: any, rootPage: any, shouldIncludeEmpty: boolean) {
+    this.user = user;
+    this.rootPage = rootPage;
+    this.shouldIncludeEmpty = shouldIncludeEmpty;
+
+    this.Page = mongoose.model('Page') as unknown as PageModel;
+  }
+
+  // prepare initial cursor
+  private async init() {
+    const initialCursor = await this.generateCursorToFindChildren(this.rootPage);
+    this.initialCursor = initialCursor;
+  }
+
+  /**
+   * Returns Iterable that yields only descendant pages unorderedly
+   * @returns Promise<AsyncGenerator>
+   */
+  async generateIterable(): Promise<AsyncGenerator> {
+    // initialize cursor
+    await this.init();
+
+    return this.generateOnlyDescendants(this.initialCursor);
+  }
+
+  /**
+   * Returns Readable that produces only descendant pages unorderedly
+   * @returns Promise<Readable>
+   */
+  async generateReadable(): Promise<Readable> {
+    return Readable.from(await this.generateIterable());
+  }
+
+  /**
+   * Generator that unorderedly yields descendant pages
+   */
+  private async* generateOnlyDescendants(cursor: QueryCursor<any>) {
+    for await (const page of cursor) {
+      const nextCursor = await this.generateCursorToFindChildren(page);
+      yield* this.generateOnlyDescendants(nextCursor); // recursively yield
+
+      yield page;
+    }
+  }
+
+  private async generateCursorToFindChildren(page: any): Promise<QueryCursor<any>> {
+    const { PageQueryBuilder } = this.Page;
+
+    const builder = new PageQueryBuilder(this.Page.find(), this.shouldIncludeEmpty);
+    builder.addConditionToFilteringByParentId(page._id);
+    await this.Page.addConditionToFilteringByViewerToEdit(builder, this.user);
+
+    const cursor = builder.query.lean().cursor({ batchSize: BULK_REINDEX_SIZE }) as QueryCursor<any>;
+
+    return cursor;
+  }
+
+}
+
+class PageService {
+
+  crowi: any;
+
+  pageEvent: any;
+
+  tagEvent: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.pageEvent = crowi.event('page');
+    this.tagEvent = crowi.event('tag');
+
+    // init
+    this.initPageEvent();
+  }
+
+  private initPageEvent() {
+    // create
+    this.pageEvent.on('create', this.pageEvent.onCreate);
+
+    // createMany
+    this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
+    this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
+
+    // update
+    this.pageEvent.on('update', async(page, user) => {
+
+      this.pageEvent.onUpdate();
+
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_UPDATE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // rename
+    this.pageEvent.on('rename', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_RENAME);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // delete
+    this.pageEvent.on('delete', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // delete completely
+    this.pageEvent.on('deleteCompletely', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE_COMPLETELY);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // likes
+    this.pageEvent.on('like', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_LIKE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // bookmark
+    this.pageEvent.on('bookmark', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_BOOKMARK);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+  }
+
+  canDeleteCompletely(creatorId, operator) {
+    const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    if (operator.admin) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
+      const operatorId = operator?._id;
+      return (operatorId != null && operatorId.equals(creatorId));
+    }
+
+    return false;
+  }
+
+  async findPageAndMetaDataByViewer({ pageId, path, user }) {
+
+    const Page = this.crowi.model('Page');
+
+    let page;
+    if (pageId != null) { // prioritized
+      page = await Page.findByIdAndViewer(pageId, user);
+    }
+    else {
+      page = await Page.findByPathAndViewer(path, user);
+    }
+
+    const result: any = {};
+
+    if (page == null) {
+      const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
+      result.isForbidden = isExist;
+      result.isNotFound = !isExist;
+      result.isCreatable = isCreatablePage(path);
+      result.page = page;
+
+      return result;
+    }
+
+    result.page = page;
+    result.isForbidden = false;
+    result.isNotFound = false;
+    result.isCreatable = false;
+    result.isDeleted = page.isDeleted();
+
+    return result;
+  }
+
+  private shouldUseV4Process(page): boolean {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const isTrashPage = page.status === Page.STATUS_DELETED;
+
+    return !isTrashPage && this.shouldUseV4ProcessForRevert(page);
+  }
+
+  private shouldUseV4ProcessForRevert(page): boolean {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const isPageMigrated = page.parent != null;
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isRoot = isTopPage(page.path);
+    const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
+
+    const shouldUseV4Process = !isRoot && !isPageRestricted && (!isV5Compatible || !isPageMigrated);
+
+    return shouldUseV4Process;
+  }
+
+  private shouldNormalizeParent(page): boolean {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
+  }
+
+  /**
+   * Generate read stream to operate descendants of the specified page path
+   * @param {string} targetPagePath
+   * @param {User} viewer
+   */
+  private async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.find(), true)
+      .addConditionAsNotMigrated() // to avoid affecting v5 pages
+      .addConditionToListOnlyDescendants(targetPagePath);
+
+    await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
+    return builder
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+  }
+
+  async renamePage(page, newPagePath, user, options) {
+    const Page = this.crowi.model('Page');
+
+    if (isTopPage(page.path)) {
+      throw Error('It is forbidden to rename the top page');
+    }
+
+    // v4 compatible process
+    const shouldUseV4Process = this.shouldUseV4Process(page);
+    if (shouldUseV4Process) {
+      return this.renamePageV4(page, newPagePath, user, options);
+    }
+
+    const updateMetadata = options.updateMetadata || false;
+    // sanitize path
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    // use the parent's grant when target page is an empty page
+    let grant;
+    let grantedUserIds;
+    let grantedGroupId;
+    if (page.isEmpty) {
+      const parent = await Page.findOne({ _id: page.parent });
+      if (parent == null) {
+        throw Error('parent not found');
+      }
+      grant = parent.grant;
+      grantedUserIds = parent.grantedUsers;
+      grantedGroupId = parent.grantedGroup;
+    }
+    else {
+      grant = page.grant;
+      grantedUserIds = page.grantedUsers;
+      grantedGroupId = page.grantedGroup;
+    }
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (grant !== Page.GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = false;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(newPagePath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${newPagePath}" when renaming`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error(`This page cannot be renamed to "${newPagePath}" since the selected grant or grantedGroup is not assignable to this page.`);
+      }
+    }
+
+    /*
+     * update target
+     */
+    const update: Partial<IPage> = {};
+    // find or create parent
+    const newParent = await Page.getParentAndFillAncestors(newPagePath);
+    // update Page
+    update.path = newPagePath;
+    update.parent = newParent._id;
+    if (updateMetadata) {
+      update.lastUpdateUser = user;
+      update.updatedAt = new Date();
+    }
+
+    // *************************
+    // * before rename target page
+    // *************************
+    const oldPageParentId = page.parent; // this is used to update descendantCount of old page's ancestors
+
+    // *************************
+    // * rename target page
+    // *************************
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+    this.pageEvent.emit('rename', page, user);
+
+    // *************************
+    // * after rename target page
+    // *************************
+    // rename descendants and update descendantCount asynchronously
+    this.resumableRenameDescendants(page, newPagePath, user, options, shouldUseV4Process, renamedPage, oldPageParentId);
+
+    return renamedPage;
+  }
+
+  async resumableRenameDescendants(page, newPagePath, user, options, shouldUseV4Process, renamedPage, oldPageParentId) {
+    // TODO: resume
+    // update descendants first
+    await this.renameDescendantsWithStream(page, newPagePath, user, options, shouldUseV4Process);
+
+    // reduce ancestore's descendantCount
+    const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
+    await this.updateDescendantCountOfAncestors(oldPageParentId, nToReduce, true);
+
+    // increase ancestore's descendantCount
+    const nToIncrease = (renamedPage.isEmpty ? 0 : 1) + page.descendantCount;
+    await this.updateDescendantCountOfAncestors(renamedPage._id, nToIncrease, false);
+  }
+
+  // !!renaming always include descendant pages!!
+  private async renamePageV4(page, newPagePath, user, options) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+    const updateMetadata = options.updateMetadata || false;
+
+    // sanitize path
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    // create descendants first
+    await this.renameDescendantsWithStream(page, newPagePath, user, options);
+
+
+    const update: any = {};
+    // update Page
+    update.path = newPagePath;
+    if (updateMetadata) {
+      update.lastUpdateUser = user;
+      update.updatedAt = Date.now();
+    }
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+
+    // update Rivisions
+    await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
+
+    this.pageEvent.emit('rename', page, user);
+
+    return renamedPage;
+  }
+
+
+  private async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = true) {
+    // v4 compatible process
+    if (shouldUseV4Process) {
+      return this.renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix);
+    }
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    const { updateMetadata, createRedirectPage } = options;
+
+    const updatePathOperations: any[] = [];
+    const insertPageRedirectOperations: any[] = [];
+
+    pages.forEach((page) => {
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+
+      // increment updatePathOperations
+      let update;
+      if (!page.isEmpty && updateMetadata) {
+        update = {
+          $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() },
+        };
+
+      }
+      else {
+        update = {
+          $set: { path: newPagePath },
+        };
+      }
+
+      if (!page.isEmpty && createRedirectPage) {
+        // insert PageRedirect
+        insertPageRedirectOperations.push({
+          insertOne: {
+            document: {
+              fromPath: page.path,
+              toPath: newPagePath,
+            },
+          },
+        });
+      }
+
+      updatePathOperations.push({
+        updateOne: {
+          filter: {
+            _id: page._id,
+          },
+          update,
+        },
+      });
+    });
+
+    try {
+      await Page.bulkWrite(updatePathOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error(`Failed to rename pages: ${err}`);
+      }
+    }
+
+    try {
+      await PageRedirect.bulkWrite(insertPageRedirectOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw Error(`Failed to create PageRedirect documents: ${err}`);
+      }
+    }
+
+    this.pageEvent.emit('updateMany', pages, user);
+  }
+
+  private async renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+    const pageCollection = mongoose.connection.collection('pages');
+    const { updateMetadata, createRedirectPage } = options;
+
+    const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const insertPageRedirectOperations: any[] = [];
+
+    pages.forEach((page) => {
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+
+      if (updateMetadata) {
+        unorderedBulkOp
+          .find({ _id: page._id })
+          .update({ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() } });
+      }
+      else {
+        unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
+      }
+      // insert PageRedirect
+      if (!page.isEmpty && createRedirectPage) {
+        insertPageRedirectOperations.push({
+          insertOne: {
+            document: {
+              fromPath: page.path,
+              toPath: newPagePath,
+            },
+          },
+        });
+      }
+    });
+
+    try {
+      await unorderedBulkOp.execute();
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error(`Failed to rename pages: ${err}`);
+      }
+    }
+
+    try {
+      await PageRedirect.bulkWrite(insertPageRedirectOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw Error(`Failed to create PageRedirect documents: ${err}`);
+      }
+    }
+
+    this.pageEvent.emit('updateMany', pages, user);
+  }
+
+  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true) {
+    // v4 compatible process
+    if (shouldUseV4Process) {
+      return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
+    }
+
+    const factory = new PageCursorsForDescendantsFactory(user, targetPage, true);
+    const readStream = await factory.generateReadable();
+
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
+
+    const renameDescendants = this.renameDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await renameDescendants(
+            batch, user, options, pathRegExp, newPagePathPrefix, shouldUseV4Process,
+          );
+          logger.debug(`Renaming pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('Renaming error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      async final(callback) {
+        logger.debug(`Renaming pages has completed: (totalCount=${count})`);
+
+        // update path
+        targetPage.path = newPagePath;
+        pageEvent.emit('syncDescendantsUpdate', targetPage, user);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(writeStream);
+  }
+
+  private async renameDescendantsWithStreamV4(targetPage, newPagePath, user, options = {}) {
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
+
+    const renameDescendants = this.renameDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await renameDescendants(batch, user, options, pathRegExp, newPagePathPrefix);
+          logger.debug(`Renaming pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('renameDescendants error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Renaming pages has completed: (totalCount=${count})`);
+        // update  path
+        targetPage.path = newPagePath;
+        pageEvent.emit('syncDescendantsUpdate', targetPage, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(readStream);
+  }
+
+  /*
+   * Duplicate
+   */
+  async duplicate(page, newPagePath, user, isRecursively) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
+
+    // v4 compatible process
+    const shouldUseV4Process = this.shouldUseV4Process(page);
+    if (shouldUseV4Process) {
+      return this.duplicateV4(page, newPagePath, user, isRecursively);
+    }
+
+    // use the parent's grant when target page is an empty page
+    let grant;
+    let grantedUserIds;
+    let grantedGroupId;
+    if (page.isEmpty) {
+      const parent = await Page.findOne({ _id: page.parent });
+      if (parent == null) {
+        throw Error('parent not found');
+      }
+      grant = parent.grant;
+      grantedUserIds = parent.grantedUsers;
+      grantedGroupId = parent.grantedGroup;
+    }
+    else {
+      grant = page.grant;
+      grantedUserIds = page.grantedUsers;
+      grantedGroupId = page.grantedGroup;
+    }
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (grant !== Page.GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = false;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(newPagePath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${newPagePath}" when duplicating`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error(`This page cannot be duplicated to "${newPagePath}" since the selected grant or grantedGroup is not assignable to this page.`);
+      }
+    }
+
+    // populate
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
+
+    // create option
+    const options: PageCreateOptions = {
+      grant: page.grant,
+      grantUserGroupId: page.grantedGroup,
+    };
+
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    let createdPage;
+
+    if (page.isEmpty) {
+      const parent = await Page.getParentAndFillAncestors(newPagePath);
+      createdPage = await Page.createEmptyPage(newPagePath, parent);
+    }
+    else {
+      createdPage = await (Page.create as CreateMethod)(
+        newPagePath, page.revision.body, user, options,
+      );
+    }
+
+    // take over tags
+    const originTags = await page.findRelatedTagsById();
+    let savedTags = [];
+    if (originTags.length !== 0) {
+      await PageTagRelation.updatePageTags(createdPage._id, originTags);
+      savedTags = await PageTagRelation.listTagNamesByPage(createdPage._id);
+      this.tagEvent.emit('update', createdPage, savedTags);
+    }
+
+    const result = serializePageSecurely(createdPage);
+    result.tags = savedTags;
+
+    // TODO: resume
+    if (isRecursively) {
+      this.resumableDuplicateDescendants(page, newPagePath, user, shouldUseV4Process, createdPage._id);
+    }
+    return result;
+  }
+
+  async resumableDuplicateDescendants(page, newPagePath, user, shouldUseV4Process, createdPageId) {
+    const descendantCountAppliedToAncestors = await this.duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process);
+    await this.updateDescendantCountOfAncestors(createdPageId, descendantCountAppliedToAncestors, false);
+  }
+
+  async duplicateV4(page, newPagePath, user, isRecursively) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
+    // populate
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
+
+    // create option
+    const options: any = { page };
+    options.grant = page.grant;
+    options.grantUserGroupId = page.grantedGroup;
+    options.grantedUserIds = page.grantedUsers;
+
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    const createdPage = await Page.create(
+      newPagePath, page.revision.body, user, options,
+    );
+
+    if (isRecursively) {
+      this.duplicateDescendantsWithStream(page, newPagePath, user);
+    }
+
+    // take over tags
+    const originTags = await page.findRelatedTagsById();
+    let savedTags = [];
+    if (originTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, originTags);
+      savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+      this.tagEvent.emit('update', createdPage, savedTags);
+    }
+    const result = serializePageSecurely(createdPage);
+    result.tags = savedTags;
+
+    return result;
+  }
+
+  /**
+   * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
+   * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
+   */
+  private async duplicateTags(pageIdMapping) {
+    const PageTagRelation = mongoose.model('PageTagRelation');
+
+    // convert pageId from string to ObjectId
+    const pageIds = Object.keys(pageIdMapping);
+    const stage = { $or: pageIds.map((pageId) => { return { relatedPage: new mongoose.Types.ObjectId(pageId) } }) };
+
+    const pagesAssociatedWithTag = await PageTagRelation.aggregate([
+      {
+        $match: stage,
+      },
+      {
+        $group: {
+          _id: '$relatedTag',
+          relatedPages: { $push: '$relatedPage' },
+        },
+      },
+    ]);
+
+    const newPageTagRelation: any[] = [];
+    pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
+      // relatedPages
+      relatedPages.forEach((pageId) => {
+        newPageTagRelation.push({
+          relatedPage: pageIdMapping[pageId], // newPageId
+          relatedTag: _id,
+        });
+      });
+    });
+
+    return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
+  }
+
+  private async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = true) {
+    if (shouldUseV4Process) {
+      return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
+    }
+
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const pageIds = pages.map(page => page._id);
+    const revisions = await Revision.find({ pageId: { $in: pageIds } });
+
+    // Mapping to set to the body of the new revision
+    const pageIdRevisionMapping = {};
+    revisions.forEach((revision) => {
+      pageIdRevisionMapping[revision.pageId] = revision;
+    });
+
+    // key: oldPageId, value: newPageId
+    const pageIdMapping = {};
+    const newPages: any[] = [];
+    const newRevisions: any[] = [];
+
+    // no need to save parent here
+    pages.forEach((page) => {
+      const newPageId = new mongoose.Types.ObjectId();
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+      pageIdMapping[page._id] = newPageId;
+
+      let newPage;
+      if (!page.isEmpty) {
+        newPage = {
+          _id: newPageId,
+          path: newPagePath,
+          creator: user._id,
+          grant: page.grant,
+          grantedGroup: page.grantedGroup,
+          grantedUsers: page.grantedUsers,
+          lastUpdateUser: user._id,
+          revision: revisionId,
+        };
+        newRevisions.push({
+          _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+        });
+      }
+      newPages.push(newPage);
+    });
+
+    await Page.insertMany(newPages, { ordered: false });
+    await Revision.insertMany(newRevisions, { ordered: false });
+    await this.duplicateTags(pageIdMapping);
+  }
+
+  private async duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const pageIds = pages.map(page => page._id);
+    const revisions = await Revision.find({ pageId: { $in: pageIds } });
+
+    // Mapping to set to the body of the new revision
+    const pageIdRevisionMapping = {};
+    revisions.forEach((revision) => {
+      pageIdRevisionMapping[revision.pageId] = revision;
+    });
+
+    // key: oldPageId, value: newPageId
+    const pageIdMapping = {};
+    const newPages: any[] = [];
+    const newRevisions: any[] = [];
+
+    pages.forEach((page) => {
+      const newPageId = new mongoose.Types.ObjectId();
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+      pageIdMapping[page._id] = newPageId;
+
+      newPages.push({
+        _id: newPageId,
+        path: newPagePath,
+        creator: user._id,
+        grant: page.grant,
+        grantedGroup: page.grantedGroup,
+        grantedUsers: page.grantedUsers,
+        lastUpdateUser: user._id,
+        revision: revisionId,
+      });
+
+      newRevisions.push({
+        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+      });
+
+    });
+
+    await Page.insertMany(newPages, { ordered: false });
+    await Revision.insertMany(newRevisions, { ordered: false });
+    await this.duplicateTags(pageIdMapping);
+  }
+
+  private async duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process = true) {
+    if (shouldUseV4Process) {
+      return this.duplicateDescendantsWithStreamV4(page, newPagePath, user);
+    }
+
+    const iterableFactory = new PageCursorsForDescendantsFactory(user, page, true);
+    const readStream = await iterableFactory.generateReadable();
+
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+
+    const duplicateDescendants = this.duplicateDescendants.bind(this);
+    const shouldNormalizeParent = this.shouldNormalizeParent.bind(this);
+    const normalizeParentAndDescendantCountOfDescendants = this.normalizeParentAndDescendantCountOfDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    let nNonEmptyDuplicatedPages = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          nNonEmptyDuplicatedPages += batch.filter(page => !page.isEmpty).length;
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, shouldUseV4Process);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      async final(callback) {
+        // normalize parent of descendant pages
+        const shouldNormalize = shouldNormalizeParent(page);
+        if (shouldNormalize) {
+          try {
+            await normalizeParentAndDescendantCountOfDescendants(newPagePath);
+            logger.info(`Successfully normalized duplicated descendant pages under "${newPagePath}"`);
+          }
+          catch (err) {
+            logger.error('Failed to normalize descendants afrer duplicate:', err);
+            throw err;
+          }
+        }
+
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+        // update  path
+        page.path = newPagePath;
+        pageEvent.emit('syncDescendantsUpdate', page, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(writeStream);
+
+    return nNonEmptyDuplicatedPages;
+  }
+
+  private async duplicateDescendantsWithStreamV4(page, newPagePath, user) {
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
+
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+
+    const duplicateDescendants = this.duplicateDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+        // update  path
+        page.path = newPagePath;
+        pageEvent.emit('syncDescendantsUpdate', page, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(writeStream);
+
+    return count;
+  }
+
+  /*
+   * Delete
+   */
+  async deletePage(page, user, options = {}, isRecursively = false) {
+    const Page = mongoose.model('Page') as PageModel;
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    // v4 compatible process
+    const shouldUseV4Process = this.shouldUseV4Process(page);
+    if (shouldUseV4Process) {
+      return this.deletePageV4(page, user, options, isRecursively);
+    }
+
+    const newPath = Page.getDeletedPageName(page.path);
+    const isTrashed = isTrashPage(page.path);
+
+    if (isTrashed) {
+      throw new Error('This method does NOT support deleting trashed pages.');
+    }
+
+    if (!Page.isDeletableName(page.path)) {
+      throw new Error('Page is not deletable.');
+    }
+
+    if (!isRecursively) {
+      // replace with an empty page
+      const shouldReplace = await Page.exists({ parent: page._id });
+      if (shouldReplace) {
+        await Page.replaceTargetWithPage(page);
+      }
+
+      // update descendantCount of ancestors'
+      await this.updateDescendantCountOfAncestors(page.parent, -1, true);
+
+      const shouldDeleteLeafEmptyPages = !shouldReplace;
+      if (shouldDeleteLeafEmptyPages) {
+        // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
+      }
+    }
+
+    let deletedPage;
+    // update Revisions
+    if (page.isEmpty) {
+      await Page.remove({ _id: page._id });
+    }
+    else {
+      await Revision.updateRevisionListByPageId(page._id, { pageId: page._id });
+      deletedPage = await Page.findByIdAndUpdate(page._id, {
+        $set: {
+          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(), parent: null, descendantCount: 0, // set parent as null
+        },
+      }, { new: true });
+      await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
+
+      await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+
+      this.pageEvent.emit('delete', page, user);
+      this.pageEvent.emit('create', deletedPage, user);
+    }
+
+    // TODO: resume
+    // no await for deleteDescendantsWithStream and updateDescendantCountOfAncestors
+    if (isRecursively) {
+      (async() => {
+        const deletedDescendantCount = await this.deleteDescendantsWithStream(page, user, shouldUseV4Process); // use the same process in both version v4 and v5
+
+        // update descendantCount of ancestors'
+        if (page.parent != null) {
+          await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
+
+          // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
+        }
+      })();
+    }
+
+    return deletedPage;
+  }
+
+  private async deletePageV4(page, user, options = {}, isRecursively = false) {
+    const Page = mongoose.model('Page') as PageModel;
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    const newPath = Page.getDeletedPageName(page.path);
+    const isTrashed = isTrashPage(page.path);
+
+    if (isTrashed) {
+      throw new Error('This method does NOT support deleting trashed pages.');
+    }
+
+    if (!Page.isDeletableName(page.path)) {
+      throw new Error('Page is not deletable.');
+    }
+
+    if (isRecursively) {
+      this.deleteDescendantsWithStream(page, user);
+    }
+
+    // update Revisions
+    await Revision.updateRevisionListByPageId(page._id, { pageId: page._id });
+    const deletedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
+      },
+    }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
+
+    await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+
+    this.pageEvent.emit('delete', page, user);
+    this.pageEvent.emit('create', deletedPage, user);
+
+    return deletedPage;
+  }
+
+  private async deleteDescendants(pages, user) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    const deletePageOperations: any[] = [];
+    const insertPageRedirectOperations: any[] = [];
+
+    pages.forEach((page) => {
+      const newPath = Page.getDeletedPageName(page.path);
+
+      let operation;
+      // if empty, delete completely
+      if (page.isEmpty) {
+        operation = {
+          deleteOne: {
+            filter: { _id: page._id },
+          },
+        };
+      }
+      // if not empty, set parent to null and update to trash
+      else {
+        operation = {
+          updateOne: {
+            filter: { _id: page._id },
+            update: {
+              $set: {
+                path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(), parent: null, descendantCount: 0, // set parent as null
+              },
+            },
+          },
+        };
+
+        insertPageRedirectOperations.push({
+          insertOne: {
+            document: {
+              fromPath: page.path,
+              toPath: newPath,
+            },
+          },
+        });
+      }
+
+      deletePageOperations.push(operation);
+    });
+
+    try {
+      await Page.bulkWrite(deletePageOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error(`Failed to delete pages: ${err}`);
+      }
+    }
+    finally {
+      this.pageEvent.emit('syncDescendantsDelete', pages, user);
+    }
+
+    try {
+      await PageRedirect.bulkWrite(insertPageRedirectOperations);
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw Error(`Failed to create PageRedirect documents: ${err}`);
+      }
+    }
+  }
+
+  /**
+   * Create delete stream and return deleted document count
+   */
+  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true): Promise<number> {
+    let readStream;
+    if (shouldUseV4Process) {
+      readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+    }
+    else {
+      const factory = new PageCursorsForDescendantsFactory(user, targetPage, true);
+      readStream = await factory.generateReadable();
+    }
+
+
+    const deleteDescendants = this.deleteDescendants.bind(this);
+    let count = 0;
+    let nDeletedNonEmptyPages = 0; // used for updating descendantCount
+
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        nDeletedNonEmptyPages += batch.filter(d => !d.isEmpty).length;
+
+        try {
+          count += batch.length;
+          await deleteDescendants(batch, user);
+          logger.debug(`Deleting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('deleteDescendants error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Deleting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(readStream);
+
+    return nDeletedNonEmptyPages;
+  }
+
+  private async deleteCompletelyOperation(pageIds, pagePaths) {
+    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
+    const Bookmark = this.crowi.model('Bookmark');
+    const Comment = this.crowi.model('Comment');
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+    const ShareLink = this.crowi.model('ShareLink');
+    const Revision = this.crowi.model('Revision');
+    const Attachment = this.crowi.model('Attachment');
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    const { attachmentService } = this.crowi;
+    const attachments = await Attachment.find({ page: { $in: pageIds } });
+
+    return Promise.all([
+      Bookmark.deleteMany({ page: { $in: pageIds } }),
+      Comment.deleteMany({ page: { $in: pageIds } }),
+      PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
+      ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
+      Revision.deleteMany({ pageId: { $in: pageIds } }),
+      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { _id: { $in: pageIds } }] }),
+      PageRedirect.deleteMany({ $or: [{ toPath: { $in: pagePaths } }] }),
+      attachmentService.removeAllAttachments(attachments),
+    ]);
+  }
+
+  // delete multiple pages
+  private async deleteMultipleCompletely(pages, user, options = {}) {
+    const ids = pages.map(page => (page._id));
+    const paths = pages.map(page => (page.path));
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    this.pageEvent.emit('syncDescendantsDelete', pages, user); // update as renamed page
+
+    return;
+  }
+
+  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
+    const Page = mongoose.model('Page') as PageModel;
+
+    if (isTopPage(page.path)) {
+      throw Error('It is forbidden to delete the top page');
+    }
+
+    // v4 compatible process
+    const shouldUseV4Process = this.shouldUseV4Process(page);
+    if (shouldUseV4Process) {
+      return this.deleteCompletelyV4(page, user, options, isRecursively, preventEmitting);
+    }
+
+    const ids = [page._id];
+    const paths = [page.path];
+
+    logger.debug('Deleting completely', paths);
+
+    // replace with an empty page
+    const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
+    if (shouldReplace) {
+      await Page.replaceTargetWithPage(page);
+    }
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    if (!isRecursively) {
+      await this.updateDescendantCountOfAncestors(page.parent, -1, true);
+
+      // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
+    }
+
+    if (!page.isEmpty && !preventEmitting) {
+      this.pageEvent.emit('deleteCompletely', page, user);
+    }
+
+    // TODO: resume
+    if (isRecursively) {
+      // no await for deleteCompletelyDescendantsWithStream
+      (async() => {
+        const deletedDescendantCount = await this.deleteCompletelyDescendantsWithStream(page, user, options, shouldUseV4Process);
+
+        // update descendantCount of ancestors'
+        if (page.parent != null) {
+          await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
+        }
+
+        // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
+      })();
+    }
+
+    return;
+  }
+
+  private async deleteCompletelyV4(page, user, options = {}, isRecursively = false, preventEmitting = false) {
+    const ids = [page._id];
+    const paths = [page.path];
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    if (isRecursively) {
+      this.deleteCompletelyDescendantsWithStream(page, user, options);
+    }
+
+    if (!page.isEmpty && !preventEmitting) {
+      this.pageEvent.emit('deleteCompletely', page, user);
+    }
+
+    return;
+  }
+
+  async emptyTrashPage(user, options = {}) {
+    return this.deleteCompletelyDescendantsWithStream({ path: '/trash' }, user, options);
+  }
+
+  /**
+   * Create delete completely stream
+   */
+  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+    let readStream;
+
+    if (shouldUseV4Process) { // pages don't have parents
+      readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+    }
+    else {
+      const factory = new PageCursorsForDescendantsFactory(user, targetPage, true);
+      readStream = await factory.generateReadable();
+    }
+
+    let count = 0;
+    let nDeletedNonEmptyPages = 0; // used for updating descendantCount
+
+    const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        nDeletedNonEmptyPages += batch.filter(d => !d.isEmpty).length;
+
+        try {
+          count += batch.length;
+          await deleteMultipleCompletely(batch, user, options);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(readStream);
+
+    return nDeletedNonEmptyPages;
+  }
+
+  // use the same process in both v4 and v5
+  private async revertDeletedDescendants(pages, user) {
+    const Page = this.crowi.model('Page');
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    const revertPageOperations: any[] = [];
+    const fromPathsToDelete: string[] = [];
+
+    pages.forEach((page) => {
+      // e.g. page.path = /trash/test, toPath = /test
+      const toPath = Page.getRevertDeletedPageName(page.path);
+      revertPageOperations.push({
+        updateOne: {
+          filter: { _id: page._id },
+          update: {
+            $set: {
+              path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+            },
+          },
+        },
+      });
+
+      fromPathsToDelete.push(page.path);
+    });
+
+    try {
+      await Page.bulkWrite(revertPageOperations);
+      await PageRedirect.deleteMany({ fromPath: { $in: fromPathsToDelete } });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error(`Failed to revert pages: ${err}`);
+      }
+    }
+  }
+
+  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+
+    // v4 compatible process
+    const shouldUseV4Process = this.shouldUseV4ProcessForRevert(page);
+    if (shouldUseV4Process) {
+      return this.revertDeletedPageV4(page, user, options, isRecursively);
+    }
+
+    const newPath = Page.getRevertDeletedPageName(page.path);
+    const includeEmpty = true;
+    const originPage = await Page.findByPath(newPath, includeEmpty);
+
+    // throw if any page already exists
+    if (originPage != null) {
+      throw Error(`This page cannot be reverted since a page with path "${originPage.path}" already exists. Rename the existing pages first.`);
+    }
+
+    const parent = await Page.getParentAndFillAncestors(newPath);
+
+    page.status = Page.STATUS_PUBLISHED;
+    page.lastUpdateUser = user;
+    const updatedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
+      },
+    }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
+
+    if (isRecursively) {
+      await this.updateDescendantCountOfAncestors(parent._id, 1, true);
+    }
+
+    // TODO: resume
+    if (!isRecursively) {
+      // no await for revertDeletedDescendantsWithStream
+      (async() => {
+        const revertedDescendantCount = await this.revertDeletedDescendantsWithStream(page, user, options, shouldUseV4Process);
+
+        // update descendantCount of ancestors'
+        if (page.parent != null) {
+          await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
+
+          // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
+        }
+      })();
+    }
+
+    return updatedPage;
+  }
+
+  private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+
+    const newPath = Page.getRevertDeletedPageName(page.path);
+    const originPage = await Page.findByPath(newPath);
+    if (originPage != null) {
+      throw Error(`This page cannot be reverted since a page with path "${originPage.path}" already exists.`);
+    }
+
+    if (isRecursively) {
+      this.revertDeletedDescendantsWithStream(page, user, options);
+    }
+
+    page.status = Page.STATUS_PUBLISHED;
+    page.lastUpdateUser = user;
+    debug('Revert deleted the page', page, newPath);
+    const updatedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+      },
+    }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
+
+    return updatedPage;
+  }
+
+  /**
+   * Create revert stream
+   */
+  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+    if (shouldUseV4Process) {
+      return this.revertDeletedDescendantsWithStreamV4(targetPage, user, options);
+    }
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
+    const normalizeParentAndDescendantCountOfDescendants = this.normalizeParentAndDescendantCountOfDescendants.bind(this);
+    const shouldNormalizeParent = this.shouldNormalizeParent.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await revertDeletedDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      async final(callback) {
+        const Page = mongoose.model('Page') as unknown as PageModel;
+        // normalize parent of descendant pages
+        const shouldNormalize = shouldNormalizeParent(targetPage);
+        if (shouldNormalize) {
+          try {
+            const newPath = Page.getRevertDeletedPageName(targetPage.path);
+            await normalizeParentAndDescendantCountOfDescendants(newPath);
+            logger.info(`Successfully normalized reverted descendant pages under "${newPath}"`);
+          }
+          catch (err) {
+            logger.error('Failed to normalize descendants afrer revert:', err);
+            throw err;
+          }
+        }
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(readStream);
+
+    return count;
+  }
+
+  private async revertDeletedDescendantsWithStreamV4(targetPage, user, options = {}) {
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
+    const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await revertDeletedDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(readStream);
+
+    return count;
+  }
+
+
+  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user) {
+    const Page = this.crowi.model('Page');
+    const pages = await Page.find({ grantedGroup: { $in: groupsToDelete } });
+
+    switch (action) {
+      case 'public':
+        await Page.publicizePages(pages);
+        break;
+      case 'delete':
+        return this.deleteMultipleCompletely(pages, user);
+      case 'transfer':
+        await Page.transferPagesToGroup(pages, transferToUserGroupId);
+        break;
+      default:
+        throw new Error('Unknown action for private pages');
+    }
+  }
+
+  private extractStringIds(refs: Ref<HasObjectId>[]) {
+    return refs.map((ref: Ref<HasObjectId>) => {
+      return (typeof ref === 'string') ? ref : ref._id.toString();
+    });
+  }
+
+  constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
+    if (page.isEmpty) {
+      return {
+        isEmpty: true,
+        isMovable: true,
+        isDeletable: false,
+        isAbleToDeleteCompletely: false,
+      };
+    }
+
+    const isMovable = isGuestUser ? false : !isTopPage(page.path);
+
+    const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
+    const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
+
+    const Page = this.crowi.model('Page');
+    return {
+      isEmpty: false,
+      sumOfLikers: page.liker.length,
+      likerIds: this.extractStringIds(likers),
+      seenUserIds: this.extractStringIds(seenUsers),
+      sumOfSeenUsers: page.seenUsers.length,
+      isMovable,
+      isDeletable: Page.isDeletableName(page.path),
+      isAbleToDeleteCompletely: false,
+    };
+
+  }
+
+  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user): Promise<Record<string, string | null>> {
+    const Page = mongoose.model('Page');
+    const MAX_LENGTH = 350;
+
+    // aggregation options
+    const viewerCondition = await generateGrantCondition(user, null);
+    const filterByIds = {
+      _id: { $in: pageIds },
+    };
+
+    let pages;
+    try {
+      pages = await Page
+        .aggregate([
+          // filter by pageIds
+          {
+            $match: filterByIds,
+          },
+          // filter by viewer
+          viewerCondition,
+          // lookup: https://docs.mongodb.com/v4.4/reference/operator/aggregation/lookup/
+          {
+            $lookup: {
+              from: 'revisions',
+              let: { localRevision: '$revision' },
+              pipeline: [
+                {
+                  $match: {
+                    $expr: {
+                      $eq: ['$_id', '$$localRevision'],
+                    },
+                  },
+                },
+                {
+                  $project: {
+                    // What is $substrCP?
+                    // see: https://stackoverflow.com/questions/43556024/mongodb-error-substrbytes-invalid-range-ending-index-is-in-the-middle-of-a-ut/43556249
+                    revision: { $substrCP: ['$body', 0, MAX_LENGTH] },
+                  },
+                },
+              ],
+              as: 'revisionData',
+            },
+          },
+          // projection
+          {
+            $project: {
+              _id: 1,
+              revisionData: 1,
+            },
+          },
+        ]).exec();
+    }
+    catch (err) {
+      logger.error('Error occurred while generating shortBodiesMap');
+      throw err;
+    }
+
+    const shortBodiesMap = {};
+    pages.forEach((page) => {
+      shortBodiesMap[page._id] = page.revisionData?.[0]?.revision;
+    });
+
+    return shortBodiesMap;
+  }
+
+  private async createAndSendNotifications(page, user, action) {
+    const { activityService, inAppNotificationService } = this.crowi;
+
+    const snapshot = stringifySnapshot(page);
+
+    // Create activity
+    const parameters = {
+      user: user._id,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: page,
+      action,
+    };
+    const activity = await activityService.createByParameters(parameters);
+
+    // Get user to be notified
+    const targetUsers = await activity.getNotificationTargetUsers();
+
+    // Create and send notifications
+    await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
+    await inAppNotificationService.emitSocketIo(targetUsers);
+  }
+
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[]): Promise<void> {
+    for await (const pageId of pageIds) {
+      try {
+        await this.normalizeParentByPageId(pageId);
+      }
+      catch (err) {
+        // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
+      }
+    }
+  }
+
+  private async normalizeParentByPageId(pageId: ObjectIdLike) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const target = await Page.findById(pageId);
+    if (target == null) {
+      throw Error('target does not exist');
+    }
+
+    const {
+      path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+    } = target;
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (target.grant !== Page.GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = true;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}"`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('This page cannot be migrated since the selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+    else {
+      throw Error('Restricted pages can not be migrated');
+    }
+
+    // getParentAndFillAncestors
+    const parent = await Page.getParentAndFillAncestors(target.path);
+
+    return Page.updateOne({ _id: pageId }, { parent: parent._id });
+  }
+
+  async normalizeParentRecursivelyByPageIds(pageIds) {
+    if (pageIds == null || pageIds.length === 0) {
+      logger.error('pageIds is null or 0 length.');
+      return;
+    }
+
+    const [normalizedIds, notNormalizedPaths] = await this.crowi.pageGrantService.separateNormalizedAndNonNormalizedPages(pageIds);
+
+    if (normalizedIds.length === 0) {
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+      return;
+    }
+
+    if (notNormalizedPaths.length !== 0) {
+      // TODO: iterate notNormalizedPaths and send socket error to client so that the user can know which path failed to migrate
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+    }
+
+    // generate regexps
+    const regexps = await this._generateRegExpsByPageIds(normalizedIds);
+
+    // migrate recursively
+    try {
+      await this.normalizeParentRecursively(null, regexps);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+
+      throw err;
+    }
+  }
+
+  async _isPagePathIndexUnique() {
+    const Page = this.crowi.model('Page');
+    const now = (new Date()).toString();
+    const path = `growi_check_is_path_index_unique_${now}`;
+
+    let isUnique = false;
+
+    try {
+      await Page.insertMany([
+        { path },
+        { path },
+      ]);
+    }
+    catch (err) {
+      if (err?.code === 11000) { // Error code 11000 indicates the index is unique
+        isUnique = true;
+        logger.info('Page path index is unique.');
+      }
+      else {
+        throw err;
+      }
+    }
+    finally {
+      await Page.deleteMany({ path: { $regex: new RegExp('growi_check_is_path_index_unique', 'g') } });
+    }
+
+
+    return isUnique;
+  }
+
+  // TODO: use socket to send status to the client
+  async normalizeAllPublicPages() {
+    // const socket = this.crowi.socketIoService.getAdminSocket();
+
+    let isUnique;
+    try {
+      isUnique = await this._isPagePathIndexUnique();
+    }
+    catch (err) {
+      logger.error('Failed to check path index status', err);
+      throw err;
+    }
+
+    // drop unique index first
+    if (isUnique) {
+      try {
+        await this._v5NormalizeIndex();
+      }
+      catch (err) {
+        logger.error('V5 index normalization failed.', err);
+        // socket.emit('v5IndexNormalizationFailed', { error: err.message });
+        throw err;
+      }
+    }
+
+    // then migrate
+    try {
+      const Page = mongoose.model('Page') as unknown as PageModel;
+      await this.normalizeParentRecursively(Page.GRANT_PUBLIC, null, true);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('v5InitialMirationFailed', { error: err.message });
+
+      throw err;
+    }
+
+    // update descendantCount of all public pages
+    try {
+      await this.updateDescendantCountOfSelfAndDescendants('/');
+      logger.info('Successfully updated all descendantCount of public pages.');
+    }
+    catch (err) {
+      logger.error('Failed updating descendantCount of public pages.', err);
+      throw err;
+    }
+
+    await this._setIsV5CompatibleTrue();
+  }
+
+  /*
+   * returns an array of js RegExp instance instead of RE2 instance for mongo filter
+   */
+  private async _generateRegExpsByPageIds(pageIds) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    let result;
+    try {
+      result = await Page.findListByPageIds(pageIds, null, false);
+    }
+    catch (err) {
+      logger.error('Failed to find pages by ids', err);
+      throw err;
+    }
+
+    const { pages } = result;
+    const regexps = pages.map(page => new RegExp(`^${escapeStringRegexp(page.path)}`));
+
+    return regexps;
+  }
+
+  private async _setIsV5CompatibleTrue() {
+    try {
+      await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
+        'app:isV5Compatible': true,
+      });
+      logger.info('Successfully migrated all public pages.');
+    }
+    catch (err) {
+      logger.warn('Failed to update app:isV5Compatible to true.');
+      throw err;
+    }
+  }
+
+  private async normalizeParentAndDescendantCountOfDescendants(path: string): Promise<void> {
+    const escapedPath = escapeStringRegexp(path);
+    const regexps = [new RegExp(`^${escapedPath}`, 'i')];
+    await this.normalizeParentRecursively(null, regexps);
+
+    // update descendantCount of descendant pages
+    await this.updateDescendantCountOfSelfAndDescendants(path);
+  }
+
+  // TODO: use websocket to show progress
+  private async normalizeParentRecursively(grant, regexps, publicOnly = false): Promise<void> {
+    const BATCH_SIZE = 100;
+    const PAGES_LIMIT = 1000;
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    // GRANT_RESTRICTED and GRANT_SPECIFIED will never have parent
+    const grantFilter: any = {
+      $and: [
+        { grant: { $ne: Page.GRANT_RESTRICTED } },
+        { grant: { $ne: Page.GRANT_SPECIFIED } },
+      ],
+    };
+
+    if (grant != null) { // add grant condition if not null
+      grantFilter.$and = [...grantFilter.$and, { grant }];
+    }
+
+    // generate filter
+    const filter: any = {
+      $and: [
+        {
+          parent: null,
+          status: Page.STATUS_PUBLISHED,
+          path: { $ne: '/' },
+        },
+      ],
+    };
+    if (regexps != null && regexps.length !== 0) {
+      filter.$and.push({
+        parent: null,
+        status: Page.STATUS_PUBLISHED,
+        path: { $in: regexps },
+      });
+    }
+
+    const total = await Page.countDocuments(filter);
+
+    let baseAggregation = Page
+      .aggregate([
+        { $match: grantFilter },
+        { $match: filter },
+        {
+          $project: { // minimize data to fetch
+            _id: 1,
+            path: 1,
+          },
+        },
+      ]);
+
+    // limit pages to get
+    if (total > PAGES_LIMIT) {
+      baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
+    }
+
+    const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
+
+    // use batch stream
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    let countPages = 0;
+    let shouldContinue = true;
+
+    // migrate all siblings for each page
+    const migratePagesStream = new Writable({
+      objectMode: true,
+      async write(pages, encoding, callback) {
+        // make list to create empty pages
+        const parentPathsSet = new Set<string>(pages.map(page => pathlib.dirname(page.path)));
+        const parentPaths = Array.from(parentPathsSet);
+
+        // fill parents with empty pages
+        await Page.createEmptyPagesByPaths(parentPaths, publicOnly);
+
+        // find parents again
+        const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
+        const parents = await builder
+          .addConditionToListByPathsArray(parentPaths)
+          .query
+          .lean()
+          .exec();
+
+        // bulkWrite to update parent
+        const updateManyOperations = parents.map((parent) => {
+          const parentId = parent._id;
+
+          // modify to adjust for RegExp
+          let parentPath = parent.path === '/' ? '' : parent.path;
+          parentPath = escapeStringRegexp(parentPath);
+
+          const filter: any = {
+            // regexr.com/6889f
+            // ex. /parent/any_child OR /any_level1
+            path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'i') },
+          };
+          if (grant != null) {
+            filter.grant = grant;
+          }
+
+          return {
+            updateMany: {
+              filter,
+              update: {
+                parent: parentId,
+              },
+            },
+          };
+        });
+        try {
+          const res = await Page.bulkWrite(updateManyOperations);
+          countPages += res.result.nModified;
+          logger.info(`Page migration processing: (count=${countPages})`);
+
+          // throw
+          if (res.result.writeErrors.length > 0) {
+            logger.error('Failed to migrate some pages', res.result.writeErrors);
+            throw Error('Failed to migrate some pages');
+          }
+
+          // finish migration
+          if (res.result.nModified === 0 && res.result.nMatched === 0) {
+            shouldContinue = false;
+            logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
+          }
+        }
+        catch (err) {
+          logger.error('Failed to update page.parent.', err);
+          throw err;
+        }
+
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+
+    pagesStream
+      .pipe(batchStream)
+      .pipe(migratePagesStream);
+
+    await streamToPromise(migratePagesStream);
+
+    const existsFilter = { $and: [...grantFilter.$and, ...filter.$and] };
+    if (await Page.exists(existsFilter) && shouldContinue) {
+      return this.normalizeParentRecursively(grant, regexps, publicOnly);
+    }
+
+  }
+
+  private async _v5NormalizeIndex() {
+    const collection = mongoose.connection.collection('pages');
+
+    try {
+      // drop pages.path_1 indexes
+      await collection.dropIndex('path_1');
+      logger.info('Succeeded to drop unique indexes from pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to drop unique indexes from pages.path.', err);
+      throw err;
+    }
+
+    try {
+      // create indexes without
+      await collection.createIndex({ path: 1 }, { unique: false });
+      logger.info('Succeeded to create non-unique indexes on pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to create non-unique indexes on pages.path.', err);
+      throw err;
+    }
+  }
+
+  async v5MigratablePrivatePagesCount(user) {
+    if (user == null) {
+      throw Error('user is required');
+    }
+    const Page = this.crowi.model('Page');
+    return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
+  }
+
+  /**
+   * update descendantCount of the following pages
+   * - page that has the same path as the provided path
+   * - pages that are descendants of the above page
+   */
+  async updateDescendantCountOfSelfAndDescendants(path) {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+
+    const aggregateCondition = Page.getAggrConditionForPageWithProvidedPathAndDescendants(path);
+    const aggregatedPages = await Page.aggregate(aggregateCondition).cursor({ batchSize: BATCH_SIZE });
+
+    const recountWriteStream = new Writable({
+      objectMode: true,
+      async write(pageDocuments, encoding, callback) {
+        for await (const document of pageDocuments) {
+          const descendantCount = await Page.recountDescendantCount(document._id);
+          await Page.findByIdAndUpdate(document._id, { descendantCount });
+        }
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+    aggregatedPages
+      .pipe(createBatchStream(BATCH_SIZE))
+      .pipe(recountWriteStream);
+
+    await streamToPromise(recountWriteStream);
+  }
+
+  // update descendantCount of all pages that are ancestors of a provided pageId by count
+  async updateDescendantCountOfAncestors(pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean): Promise<void> {
+    const Page = this.crowi.model('Page');
+    const ancestors = await Page.findAncestorsUsingParentRecursively(pageId, shouldIncludeTarget);
+    const ancestorPageIds = ancestors.map(p => p._id);
+    await Page.incrementDescendantCountOfPageIds(ancestorPageIds, inc);
+  }
+
+}
+
+export default PageService;

+ 6 - 6
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -99,7 +99,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
   }
 
 
   shouldIndexed(page) {
   shouldIndexed(page) {
-    return page.revision != null && page.redirectTo == null;
+    return page.revision != null;
   }
   }
 
 
   initClient() {
   initClient() {
@@ -415,7 +415,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
   }
 
 
   updateOrInsertDescendantsPagesById(page, user) {
   updateOrInsertDescendantsPagesById(page, user) {
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.find());
     const builder = new PageQueryBuilder(Page.find());
     builder.addConditionToListWithDescendants(page.path);
     builder.addConditionToListWithDescendants(page.path);
@@ -428,7 +428,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   async updateOrInsertPages(queryFactory, option: any = {}) {
   async updateOrInsertPages(queryFactory, option: any = {}) {
     const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
     const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
 
 
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
     const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
@@ -441,8 +441,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     const shouldIndexed = this.shouldIndexed.bind(this);
     const shouldIndexed = this.shouldIndexed.bind(this);
     const bulkWrite = this.client.bulk.bind(this.client);
     const bulkWrite = this.client.bulk.bind(this.client);
 
 
-    const findQuery = new PageQueryBuilder(queryFactory()).addConditionToExcludeRedirect().query;
-    const countQuery = new PageQueryBuilder(queryFactory()).addConditionToExcludeRedirect().query;
+    const findQuery = new PageQueryBuilder(queryFactory()).query;
+    const countQuery = new PageQueryBuilder(queryFactory()).query;
 
 
     const totalCount = await countQuery.count();
     const totalCount = await countQuery.count();
 
 
@@ -831,7 +831,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
 
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
 
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const {
     const {
       GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
       GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = Page;
     } = Page;

+ 1 - 1
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -28,7 +28,7 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     }
     }
 
 
     // find private legacy pages
     // find private legacy pages
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
     const queryBuilder = new PageQueryBuilder(Page.find());
     const queryBuilder = new PageQueryBuilder(Page.find());

+ 1 - 1
packages/app/src/server/service/search.ts

@@ -367,7 +367,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     /*
     /*
      * Format ElasticSearch result
      * Format ElasticSearch result
      */
      */
-    const Page = this.crowi.model('Page') as PageModel;
+    const Page = this.crowi.model('Page') as unknown as PageModel;
     const User = this.crowi.model('User');
     const User = this.crowi.model('User');
     const result = {} as IFormattedSearchResult;
     const result = {} as IFormattedSearchResult;
 
 

+ 4 - 11
packages/app/src/server/util/compare-objectId.ts

@@ -1,9 +1,11 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
 type IObjectId = mongoose.Types.ObjectId;
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Types.ObjectId;
 
 
-export const isIncludesObjectId = (arr: (IObjectId | string)[], id: IObjectId | string): boolean => {
+export const isIncludesObjectId = (arr: ObjectIdLike[], id: ObjectIdLike): boolean => {
   const _arr = arr.map(i => i.toString());
   const _arr = arr.map(i => i.toString());
   const _id = id.toString();
   const _id = id.toString();
 
 
@@ -17,7 +19,7 @@ export const isIncludesObjectId = (arr: (IObjectId | string)[], id: IObjectId |
  * @returns Array of mongoose.Types.ObjectId
  * @returns Array of mongoose.Types.ObjectId
  */
  */
 export const excludeTestIdsFromTargetIds = <T extends { toString: any } = IObjectId>(
 export const excludeTestIdsFromTargetIds = <T extends { toString: any } = IObjectId>(
-  targetIds: T[], testIds: (IObjectId | string)[],
+  targetIds: T[], testIds: ObjectIdLike[],
 ): T[] => {
 ): T[] => {
   // cast to string
   // cast to string
   const arr1 = targetIds.map(e => e.toString());
   const arr1 = targetIds.map(e => e.toString());
@@ -32,12 +34,3 @@ export const excludeTestIdsFromTargetIds = <T extends { toString: any } = IObjec
 
 
   return shouldReturnString(targetIds) ? excluded : excluded.map(e => new ObjectId(e));
   return shouldReturnString(targetIds) ? excluded : excluded.map(e => new ObjectId(e));
 };
 };
-
-export const removeDuplicates = (objectIds: (IObjectId | string)[]): IObjectId[] => {
-  // cast to string
-  const strs = objectIds.map(id => id.toString());
-  const uniqueArr = Array.from(new Set(strs));
-
-  // cast to ObjectId
-  return uniqueArr.map(str => new ObjectId(str));
-};

+ 1 - 9
packages/app/src/server/util/swigFunctions.js

@@ -146,8 +146,7 @@ module.exports = function(crowi, req, locals) {
     return false;
     return false;
   };
   };
 
 
-  locals.isTrashPage = function() {
-    const path = req.path || '';
+  locals.isTrashPage = function(path = '') {
     if (path.match(/^\/trash(\/.*)?$/)) {
     if (path.match(/^\/trash(\/.*)?$/)) {
       return true;
       return true;
     }
     }
@@ -155,13 +154,6 @@ module.exports = function(crowi, req, locals) {
     return false;
     return false;
   };
   };
 
 
-  locals.isDeletablePage = function() {
-    const Page = crowi.model('Page');
-    const path = req.path || '';
-
-    return Page.isDeletableName(path);
-  };
-
   locals.userPageRoot = function(user) {
   locals.userPageRoot = function(user) {
     if (!user || !user.username) {
     if (!user || !user.username) {
       return '';
       return '';

+ 1 - 2
packages/app/src/server/views/layout-growi/identical-path-page.html

@@ -18,8 +18,7 @@
       <div class="flex-grow-1 flex-basis-0 mw-0">
       <div class="flex-grow-1 flex-basis-0 mw-0">
         <div
         <div
           id="identical-path-page"
           id="identical-path-page"
-          data-identical-page-data-list="{{ identicalPageDataList|json }}"
-          data-shortody-map="{{ shortBodyMap|json }}"
+          data-identical-path-pages="{{ identicalPathPages|json }}"
         ></div>
         ></div>
       </div>
       </div>
       <div id="page-context"></div>
       <div id="page-context"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов