Browse Source

Merge branch 'dev/5.0.x' into feat/scroll-into-highlighted-string

yohei0125 4 years ago
parent
commit
d67b8f4ce6
88 changed files with 3997 additions and 2575 deletions
  1. 5 2
      packages/app/resource/locales/en_US/translation.json
  2. 4 2
      packages/app/resource/locales/ja_JP/translation.json
  3. 4 2
      packages/app/resource/locales/zh_CN/translation.json
  4. 6 0
      packages/app/src/client/base.jsx
  5. 2 3
      packages/app/src/client/services/AdminAppContainer.js
  6. 2 6
      packages/app/src/client/services/ContextExtractor.tsx
  7. 0 2
      packages/app/src/client/services/PageContainer.js
  8. 53 0
      packages/app/src/client/services/page-operation.ts
  9. 2 2
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  10. 20 14
      packages/app/src/components/BookmarkButtons.tsx
  11. 11 3
      packages/app/src/components/Common/ClosableTextInput.tsx
  12. 187 90
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  13. 61 12
      packages/app/src/components/DescendantsPageList.tsx
  14. 20 8
      packages/app/src/components/IdenticalPathPage.tsx
  15. 7 14
      packages/app/src/components/LikeButtons.tsx
  16. 61 4
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  17. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  18. 93 63
      packages/app/src/components/Navbar/SubNavButtons.tsx
  19. 14 4
      packages/app/src/components/NotFoundPage.tsx
  20. 11 22
      packages/app/src/components/Page/PageManagement.jsx
  21. 1 0
      packages/app/src/components/Page/TrashPageAlert.jsx
  22. 0 5
      packages/app/src/components/PageAccessoriesModalControl.jsx
  23. 18 12
      packages/app/src/components/PageDeleteModal.tsx
  24. 15 15
      packages/app/src/components/PageDuplicateModal.jsx
  25. 1 1
      packages/app/src/components/PageHistory/RevisionDiff.jsx
  26. 6 5
      packages/app/src/components/PageList/PageList.tsx
  27. 25 23
      packages/app/src/components/PageList/PageListItemL.tsx
  28. 0 49
      packages/app/src/components/PageReactionButtons.tsx
  29. 16 17
      packages/app/src/components/PageRenameModal.jsx
  30. 1 20
      packages/app/src/components/SearchPage.jsx
  31. 32 6
      packages/app/src/components/SearchPage/SearchResultList.tsx
  32. 2 23
      packages/app/src/components/Sidebar/PageTree.tsx
  33. 73 94
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  34. 9 26
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  35. 9 41
      packages/app/src/components/SubscribeButton.tsx
  36. 8 16
      packages/app/src/components/User/SeenUserInfo.tsx
  37. 0 8
      packages/app/src/interfaces/page-info.ts
  38. 59 11
      packages/app/src/interfaces/page.ts
  39. 4 0
      packages/app/src/interfaces/revision.ts
  40. 4 3
      packages/app/src/interfaces/search.ts
  41. 6 0
      packages/app/src/interfaces/subscription.ts
  42. 107 0
      packages/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration.js
  43. 85 0
      packages/app/src/migrations/20220131001218-convert-redirect-to-pages-to-page-redirect-documents.js
  44. 4 2
      packages/app/src/server/crowi/index.js
  45. 3 0
      packages/app/src/server/interfaces/mongoose-utils.ts
  46. 25 0
      packages/app/src/server/middlewares/apiv1-form-validator.ts
  47. 19 61
      packages/app/src/server/models/obsolete-page.js
  48. 29 0
      packages/app/src/server/models/page-redirect.ts
  49. 146 46
      packages/app/src/server/models/page.ts
  50. 5 40
      packages/app/src/server/models/revision.js
  51. 8 9
      packages/app/src/server/models/subscription.ts
  52. 4 0
      packages/app/src/server/routes/apiv3/overwrite-params/pages.js
  53. 52 3
      packages/app/src/server/routes/apiv3/page-listing.ts
  54. 40 70
      packages/app/src/server/routes/apiv3/page.js
  55. 41 34
      packages/app/src/server/routes/apiv3/pages.js
  56. 1 1
      packages/app/src/server/routes/apiv3/revisions.js
  57. 3 2
      packages/app/src/server/routes/index.js
  58. 48 39
      packages/app/src/server/routes/page.js
  59. 4 3
      packages/app/src/server/service/in-app-notification.ts
  60. 80 35
      packages/app/src/server/service/page-grant.ts
  61. 0 1319
      packages/app/src/server/service/page.js
  62. 2162 0
      packages/app/src/server/service/page.ts
  63. 6 6
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  64. 1 1
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  65. 1 1
      packages/app/src/server/service/search.ts
  66. 4 11
      packages/app/src/server/util/compare-objectId.ts
  67. 1 9
      packages/app/src/server/util/swigFunctions.js
  68. 1 2
      packages/app/src/server/views/layout-growi/identical-path-page.html
  69. 1 1
      packages/app/src/server/views/layout-growi/page_list.html
  70. 3 0
      packages/app/src/server/views/layout/layout.html
  71. 1 1
      packages/app/src/server/views/widget/page_alerts.html
  72. 0 2
      packages/app/src/server/views/widget/page_content.html
  73. 6 5
      packages/app/src/stores/bookmark.ts
  74. 0 8
      packages/app/src/stores/context.tsx
  75. 2 1
      packages/app/src/stores/page-listing.tsx
  76. 16 34
      packages/app/src/stores/page.tsx
  77. 122 2
      packages/app/src/stores/ui.tsx
  78. 1 11
      packages/app/src/stores/user.tsx
  79. 0 27
      packages/app/src/styles/_page-accessories-control.scss
  80. 46 7
      packages/app/src/styles/_subnav.scss
  81. 3 9
      packages/app/src/styles/atoms/_buttons.scss
  82. 1 1
      packages/app/src/styles/theme/_apply-colors.scss
  83. 1 0
      packages/app/test/integration/models/page.test.js
  84. 43 135
      packages/app/test/integration/service/page.test.js
  85. 14 2
      packages/app/test/integration/service/v5-migration.test.js
  86. 1 2
      packages/plugin-attachment-refs/src/server/routes/refs.js
  87. 1 2
      packages/plugin-lsx/src/server/routes/lsx.js
  88. 2 2
      packages/ui/src/components/SearchPage/FootstampIcon.jsx

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

@@ -179,7 +179,9 @@
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "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": {
     "Create Page": "Create Page",
@@ -973,7 +975,8 @@
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },
   "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" : {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",

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

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

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

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

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

@@ -7,6 +7,9 @@ import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import PageCreateModal from '../components/PageCreateModal';
+import PageDeleteModal from '../components/PageDeleteModal';
+import PageDuplicateModal from '../components/PageDuplicateModal';
+import PageRenameModal from '../components/PageRenameModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
@@ -40,6 +43,9 @@ const componentMappings = {
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
 
   'page-create-modal': <PageCreateModal />,
+  'page-delete-modal': <PageDeleteModal />,
+  'page-duplicate-modal': <PageDuplicateModal />,
+  'page-rename-modal': <PageRenameModal />,
 
   '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
    * @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;
     return { isV5Compatible };
   }

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

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

+ 0 - 2
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,
       isTrashPage: isTrashPage(path),
       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')),
-      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),

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

@@ -0,0 +1,53 @@
+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);
+  }
+};

+ 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';
 
 type Props = {
-  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 
 const V5PageMigration: FC<Props> = (props: Props) => {
@@ -17,7 +17,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     try {
-      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
       if (isV5Compatible) {
 
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));

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

@@ -9,16 +9,20 @@ import UserPictureList from './User/UserPictureList';
 import { useIsGuestUser } from '~/stores/context';
 
 interface Props {
+  bookmarkCount?: number
+  isBookmarked?: boolean
+  bookmarkedUsers?: IUser[]
   hideTotalNumber?: boolean
-  isBookmarked: boolean
-  sumOfBookmarks: number
-  bookmarkedUsers: IUser[]
   onBookMarkClicked: ()=>void;
 }
 
 const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
 
+  const {
+    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+  } = props;
+
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -40,9 +44,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         id="bookmark-button"
         onClick={handleClick}
         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>
 
       {isGuestUser && (
@@ -51,18 +55,20 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         </UncontrolledTooltip>
       )}
 
-      { !props.hideTotalNumber && (
+      { !hideTotalNumber && (
         <>
           <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
-            {props.sumOfBookmarks}
+            {bookmarkCount ?? 0}
           </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>

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

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

+ 187 - 90
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 {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
@@ -6,132 +6,229 @@ import {
 import toastr from 'toastr';
 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>,
+  onClickRenameMenuItem?: (pageId: string) => void,
+  onClickDeleteMenuItem?: (pageId: string) => void,
+
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+}
+
+
+type DropdownMenuProps = CommonProps & {
+  pageId: string,
+  isLoading?: boolean,
 }
 
-const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
+const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
+  const { t } = useTranslation('');
 
   const {
-    page, isEnableActions, onClickDeleteButtonHandler, isDeletable, onClickRenameButtonHandler,
+    pageId, isLoading,
+    pageInfo, isEnableActions, showBookmarkMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems,
   } = 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();
-    }
-    catch (err) {
-      toastError(err);
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const renameItemClickedHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
     }
-  });
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
 
-  const renderBookmarkText = () => {
-    if (bookmarkInfoError != null || bookmarkInfo == null) {
-      return '';
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const deleteItemClickedHandler = useCallback(async() => {
+    if (pageInfo == null || onClickDeleteMenuItem == null) {
+      return;
     }
-    return bookmarkInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark');
-  };
-
-
-  const dropdownToggle = () => {
-    setIsOpen(!isOpen);
-  };
-
-
-  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 (!pageInfo.isDeletable) {
+      logger.warn('This page could not be deleted.');
+      return;
+    }
+    await onClickDeleteMenuItem(pageId);
+  }, [onClickDeleteMenuItem, pageId, pageInfo]);
+
+  let contents = <></>;
+
+  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>
             <p>
               {t('search_result.currently_not_implemented')}
             </p>
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={bookmarkToggleHandler}>
+        ) }
+
+        {/* Bookmark */}
+        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+          <DropdownItem onClick={bookmarkItemClickedHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
-            {renderBookmarkText()}
+            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
-        )}
-        {isEnableActions && (
+        ) }
+
+        {/* Duplicate */}
+        { isEnableActions && !pageInfo.isEmpty && (
           <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={renameButtonClickedHandler}>
+        ) }
+
+        {/* Move/Rename */}
+        { isEnableActions && pageInfo.isMovable && (
+          <DropdownItem onClick={renameItemClickedHandler}>
             <i className="icon-fw  icon-action-redo"></i>
             {t('Move/Rename')}
           </DropdownItem>
-        )}
-        {isDeletable && isEnableActions && (
+        ) }
+
+        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+
+        {/* divider */}
+        {/* Delete */}
+        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
           <>
             <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>
               {t('Delete')}
             </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,
+  } = 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;
+
+  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}
+      />
     </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} />;
+};

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

@@ -1,6 +1,11 @@
 import React, { useState } from 'react';
+import {
+  IPageHasId, IPageWithMeta,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useIsGuestUser } from '~/stores/context';
 
-import { useSWRxPageList } from '~/stores/page';
+import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
@@ -9,12 +14,54 @@ type Props = {
   path: string,
 }
 
+
+const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
+  return { pageData: page };
+};
+
 const DescendantsPageList = (props: Props): JSX.Element => {
   const { path } = props;
 
   const [activePage, setActivePage] = useState(1);
 
-  const { data, error } = useSWRxPageList(path, activePage);
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: pagingResult, error } = useSWRxPageList(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) {
     setActivePage(selectedPageNumber);
@@ -28,7 +75,7 @@ const DescendantsPageList = (props: Props): JSX.Element => {
     );
   }
 
-  if (data === undefined) {
+  if (pagingResult == null || pagingResultWithMeta == null) {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
@@ -40,15 +87,17 @@ const DescendantsPageList = (props: Props): JSX.Element => {
 
   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>
     </>
   );
 };

+ 20 - 8
packages/app/src/components/IdenticalPathPage.tsx

@@ -8,6 +8,8 @@ import { DevidedPagePath } from '@growi/core';
 import { useCurrentPagePath } from '~/stores/context';
 
 import { PageListItemL } from './PageList/PageListItemL';
+import { useSWRxPageInfoForList } from '~/stores/page';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 
 
 type IdenticalPathAlertProps = {
@@ -34,7 +36,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
       <p>
         {t('duplicated_page_alert.same_page_name_exists_at_path',
           { path: _path, pageName: _pageName })}<br />
-        <p
+        <span
           // eslint-disable-next-line react/no-danger
           dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
         />
@@ -55,8 +57,11 @@ const jsonNull = 'null';
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
 
   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: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
 
   const { data: currentPath } = useCurrentPagePath();
 
@@ -76,16 +81,23 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
         <IdenticalPathAlert path={currentPath} />
 
         <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-flush">
+            {pages.map((page) => {
+              const pageId = page._id;
+              const pageInfo = (idToPageInfoMap ?? {})[pageId];
+
+              const pageWithMeta: IPageWithMeta = {
+                pageData: page,
+                pageMeta: pageInfo,
+              };
+
               return (
                 <PageListItemL
-                  key={data.pageData._id}
-                  page={data}
+                  key={pageId}
+                  page={pageWithMeta}
                   isSelected={false}
                   isChecked={false}
                   isEnableActions
-                  shortBody={shortbodyMap[data.pageData._id]}
                 // 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';
 
 type LikeButtonsProps = {
-  appContainer: AppContainer,
 
   hideTotalNumber?: boolean,
   sumOfLikers: number,
-  isLiked: boolean,
   likers: IUser[],
+
+  isGuestUser?: boolean,
+  isLiked?: boolean,
   onLikeClicked?: ()=>void,
 }
 
@@ -27,30 +28,22 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
-  const handleClick = () => {
-    if (props.onLikeClicked == null) {
-      return;
-    }
-    props.onLikeClicked();
-  };
-
   const {
-    appContainer, hideTotalNumber, isLiked, sumOfLikers,
+    hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
   } = props;
-  const { isGuestUser } = appContainer;
 
   return (
     <div className="btn-group" role="group" aria-label="Like buttons">
       <button
         type="button"
         id="like-button"
-        onClick={handleClick}
+        onClick={onLikeClicked}
         className={`btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
-      {isGuestUser && (
+      { isGuestUser && (
         <UncontrolledTooltip placement="top" target="like-button" fade={false}>
           {t('Not available for guest')}
         </UncontrolledTooltip>
@@ -62,7 +55,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
             {sumOfLikers}
           </button>
           <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">
                 {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
               </div>

+ 61 - 4
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,12 @@
 import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 
+import { useTranslation } from 'react-i18next';
+
+import { DropdownItem } from 'reactstrap';
+
+import urljoin from 'url-join';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
@@ -9,10 +15,11 @@ import {
 } from '~/stores/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useIsGuestUser,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser,
 } from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
 import { SubNavButtons } from './SubNavButtons';
 import PageEditorModeManager from './PageEditorModeManager';
 
@@ -20,6 +27,52 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { IPageHasId } from '~/interfaces/page';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
+import PresentationIcon from '../Icons/PresentationIcon';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId } = props;
+
+  const exportPageHandler = useCallback(async(format: string): Promise<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;
+  }, [pageId, revisionId]);
+
+  return (
+    <>
+      <DropdownItem divider />
+
+      {/* 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={() => exportPageHandler('md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </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();
@@ -33,6 +86,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: creator } = useCreator();
   const { data: revisionAuthor } = useRevisionAuthor();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -77,11 +131,14 @@ const GrowiContextualSubNavigation = (props) => {
     return (
       <>
         <div className="h-50 d-flex flex-column align-items-end justify-content-center">
-          { isViewMode && (
+          { pageId != null && isViewMode && (
             <SubNavButtons
               isCompactMode={isCompactMode}
               pageId={pageId}
+              revisionId={revisionId}
+              disableSeenUserInfoPopover={isSharedUser}
               showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} />}
             />
           ) }
         </div>
@@ -98,9 +155,9 @@ const GrowiContextualSubNavigation = (props) => {
       </>
     );
   }, [
-    pageId,
+    pageId, revisionId,
     editorMode, mutateEditorMode,
-    isCompactMode, isDeviceSmallerThanMd, isGuestUser,
+    isCompactMode, isDeviceSmallerThanMd, isGuestUser, isSharedUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
   ]);
 

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -29,7 +29,7 @@ type Props = {
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
 
-  controls?: any,
+  controls?: React.FunctionComponent,
 }
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {

+ 93 - 63
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,119 +1,149 @@
 import React, { useCallback } from 'react';
 
-import SubscribeButton from '../SubscribeButton';
-import PageReactionButtons from '../PageReactionButtons';
-import { useSWRPageInfo } from '../../stores/page';
+import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
+
+import { useSWRxPageInfo } from '../../stores/page';
 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 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 SubNavButtonsSubstanceProps= {
+
+type CommonProps = {
   isCompactMode?: boolean,
+  disableSeenUserInfoPopover?: boolean,
   showPageControlDropdown?: boolean,
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
-const SubNavButtonsSubstance = (props: { pageId: string } & SubNavButtonsSubstanceProps): JSX.Element => {
+
+type SubNavButtonsSubstanceProps= CommonProps & {
+  pageId: string,
+  revisionId: string,
+  pageInfo: IPageInfoAll,
+}
+
+const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
-    isCompactMode, pageId, showPageControlDropdown,
+    pageInfo, pageId, isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
   } = props;
 
   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);
 
-  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) {
       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]);
 
   const bookmarkClickHandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
       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 <></>;
   }
 
-  const { sumOfLikers, isLiked } = pageInfo;
-  const { sumOfBookmarks, isBookmarked, bookmarkedUsers } = bookmarkInfo;
+  const {
+    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+  } = pageInfo;
 
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
       <span>
-        <SubscribeButton pageId={props.pageId} />
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
       </span>
-      <PageReactionButtons
-        isCompactMode={isCompactMode}
+      <LikeButtons
+        hideTotalNumber={isCompactMode}
+        onLikeClicked={likeClickhandler}
         sumOfLikers={sumOfLikers}
         isLiked={isLiked}
-        likers={likers || []}
-        onLikeClicked={likeClickhandler}
-        sumOfBookmarks={sumOfBookmarks}
+        likers={likers}
+      />
+      <BookmarkButtons
+        hideTotalNumber={isCompactMode}
+        bookmarkCount={bookmarkCount}
         isBookmarked={isBookmarked}
-        bookmarkedUsers={bookmarkedUsers}
+        bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
         onBookMarkClicked={bookmarkClickHandler}
-      >
-      </PageReactionButtons>
-
+      />
+      <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
       { showPageControlDropdown && (
-        /*
-          TODO:
-          replace with PageItemControl
-        */
-        <></>
-        // <PageManagement
-        //   pageId={pageId}
-        //   revisionId={revisionId}
-        //   path={path}
-        //   isCompactMode={isCompactMode}
-        //   isDeletable={isDeletable}
-        //   isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        // >
-        // </PageManagement>
+        <PageItemControl
+          pageId={pageId}
+          pageInfo={pageInfo}
+          isEnableActions={!isGuestUser}
+          additionalMenuItemRenderer={additionalMenuItemRenderer}
+        />
       )}
     </div>
   );
 };
 
-type SubNavButtonsProps= SubNavButtonsSubstanceProps & {
-  pageId?: string | null,
+type SubNavButtonsProps= CommonProps & {
+  pageId: string,
+  revisionId?: string | null,
 };
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
-  const { pageId, isCompactMode } = props;
+  const { pageId, revisionId } = props;
+
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null);
+
+  if (revisionId == null || error != null) {
+    return <></>;
+  }
 
-  if (pageId == null) {
+  if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
   }
 
-  return <SubNavButtonsSubstance pageId={pageId} isCompactMode={isCompactMode} />;
+  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 PageListIcon from './Icons/PageListIcon';
@@ -6,15 +6,25 @@ import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import DescendantsPageList from './DescendantsPageList';
 import PageTimeline from './PageTimeline';
+import { useCurrentPagePath } from '~/stores/context';
+
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const DescendantsPageListForThisPage = useCallback((): JSX.Element => {
+    return currentPagePath != null
+      ? <DescendantsPageList path={currentPagePath} />
+      : <></>;
+  }, [currentPagePath]);
+
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageList,
+        Content: DescendantsPageListForThisPage,
         i18n: t('page_list'),
         index: 0,
       },
@@ -25,12 +35,12 @@ const NotFoundPage = (): JSX.Element => {
         index: 1,
       },
     };
-  }, [t]);
+  }, [DescendantsPageListForThisPage, t]);
 
 
   return (
     <div className="d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />
     </div>
   );
 };

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

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

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

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

+ 0 - 5
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -11,7 +11,6 @@ import TimeLineIcon from './Icons/TimeLineIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-import SeenUserInfo from './User/SeenUserInfo';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -91,10 +90,6 @@ const PageAccessoriesModalControl = (props) => {
           </Fragment>
         );
       })}
-      <div className="d-flex align-items-center">
-        <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} pageId={pageId} />
-      </div>
     </div>
   );
 };

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

@@ -6,14 +6,10 @@ import {
 import { useTranslation } from 'react-i18next';
 
 // import { apiPost } from '~/client/util/apiv1-client';
+import { usePageDeleteModalStatus, usePageDeleteModalOpened } from '~/stores/ui';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-export type IPageForPageDeleteModal = {
-  pageId: string,
-  revisionId: string,
-  path: string
-}
 
 const deleteIconAndKey = {
   completely: {
@@ -30,7 +26,6 @@ const deleteIconAndKey = {
 
 type Props = {
   isOpen: boolean,
-  pages: IPageForPageDeleteModal[],
   isDeleteCompletelyModal: boolean,
   isAbleToDeleteCompletely: boolean,
   onClose?: () => void,
@@ -39,12 +34,18 @@ type Props = {
 const PageDeleteModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('');
   const {
-    isOpen, onClose, isDeleteCompletelyModal, pages, isAbleToDeleteCompletely,
+    isDeleteCompletelyModal, isAbleToDeleteCompletely,
   } = props;
+
+
+  const { data: pagesDataToDelete, close: closeDeleteModal } = usePageDeleteModalStatus();
+  const { data: isOpened } = usePageDeleteModalOpened();
+
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
 
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState(null);
 
   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 (
-    <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>
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
       </ModalHeader>
@@ -153,9 +161,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
-          {pages.map((page) => {
-            return <div key={page.pageId}><code>{ page.path }</code></div>;
-          })}
+          {renderPagePathsToDelete()}
         </div>
         {renderDeleteRecursivelyForm()}
         {!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 { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageDuplicateModalStatus, usePageDuplicateModalOpened } from '~/stores/ui';
 
 import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
@@ -20,12 +21,16 @@ const LIMIT_FOR_LIST = 10;
 
 const PageDuplicateModal = (props) => {
   const {
-    t, appContainer, pageId, path,
+    t, appContainer,
   } = props;
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
+  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModalStatus();
+  const { data: isOpened } = usePageDuplicateModalOpened();
+
+  const { path, pageId } = pagesDataToDuplicate;
 
   const [pageNameInput, setPageNameInput] = useState(path);
 
@@ -50,14 +55,14 @@ const PageDuplicateModal = (props) => {
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
   const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [],
+    debounce(1000, checkExistPaths), [pageId, path],
   );
 
   useEffect(() => {
-    if (pageNameInput !== path) {
+    if (pageId != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
-  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
 
   /**
    * change pageNameInput for PagePathAutoComplete
@@ -94,10 +99,11 @@ const PageDuplicateModal = (props) => {
   }, [appContainer, path, t]);
 
   useEffect(() => {
-    if (props.isOpen) {
+    if (isOpened) {
       getSubordinatedList();
+      setPageNameInput(path);
     }
-  }, [props.isOpen, getSubordinatedList]);
+  }, [isOpened, getSubordinatedList, path]);
 
   function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
     setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
@@ -120,8 +126,8 @@ const PageDuplicateModal = (props) => {
   }
 
   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') }
       </ModalHeader>
       <ModalBody>
@@ -188,7 +194,7 @@ const PageDuplicateModal = (props) => {
             )}
           </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} />}
           </div>
         </div>
@@ -219,12 +225,6 @@ const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [A
 PageDuplicateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   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);

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

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

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

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

+ 25 - 23
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,33 +1,32 @@
-import React, { FC, memo, useCallback } from 'react';
+import React, { memo, useCallback } from 'react';
 
 import Clamp from 'react-multiline-clamp';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
-import { pagePathUtils, DevidedPagePath } from '@growi/core';
+import { DevidedPagePath } from '@growi/core';
 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 PageItemControl from '../Common/Dropdown/PageItemControl';
-
-const { isTopPage, isUserNamePage } = pagePathUtils;
+import { PageItemControl } from '../Common/Dropdown/PageItemControl';
 
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageSearchMeta>,
+  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isChecked?: boolean, // is checkbox of item checked
   isEnableActions?: boolean,
-  shortBody?: string
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onClickCheckbox?: (pageId: string) => void,
   onClickItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
 }
 
-export const PageListItemL: FC<Props> = memo((props:Props) => {
+export const PageListItemL = memo((props: Props): JSX.Element => {
   const {
     // 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,
   } = props;
 
@@ -36,6 +35,7 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
   const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
+  const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
   const pageTitle = (
     <PagePathLabel
@@ -115,28 +115,30 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
               </Clamp>
 
               {/* 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 */}
               <div className="item-control ml-auto">
                 <PageItemControl
-                  page={pageData}
-                  onClickDeleteButtonHandler={props.onClickDeleteButton}
+                  pageId={pageData._id}
+                  pageInfo={pageMeta}
+                  onClickDeleteMenuItem={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
-                  isDeletable={!isTopPage(pageData.path) && !isUserNamePage(pageData.path)}
                 />
               </div>
             </div>
             <div className="page-list-snippet py-1">
               <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>
             </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 { debounce } from 'throttle-debounce';
+import { usePageRenameModalStatus, usePageRenameModalOpened } from '~/stores/ui';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 
@@ -24,12 +25,16 @@ import DuplicatedPathsTable from './DuplicatedPathsTable';
 
 const PageRenameModal = (props) => {
   const {
-    t, appContainer, path, pageId, revisionId,
+    t, appContainer,
   } = props;
 
   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);
 
@@ -70,10 +75,11 @@ const PageRenameModal = (props) => {
   }, [path, t]);
 
   useEffect(() => {
-    if (props.isOpen) {
+    if (isOpened) {
       updateSubordinatedList();
+      setPageNameInput(path);
     }
-  }, [props.isOpen, updateSubordinatedList]);
+  }, [isOpened, path, updateSubordinatedList]);
 
 
   const checkExistPaths = async(newParentPath) => {
@@ -90,14 +96,14 @@ const PageRenameModal = (props) => {
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
   const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [],
+    debounce(1000, checkExistPaths), [path],
   );
 
   useEffect(() => {
-    if (pageNameInput !== path) {
+    if (pageId != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
-  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);
 
   /**
    * change pageNameInput
@@ -137,8 +143,8 @@ const PageRenameModal = (props) => {
   }
 
   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') }
       </ModalHeader>
       <ModalBody>
@@ -195,7 +201,7 @@ const PageRenameModal = (props) => {
               </label>
             </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} />}
         </div>
 
@@ -252,13 +258,6 @@ const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppConta
 PageRenameModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   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);

+ 1 - 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 PageDeleteModal from './PageDeleteModal';
 import { useIsGuestUser } from '~/stores/context';
-import { apiv3Get } from '~/client/util/apiv3-client';
 
 export const specificPathNames = {
   user: '/user',
@@ -40,7 +39,6 @@ class SearchPage extends React.Component {
       focusedSearchResultData: null,
       selectedPagesIdList: new Set(),
       searchResultCount: 0,
-      shortBodiesMap: null,
       activePage: 1,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       excludeUserPages: true,
@@ -152,11 +150,6 @@ class SearchPage extends React.Component {
     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
   // refs: https://redmine.weseek.co.jp/issues/82139
   async search(data) {
@@ -195,18 +188,6 @@ class SearchPage extends React.Component {
         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);
       if (res.data.length > 0) {
         this.setState({
@@ -324,7 +305,6 @@ class SearchPage extends React.Component {
         focusedSearchResultData={this.state.focusedSearchResultData}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         searchResultCount={this.state.searchResultCount}
-        shortBodiesMap={this.state.shortBodiesMap}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
         onClickItem={this.selectPage}
@@ -371,6 +351,7 @@ class SearchPage extends React.Component {
           activePage={this.state.activePage}
         >
         </SearchPageLayout>
+        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87569  */}
         <PageDeleteModal
           isOpen={this.state.isDeleteConfirmModalShown}
           onClose={this.closeDeleteConfirmModalHandler}

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

@@ -1,19 +1,19 @@
 import React, { FC } from 'react';
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageInfoForEntity, IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { useSWRxPageInfoForList } from '~/stores/page';
 
 import { PageListItemL } from '../PageList/PageListItemL';
 import PaginationWrapper from '../PaginationWrapper';
 
 
 type Props = {
-  pages: IPageWithMeta<IPageSearchMeta>[],
+  pages: IPageWithMeta<IPageInfoForEntity & IPageSearchMeta>[],
   selectedPagesIdList: Set<string>
   isEnableActions: boolean,
   searchResultCount?: number,
   activePage?: number,
   pagingLimit?: number,
-  shortBodiesMap?: Record<string, string>
   focusedSearchResultData?: IPageWithMeta<IPageSearchMeta>,
   onPagingNumberChanged?: (activePage: number) => void,
   onClickItem?: (pageId: string) => void,
@@ -24,13 +24,40 @@ type Props = {
 
 const SearchResultList: FC<Props> = (props:Props) => {
   const {
-    focusedSearchResultData, selectedPagesIdList, isEnableActions, shortBodiesMap,
+    pages, focusedSearchResultData, selectedPagesIdList, isEnableActions,
   } = 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 : '';
   return (
     <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);
 
         return (
@@ -38,7 +65,6 @@ const SearchResultList: FC<Props> = (props:Props) => {
             key={page.pageData._id}
             page={page}
             isEnableActions={isEnableActions}
-            shortBody={shortBodiesMap?.[page.pageData._id]}
             onClickItem={props.onClickItem}
             onClickCheckbox={props.onClickCheckbox}
             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 { useSWRxV5MigrationStatus } from '~/stores/page-listing';
@@ -8,8 +8,6 @@ import {
 
 import ItemsTree from './PageTree/ItemsTree';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
-import { IPageForPageDeleteModal } from '../PageDeleteModal';
-
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
@@ -19,13 +17,8 @@ const PageTree: FC = memo(() => {
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: notFoundTargetPathOrIdData } = useNotFoundTargetPathOrId();
-
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
-  // for delete modal
-  const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
-  const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
-
   const targetPathOrId = targetId || notFoundTargetPathOrIdData?.notFoundTargetPathOrId;
 
   if (migrationStatus == null) {
@@ -56,6 +49,7 @@ const PageTree: FC = memo(() => {
       </>
     );
   }
+
   /*
    * dependencies
    */
@@ -63,15 +57,6 @@ const PageTree: FC = memo(() => {
     return null;
   }
 
-  const onClickDeleteByPage = (page: IPageForPageDeleteModal) => {
-    setDeleteModalOpen(true);
-    setPagesToDelete([page]);
-  };
-
-  const onCloseDelete = () => {
-    setDeleteModalOpen(false);
-  };
-
   const path = currentPath || '/';
 
   return (
@@ -86,12 +71,6 @@ const PageTree: FC = memo(() => {
           targetPath={path}
           targetPathOrId={targetPathOrId}
           targetAndAncestorsData={targetAndAncestorsData}
-          isDeleteModalOpen={isDeleteModalOpen}
-          pagesToDelete={pagesToDelete}
-          isAbleToDeleteCompletely={false} // TODO: pass isAbleToDeleteCompletely
-          isDeleteCompletelyModal={false} // TODO: pass isDeleteCompletelyModal
-          onCloseDelete={onCloseDelete}
-          onClickDeleteByPage={onClickDeleteByPage}
         />
       </div>
 

+ 73 - 94
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,22 +1,23 @@
 import React, {
-  useCallback, useState, FC, useEffect, memo,
+  useCallback, useState, FC, useEffect,
 } from 'react';
-import nodePath from 'path';
+import { DropdownToggle } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
-import { pagePathUtils } from '@growi/core';
+
 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, isUserNamePage } = 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 {
@@ -24,7 +25,7 @@ interface ItemProps {
   itemNode: ItemNode
   targetPathOrId?: string
   isOpen?: boolean
-  onClickDeleteByPage?(page: IPageForPageDeleteModal): void
+  onClickDeleteByPage?(pageToDelete: IPageForPageDeleteModal | null): void
 }
 
 // Utility to mark target
@@ -41,64 +42,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 = {
@@ -123,6 +71,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   const { page, children } = itemNode;
 
+  const [pageTitle, setPageTitle] = useState(page.path);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
@@ -132,8 +81,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
 
-  const isDeletable = !page.isEmpty && !isTopPage(page.path as string) && !isUserNamePage(page.path as string);
-
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     item: { page },
@@ -178,7 +125,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     setNewPageInputShown(true);
   }, []);
 
-  const onClickDeleteButton = useCallback(() => {
+  const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteByPage == null) {
       return;
     }
@@ -199,16 +146,32 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [page, onClickDeleteByPage]);
 
 
-  const onClickRenameButton = useCallback(() => {
+  const onClickRenameButton = useCallback(async(_pageId: string): Promise<void> => {
     setRenameInputShown(true);
   }, []);
 
-  // TODO: make a put request to pages/title
-  const onPressEnterForRenameHandler = () => {
-    toastWarning(t('search_result.currently_not_implemented'));
-    setRenameInputShown(false);
+  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);
+    }
   };
 
+
   // TODO: go to create page page
   const onPressEnterForCreateHandler = () => {
     toastWarning(t('search_result.currently_not_implemented'));
@@ -216,20 +179,27 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   };
 
   const inputValidator = (title: string | null): AlertInfo | null => {
-    if (title == null || title === '') {
+    if (title == null || title === '' || title.trim() === '') {
       return {
         type: AlertType.WARNING,
         message: t('form_validation.title_required'),
       };
     }
 
+    if (title.includes('/')) {
+      return {
+        type: AlertType.WARNING,
+        message: t('form_validation.slashed_are_not_yet_supported'),
+      };
+    }
+
     return null;
   };
 
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
-  }, []);
+  }, [hasChildren]);
 
   /*
    * Make sure itemNode.children and currentChildren are synced
@@ -239,7 +209,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(children, targetPathOrId);
       setCurrentChildren(children);
     }
-  }, []);
+  }, [children, currentChildren.length, targetPathOrId]);
 
   /*
    * When swr fetch succeeded
@@ -250,7 +220,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(newChildren, targetPathOrId);
       setCurrentChildren(newChildren);
     }
-  }, [data, isOpen]);
+  }, [data, error, isOpen, targetPathOrId]);
 
   return (
     <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
@@ -274,6 +244,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         { isRenameInputShown && (
           <ClosableTextInput
             isShown
+            value={nodePath.basename(pageTitle as string)}
             placeholder={t('Input page name')}
             onClickOutside={() => { setRenameInputShown(false) }}
             onPressEnter={onPressEnterForRenameHandler}
@@ -281,11 +252,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           />
         )}
         { !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 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) && (
@@ -294,14 +262,25 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           </div>
         )}
         <div className="grw-pagetree-control d-none">
-          <ItemControl
-            page={page}
-            onClickPlusButton={onClickPlusButton}
-            onClickDeleteButton={onClickDeleteButton}
-            onClickRenameButton={onClickRenameButton}
+          <AsyncPageItemControl
+            pageId={page._id}
             isEnableActions={isEnableActions}
-            isDeletable={isDeletable}
-          />
+            showBookmarkMenuItem
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickDeleteMenuItem={onClickDeleteButton}
+            onClickRenameMenuItem={onClickRenameButton}
+          >
+            <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>
       </li>
 

+ 9 - 26
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -6,7 +6,7 @@ import Item from './Item';
 import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError } from '~/client/util/apiNotification';
-import PageDeleteModal, { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
+import { IPageForPageDeleteModal, usePageDeleteModalStatus } from '~/stores/ui';
 
 /*
  * Utility to generate initial node
@@ -52,19 +52,10 @@ type ItemsTreeProps = {
   targetPath: string
   targetPathOrId?: string
   targetAndAncestorsData?: TargetAndAncestors
-
-  // for deleteModal
-  isDeleteModalOpen: boolean
-  pagesToDelete: IPageForPageDeleteModal[]
-  isAbleToDeleteCompletely: boolean
-  isDeleteCompletelyModal: boolean
-  onCloseDelete(): void
-  onClickDeleteByPage(page: IPageForPageDeleteModal): void
 }
 
 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, onClickDeleteByPage?: (pageToDelete: IPageForPageDeleteModal | null) => void,
 ): JSX.Element => {
   return (
     <ul className="grw-pagetree list-group p-3">
@@ -76,7 +67,6 @@ const renderByInitialNode = (
         isEnableActions={isEnableActions}
         onClickDeleteByPage={onClickDeleteByPage}
       />
-      {DeleteModal}
     </ul>
   );
 };
@@ -87,22 +77,16 @@ const renderByInitialNode = (
  */
 const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
-    onClickDeleteByPage, isEnableActions,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
   } = props;
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
+  const { open: openDeleteModal } = usePageDeleteModalStatus();
 
-  const DeleteModal = (
-    <PageDeleteModal
-      isOpen={isDeleteModalOpen}
-      pages={pagesToDelete}
-      isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-      isDeleteCompletelyModal={isDeleteCompletelyModal}
-      onClose={onCloseDelete}
-    />
-  );
+  const onClickDeleteByPage = (pageToDelete: IPageForPageDeleteModal) => {
+    openDeleteModal([pageToDelete]);
+  };
 
   if (error1 != null || error2 != null) {
     // TODO: improve message
@@ -115,7 +99,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetPathOrId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDeleteByPage);
   }
 
   /*
@@ -123,11 +107,10 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetPathOrId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDeleteByPage);
   }
 
   return null;
 };
 
-
 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 { 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 = {
-  pageId: string,
+  isGuestUser?: boolean,
+  status?: SubscriptionStatusType,
+  onClick?: () => Promise<void>,
 };
 
 const SubscribeButton: FC<Props> = (props: Props) => {
   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 (
     <>
       <button
         type="button"
         id="subscribe-button"
-        onClick={handleClick}
+        onClick={props.onClick}
         className={`btn btn-subscribe border-0 ${buttonClass}`}
       >
         <i className={iconClass}></i>

+ 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 { FootstampIcon } from '@growi/ui';
 
+import { IUser } from '~/interfaces/user';
+
 import UserPictureList from './UserPictureList';
-import { useSWRxPageInfo } from '~/stores/page';
-import { useSWRxUsersList } from '~/stores/user';
 
 interface Props {
-  pageId: string,
-  disabled: boolean
+  seenUsers: IUser[],
+  disabled?: boolean,
 }
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
-  const { pageId, disabled } = props;
-
   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);
 
   return (
     <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">
           <FootstampIcon />
         </span>
         <span className="seen-user-count">{seenUsers.length}</span>
       </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">
             <UserPictureList users={seenUsers} />
           </div>

+ 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 { IUser } from './user';
-import { IRevision } from './revision';
+import { IRevision, HasRevisionShortbody } from './revision';
 import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
+import { SubscriptionStatusType } from './subscription';
 
-export type IPage = {
+
+export interface IPage {
   path: string,
   status: string,
   revision: Ref<IRevision>,
@@ -16,7 +18,6 @@ export type IPage = {
   parent: Ref<IPage> | null,
   descendantCount: number,
   isEmpty: boolean,
-  redirectTo: string,
   grant: number,
   grantedUsers: Ref<IUser>[],
   grantedGroup: Ref<any>,
@@ -36,16 +37,63 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 
 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,
+  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,
-  pageMeta?: Partial<IPageInfo> & M,
+  pageMeta?: M,
 };

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

@@ -14,3 +14,7 @@ export type IRevisionOnConflict = {
   createdAt: Date,
   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 {
   NONE_CHECKED = 'noneChecked',
@@ -7,6 +7,7 @@ export enum CheckboxType {
 }
 
 export type IPageSearchMeta = {
+  bookmarkCount?: number,
   elasticSearchResult?: {
     snippet: 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 = {

+ 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 SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
+import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { InstallerService } from '../service/installer';
 import Activity from '../models/activity';
 import UserGroup from '../models/user-group';
+import PageRedirect from '../models/page-redirect';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
@@ -279,6 +281,7 @@ Crowi.prototype.setupModels = async function() {
   // include models that independent from crowi
   allModels.Activity = Activity;
   allModels.UserGroup = UserGroup;
+  allModels.PageRedirect = PageRedirect;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
@@ -669,9 +672,8 @@ Crowi.prototype.setupImport = async function() {
 };
 
 Crowi.prototype.setupPageService = async function() {
-  const PageEventService = require('../service/page');
   if (this.pageService == null) {
-    this.pageService = new PageEventService(this);
+    this.pageService = new PageService(this);
   }
   if (this.pageGrantService == null) {
     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 escapeStringRegexp = require('escape-string-regexp');
 
-const { isTopPage, isTrashPage } = pagePathUtils;
+const { isTopPage, isTrashPage, isUserNamePage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
@@ -104,11 +104,6 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionToExcludeRedirect() {
-    this.query = this.query.and({ redirectTo: null });
-    return this;
-  }
-
   /**
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * If top page, return without doing anything.
@@ -327,6 +322,11 @@ export class PageQueryBuilder {
     return this;
   }
 
+  addConditionToFilteringByParentId(parentId) {
+    this.query = this.query.and({ parent: parentId });
+    return this;
+  }
+
 }
 
 export const getPageSchema = (crowi) => {
@@ -564,18 +564,7 @@ export const getPageSchema = (crowi) => {
   };
 
   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) {
@@ -632,6 +621,16 @@ export const getPageSchema = (crowi) => {
     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
   pageSchema.statics.findByPath = function(path, includeEmpty = false) {
     if (path == null) {
@@ -675,10 +674,6 @@ export const getPageSchema = (crowi) => {
     return queryBuilder.query.exec();
   };
 
-  pageSchema.statics.findByRedirectTo = function(path) {
-    return this.findOne({ redirectTo: path });
-  };
-
   /**
    * 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);
     builder.addConditionToListWithDescendants(page.path, option);
-    builder.addConditionToExcludeRedirect();
 
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
@@ -750,9 +744,6 @@ export const getPageSchema = (crowi) => {
     const opt = Object.assign({}, option);
     const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
 
-    if (excludeRedirect) {
-      builder.addConditionToExcludeRedirect();
-    }
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
     // count
@@ -789,10 +780,6 @@ export const getPageSchema = (crowi) => {
     if (!opt.includeTrashed) {
       builder.addConditionToExcludeTrashed();
     }
-    // exclude redirect pages
-    if (!opt.includeRedirect) {
-      builder.addConditionToExcludeRedirect();
-    }
 
     // add grant conditions
     await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
@@ -994,7 +981,6 @@ export const getPageSchema = (crowi) => {
     const Page = this;
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
-    const redirectTo = options.redirectTo || null;
     const grantUserGroupId = options.grantUserGroupId || null;
 
     // sanitize path
@@ -1016,7 +1002,6 @@ export const getPageSchema = (crowi) => {
     page.path = path;
     page.creator = user;
     page.lastUpdateUser = user;
-    page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
@@ -1024,8 +1009,7 @@ export const getPageSchema = (crowi) => {
 
     let savedPage = await page.save();
     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();
 
     pageEvent.emit('create', savedPage, user);
@@ -1047,8 +1031,7 @@ export const getPageSchema = (crowi) => {
     // update existing page
     let savedPage = await pageData.save();
     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();
 
     if (isSyncRevisionToHackmd) {
@@ -1064,8 +1047,6 @@ export const getPageSchema = (crowi) => {
     const builder = new PageQueryBuilder(this.find());
     builder.addConditionToListWithDescendants(parentPage.path);
 
-    builder.addConditionToExcludeRedirect();
-
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
 
@@ -1090,29 +1071,6 @@ export const getPageSchema = (crowi) => {
     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) {
     const queryBuilder = new PageQueryBuilder(this.find(), includeEmpty);
     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);

+ 146 - 46
packages/app/src/server/models/page.ts

@@ -6,12 +6,14 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import nodePath from 'path';
-
 import { getOrCreateModel, pagePathUtils } from '@growi/core';
+
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import { IPage } from '../../interfaces/page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import { PageRedirectModel } from './page-redirect';
 
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
@@ -32,14 +34,18 @@ const STATUS_DELETED = 'deleted';
 
 export interface PageDocument extends IPage, Document {}
 
+
 type TargetAndAncestorsResult = {
   targetAndAncestors: PageDocument[]
   rootPage: PageDocument
 }
+
+export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   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[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -70,7 +76,6 @@ const schema = new Schema<PageDocument, PageModel>({
     type: String, required: true, index: true,
   },
   revision: { type: ObjectId, ref: 'Revision' },
-  redirectTo: { type: String, index: true },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   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: number, // 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) {
-    return parent._id;
+    return parent;
   }
 
   /*
@@ -162,16 +225,15 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string, parent
   await this.createEmptyPagesByPaths(ancestorPaths);
 
   // find ancestors
-  const builder = new PageQueryBuilder(this.find({}, { _id: 1, path: 1 }), true);
+  const builder = new PageQueryBuilder(this.find(), true);
   const ancestors = await builder
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToSortPagesByDescPath()
     .query
-    .lean()
     .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
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
@@ -191,8 +253,9 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string, parent
   });
   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
@@ -206,6 +269,18 @@ const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroup
   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.
  */
@@ -279,7 +354,7 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
   }
   else {
     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);
 
@@ -380,21 +455,12 @@ schema.statics.getAggrConditionForPageWithProvidedPathAndDescendants = function(
  * add/subtract descendantCount of pages with provided paths by increment.
  * 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> {
+schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id: ObjectIdLike):Promise<void> {
   const res = await this.aggregate(
     [
       {
@@ -404,8 +470,8 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mo
       },
       {
         $project: {
-          path: 1,
           parent: 1,
+          isEmpty: 1,
           descendantCount: 1,
         },
       },
@@ -416,7 +482,9 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mo
             $sum: '$descendantCount',
           },
           sumOfDocsCount: {
-            $sum: 1,
+            $sum: {
+              $cond: { if: { $eq: ['$isEmpty', true] }, then: 0, else: 1 }, // exclude isEmpty true page from sumOfDocsCount
+            },
           },
         },
       },
@@ -434,6 +502,28 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mo
   await this.findByIdAndUpdate(id, query);
 };
 
+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
  */
@@ -443,7 +533,7 @@ export default (crowi: Crowi): any => {
     pageEvent = crowi.event('page');
   }
 
-  schema.statics.create = async function(path, body, user, options = {}) {
+  schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
     if (crowi.pageGrantService == null || crowi.configManager == null) {
       throw Error('Crowi is not setup');
     }
@@ -457,7 +547,7 @@ export default (crowi: Crowi): any => {
     const Page = this;
     const Revision = crowi.model('Revision');
     const {
-      format = 'markdown', redirectTo, grantUserGroupId,
+      format = 'markdown', grantUserGroupId,
     } = options;
     let grant = options.grant;
 
@@ -509,17 +599,15 @@ export default (crowi: Crowi): any => {
       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)) {
-      parentId = await Page.getParentIdAndFillAncestors(path, parent);
+      parentId = parent._id;
     }
 
     page.path = path;
     page.creator = user;
     page.lastUpdateUser = user;
-    page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
 
     // set parent to null when GRANT_RESTRICTED
@@ -534,12 +622,24 @@ export default (crowi: Crowi): any => {
 
     let savedPage = await page.save();
 
+    await crowi.pageService?.updateDescendantCountOfAncestors(page._id, 1, false);
+
     /*
      * 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 revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
     pageEvent.emit('create', savedPage, user);
@@ -552,8 +652,9 @@ export default (crowi: Crowi): any => {
       throw Error('Crowi is not set up');
     }
 
+    const isPageMigrated = pageData.parent != null;
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    if (!isV5Compatible) {
+    if (!isV5Compatible || !isPageMigrated) {
       // v4 compatible process
       return this.updatePageV4(pageData, body, previousBody, user, options);
     }
@@ -593,8 +694,7 @@ export default (crowi: Crowi): any => {
     // update existing page
     let savedPage = await newPageData.save();
     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();
 
     if (isSyncRevisionToHackmd) {
@@ -611,7 +711,7 @@ export default (crowi: Crowi): any => {
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   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 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: {
       type: String,
       required: true,
@@ -29,25 +30,8 @@ module.exports = function(crowi) {
   });
   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) {
@@ -64,7 +48,7 @@ module.exports = function(crowi) {
     }
 
     const newRevision = new Revision();
-    newRevision.path = pageData.path;
+    newRevision.pageId = pageData._id;
     newRevision.body = body;
     newRevision.format = format;
     newRevision.author = user._id;
@@ -76,24 +60,5 @@ module.exports = function(crowi) {
     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);
 };

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

@@ -3,11 +3,10 @@ import {
 } from 'mongoose';
 
 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 {
   user: Types.ObjectId
@@ -50,17 +49,17 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   status: {
     type: String,
     require: true,
-    enum: STATUSES,
+    enum: AllSubscriptionStatusType,
   },
   createdAt: { type: Date, default: new Date() },
 });
 
 subscriptionSchema.methods.isSubscribing = function() {
-  return this.status === STATUS_SUBSCRIBE;
+  return this.status === SubscriptionStatusType.SUBSCRIBE;
 };
 
 subscriptionSchema.methods.isUnsubscribing = function() {
-  return this.status === STATUS_UNSUBSCRIBE;
+  return this.status === SubscriptionStatusType.UNSUBSCRIBE;
 };
 
 subscriptionSchema.statics.findByUserIdAndTargetId = function(userId, targetId) {
@@ -81,11 +80,11 @@ subscriptionSchema.statics.subscribeByPageId = function(user, pageId, status) {
 };
 
 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) {
-  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);

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

@@ -41,6 +41,10 @@ class PageOverwriteParamsFactory {
       return value;
     };
 
+    params.parent = (value, { document, schema, propertyName }) => {
+      return null;
+    };
+
     if (option.initPageMetadatas) {
       params.liker = [];
       params.seenUsers = [];

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

@@ -1,11 +1,15 @@
 import express, { Request, Router } from 'express';
 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 loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 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');
 
@@ -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
   router.get('/short-bodies', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { pageIds } = req.query;
 
     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) {
       logger.error('Error occurred while fetching shortBodiesMap.', err);

+ 40 - 70
packages/app/src/server/routes/apiv3/page.js

@@ -1,7 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 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
 
@@ -9,7 +10,7 @@ const express = require('express');
 const { body, query } = require('express-validator');
 
 const router = express.Router();
-const { convertToNewAffiliationPath } = pagePathUtils;
+const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -74,10 +75,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: string
  *            description: page path
  *            example: /
- *          redirectTo:
- *            type: string
- *            description: redirect path
- *            example: ""
  *          revision:
  *            type: string
  *            description: page revision
@@ -118,15 +115,11 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *        description: PageInfo
  *        type: object
  *        required:
- *          - isSeen
  *          - sumOfLikers
  *          - likerIds
  *          - sumOfSeenUsers
  *          - seenUserIds
  *        properties:
- *          isSeen:
- *            type: boolean
- *            description: Whether the page has ever been seen
  *          isLiked:
  *            type: boolean
  *            description: Whether the page is liked by the logged in user
@@ -169,7 +162,7 @@ module.exports = (crowi) => {
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
-  const { Page, GlobalNotificationSetting } = crowi.models;
+  const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
 
   const validator = {
@@ -203,7 +196,7 @@ module.exports = (crowi) => {
     ],
     subscribe: [
       body('pageId').isString(),
-      body('status').isBoolean(),
+      body('status').isIn(AllSubscriptionStatusType),
     ],
     subscribeStatus: [
       query('pageId').isString(),
@@ -362,26 +355,47 @@ module.exports = (crowi) => {
    *            description: Internal server error.
    */
   router.get('/info', loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+    const { user } = req;
     const { pageId } = req.query;
 
     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`);
+      }
 
       const isGuestUser = !req.user;
+      const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
+
+      const bookmarkCount = await Bookmark.countByPageId(pageId);
+
+      const responseBodyForGuest = {
+        ...pageInfo,
+        bookmarkCount,
+      };
+
       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) {
       logger.error('get-page-info', err);
@@ -612,9 +626,9 @@ module.exports = (crowi) => {
    *            description: Internal server error.
    */
   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 status = req.body.status ? STATUS_SUBSCRIBE : STATUS_UNSUBSCRIBE;
+
     try {
       const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
       return res.apiv3({ subscription });
@@ -625,49 +639,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;
 };

+ 41 - 34
packages/app/src/server/routes/apiv3/pages.js

@@ -110,10 +110,6 @@ const LIMIT_FOR_LIST = 10;
  *            type: string
  *            description: page path
  *            example: /Sandbox/Math
- *          redirectTo:
- *            type: string
- *            description: redirect path
- *            example: ""
  *          revision:
  *            type: string
  *            description: revision ID
@@ -174,20 +170,19 @@ module.exports = (crowi) => {
     ],
     renamePage: [
       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('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect 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: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       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
    */
   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);
 
@@ -466,7 +461,7 @@ module.exports = (crowi) => {
     };
 
     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
@@ -481,16 +476,21 @@ module.exports = (crowi) => {
     let page;
 
     try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
       if (page == null) {
         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);
       }
-      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
+      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
     }
     catch (err) {
       logger.error(err);
@@ -527,7 +527,7 @@ module.exports = (crowi) => {
     const options = {};
 
     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 });
     }
     catch (err) {
@@ -627,13 +627,12 @@ module.exports = (crowi) => {
       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) {
       res.code = 'Page is not found';
       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);
@@ -707,26 +706,14 @@ 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 Page = crowi.model('Page');
 
     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.v5InitialMigration(Page.GRANT_PUBLIC); // not await
       }
     }
     catch (err) {
@@ -736,6 +723,26 @@ module.exports = (crowi) => {
     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) => {
     try {
       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 paginateResult = await Revision.paginate(
-        { path: page.path },
+        { pageId: page._id },
         {
           page: selectedPage,
           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 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 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.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // 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.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
   app.get('/tags'                     , loginRequired, tag.showPage);

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

@@ -1,8 +1,12 @@
 import { pagePathUtils } from '@growi/core';
 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 { PageRedirectModel } from '../models/page-redirect';
 
 const { isCreatablePage, isTopPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
@@ -70,10 +74,6 @@ const { serializeUserSecurely } = require('../models/serializers/user-serializer
  *            type: string
  *            description: page path
  *            example: /
- *          redirectTo:
- *            type: string
- *            description: redirect path
- *            example: ""
  *          revision:
  *            $ref: '#/components/schemas/Revision'
  *          status:
@@ -146,6 +146,7 @@ module.exports = function(crowi, app) {
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
+  const PageRedirect = mongoose.model('PageRedirect');
 
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
@@ -283,25 +284,6 @@ module.exports = function(crowi, app) {
     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) {
     if (req.user == null) {
       return '';
@@ -442,11 +424,6 @@ module.exports = function(crowi, app) {
 
     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);
 
     const limit = 50;
@@ -622,17 +599,21 @@ module.exports = function(crowi, app) {
    * redirector
    */
   async function redirector(req, res, next, path) {
-    const pages = await Page.findByPathAndViewer(path, req.user, null, false, true);
     const { redirectFrom } = req.query;
 
-    if (pages.length >= 2) {
+    const builder = new PageQueryBuilder(Page.find({ path }));
+    await Page.addConditionToFilteringByViewerForList(builder, req.user);
+
+    const pages = await builder.query.lean().clone().exec('find');
 
-      const renderVars = {};
+    if (pages.length >= 2) {
 
-      await addRenderVarsForIdenticalPage(renderVars, pages);
+      // 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', {
-        ...renderVars,
+        identicalPathPages,
         redirectFrom,
         path,
       });
@@ -651,7 +632,18 @@ module.exports = function(crowi, app) {
       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);
   }
 
@@ -670,7 +662,10 @@ module.exports = function(crowi, app) {
 
 
   const api = {};
+  const validator = {};
+
   actions.api = api;
+  actions.validator = validator;
 
   /**
    * @swagger
@@ -1163,6 +1158,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
    * @apiName RemovePage
@@ -1176,13 +1176,13 @@ module.exports = function(crowi, app) {
     const previousRevision = req.body.revision_id || null;
 
     // get completely flag
-    const isCompletely = (req.body.completely != null);
+    const isCompletely = req.body.completely;
     // get recursively flag
-    const isRecursively = (req.body.recursively != null);
+    const isRecursively = req.body.recursively;
 
     const options = {};
 
-    const page = await Page.findByIdAndViewer(pageId, req.user);
+    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
     if (page == null) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
@@ -1198,6 +1198,11 @@ module.exports = function(crowi, app) {
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
       else {
+        const notRecursivelyAndEmpty = page.isEmpty && !isRecursively;
+        if (notRecursivelyAndEmpty) {
+          return res.json(ApiResponse.error(`Page '${pageId}' is not found.`, 'notfound'));
+        }
+
         if (!page.isUpdatable(previousRevision)) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
@@ -1225,6 +1230,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
    * @apiName RevertRemovePage
@@ -1236,7 +1245,7 @@ module.exports = function(crowi, app) {
     const pageId = req.body.page_id;
 
     // get recursively flag
-    const isRecursively = (req.body.recursively != null);
+    const isRecursively = req.body.recursively;
 
     let page;
     try {

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

@@ -1,6 +1,6 @@
 import { Types } from 'mongoose';
 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 {
   InAppNotification,
@@ -9,13 +9,14 @@ import {
 
 import { ActivityDocument } from '~/server/models/activity';
 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 { HasObjectId } from '~/interfaces/has-object-id';
 import loggerFactory from '~/utils/logger';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
@@ -167,7 +168,7 @@ export default class InAppNotificationService {
     if (inAppNotificationSettings != null) {
       const subscribeRule = inAppNotificationSettings.subscribeRules.find(subscribeRule => subscribeRule.name === targetRuleName);
       if (subscribeRule != null && subscribeRule.isEnabled) {
-        await Subscription.subscribeByPageId(userId, pageId, STATUS_SUBSCRIBE);
+        await Subscription.subscribeByPageId(userId, pageId, SubscriptionStatusType.SUBSCRIBE);
       }
     }
 

+ 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 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 { isIncludesObjectId, removeDuplicates, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 const { addTrailingSlash } = pathUtils;
 const { isTopPage } = pagePathUtils;
 
-type ObjectId = mongoose.Types.ObjectId;
+type ObjectIdLike = mongoose.Types.ObjectId | string;
 
 type ComparableTarget = {
   grant: number,
-  grantedUserIds?: ObjectId[],
-  grantedGroupId: ObjectId,
-  applicableUserIds?: ObjectId[],
-  applicableGroupIds?: ObjectId[],
+  grantedUserIds?: ObjectIdLike[],
+  grantedGroupId?: ObjectIdLike,
+  applicableUserIds?: ObjectIdLike[],
+  applicableGroupIds?: ObjectIdLike[],
 };
 
 type ComparableAncestor = {
   grant: number,
-  grantedUserIds: ObjectId[],
-  applicableUserIds?: ObjectId[],
-  applicableGroupIds?: ObjectId[],
+  grantedUserIds: ObjectIdLike[],
+  applicableUserIds?: ObjectIdLike[],
+  applicableGroupIds?: ObjectIdLike[],
 };
 
 type ComparableDescendants = {
   isPublicExist: boolean,
-  grantedUserIds: ObjectId[],
-  grantedGroupIds: ObjectId[],
+  grantedUserIds: ObjectIdLike[],
+  grantedGroupIds: ObjectIdLike[],
 };
 
 class PageGrantService {
@@ -42,7 +42,7 @@ class PageGrantService {
   }
 
   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;
 
@@ -61,7 +61,7 @@ class PageGrantService {
   private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
     this.validateComparableTarget(target);
 
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
 
     /*
      * ancestor side
@@ -80,7 +80,7 @@ class PageGrantService {
         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;
       }
     }
@@ -105,6 +105,10 @@ class PageGrantService {
       }
 
       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
           return false;
         }
@@ -136,7 +140,7 @@ class PageGrantService {
         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;
       }
     }
@@ -165,14 +169,14 @@ class PageGrantService {
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableTarget(
-      grant, grantedUserIds: ObjectId[] | undefined, grantedGroupId: ObjectId, includeApplicable: boolean,
+      grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupId: ObjectIdLike | undefined, includeApplicable: boolean,
   ): Promise<ComparableTarget> {
     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
 
-      let applicableUserIds: ObjectId[] | undefined;
-      let applicableGroupIds: ObjectId[] | undefined;
+      let applicableUserIds: ObjectIdLike[] | undefined;
+      let applicableGroupIds: ObjectIdLike[] | undefined;
 
       if (grant === Page.GRANT_USER_GROUP) {
         const targetUserGroup = await UserGroup.findOne({ _id: grantedGroupId });
@@ -208,17 +212,20 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @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
 
-    let applicableUserIds: ObjectId[] | undefined;
-    let applicableGroupIds: ObjectId[] | undefined;
+    let applicableUserIds: ObjectIdLike[] | undefined;
+    let applicableGroupIds: ObjectIdLike[] | undefined;
 
     /*
      * make granted users list of ancestor's
      */
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
+    if (!includeNotMigratedPages) {
+      builderForAncestors.addConditionAsMigrated();
+    }
     const ancestors = await builderForAncestors
       .addConditionToListOnlyAncestors(targetPath)
       .addConditionToSortPagesByDescPath()
@@ -234,7 +241,7 @@ class PageGrantService {
       const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
       const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
       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 {
@@ -250,8 +257,8 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @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
@@ -259,12 +266,17 @@ class PageGrantService {
     const pathWithTrailingSlash = addTrailingSlash(targetPath);
     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([
       { // match to descendants excluding empty pages
-        $match: {
-          path: new RegExp(`^${startsPattern}`),
-          isEmpty: { $ne: true },
-        },
+        $match,
       },
       {
         $project: {
@@ -292,7 +304,7 @@ class PageGrantService {
     const isPublicExist = result.some(r => r._id === Page.GRANT_PUBLIC);
     // GRANT_OWNER group
     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
     const grantUserGroupResult = result.filter(r => r._id === Page.GRANT_USER_GROUP)[0]; // users of GRANT_OWNER
     const grantedGroupIds = grantUserGroupResult?.grantedGroupSet ?? [];
@@ -306,16 +318,18 @@ class PageGrantService {
 
   /**
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * Only v5 schema pages will be used to compare.
    * @returns Promise<boolean>
    */
   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> {
     if (isTopPage(targetPath)) {
       return true;
     }
 
-    const comparableAncestor = await this.generateComparableAncestor(targetPath);
+    const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
       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 comparableDescendants = await this.generateComparableDescendants(targetPath);
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, includeNotMigratedPages);
 
     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;

+ 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;

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

@@ -0,0 +1,2162 @@
+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();
+    }
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+
+    this.pageEvent.emit('rename', page, user);
+
+    // TODO: resume
+    // update descendants first
+    this.renameDescendantsWithStream(page, newPagePath, user, options, shouldUseV4Process);
+
+    return renamedPage;
+  }
+
+  // !!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
+
+    const 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.duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process);
+    }
+
+    return result;
+  }
+
+  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,
+          isEmpty: true,
+        };
+      }
+      else {
+        newPage = {
+          _id: newPageId,
+          path: newPagePath,
+          creator: user._id,
+          grant: page.grant,
+          grantedGroup: page.grantedGroup,
+          grantedUsers: page.grantedUsers,
+          lastUpdateUser: user._id,
+          revision: revisionId,
+        };
+      }
+
+      newPages.push(newPage);
+
+      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 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;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.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);
+
+  }
+
+  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);
+
+  }
+
+  /*
+   * 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 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.normalizeParentRecursively(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
+   */
+  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) {
+          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 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) {
-    return page.revision != null && page.redirectTo == null;
+    return page.revision != null;
   }
 
   initClient() {
@@ -415,7 +415,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
 
   updateOrInsertDescendantsPagesById(page, user) {
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.find());
     builder.addConditionToListWithDescendants(page.path);
@@ -428,7 +428,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   async updateOrInsertPages(queryFactory, option: any = {}) {
     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 Bookmark = mongoose.model('Bookmark') 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 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();
 
@@ -831,7 +831,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
     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 {
       GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = 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
-    const Page = mongoose.model('Page') as PageModel;
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
 
     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
      */
-    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 result = {} as IFormattedSearchResult;
 

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

@@ -1,9 +1,11 @@
 import mongoose from 'mongoose';
 
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
 type IObjectId = 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 _id = id.toString();
 
@@ -17,7 +19,7 @@ export const isIncludesObjectId = (arr: (IObjectId | string)[], id: IObjectId |
  * @returns Array of mongoose.Types.ObjectId
  */
 export const excludeTestIdsFromTargetIds = <T extends { toString: any } = IObjectId>(
-  targetIds: T[], testIds: (IObjectId | string)[],
+  targetIds: T[], testIds: ObjectIdLike[],
 ): T[] => {
   // cast to string
   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));
 };
-
-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;
   };
 
-  locals.isTrashPage = function() {
-    const path = req.path || '';
+  locals.isTrashPage = function(path = '') {
     if (path.match(/^\/trash(\/.*)?$/)) {
       return true;
     }
@@ -155,13 +154,6 @@ module.exports = function(crowi, req, locals) {
     return false;
   };
 
-  locals.isDeletablePage = function() {
-    const Page = crowi.model('Page');
-    const path = req.path || '';
-
-    return Page.isDeletableName(path);
-  };
-
   locals.userPageRoot = function(user) {
     if (!user || !user.username) {
       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
           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 id="page-context"></div>

+ 1 - 1
packages/app/src/server/views/layout-growi/page_list.html

@@ -13,7 +13,7 @@
 
 
 {% block content_main_after %}
-  {% if isTrashPage() %}
+  {% if isTrashPage(page.path) %}
     <div class="grw-container-convertible">
       <div id="trash-page-list"></div>
     </div>

+ 3 - 0
packages/app/src/server/views/layout/layout.html

@@ -104,6 +104,9 @@
 {% include '../widget/system-version.html' %}
 
 <div id="page-create-modal"></div>
+<div id="page-delete-modal"></div>
+<div id="page-duplicate-modal"></div>
+<div id="page-rename-modal"></div>
 {% include '../modal/shortcuts.html' %}
 
 {% block body_end %}

+ 1 - 1
packages/app/src/server/views/widget/page_alerts.html

@@ -73,7 +73,7 @@
     </div>
     {% endif %}
 
-    {% if isTrashPage() %}
+    {% if isTrashPage(page.path) %}
       <div id="trash-page-alert"></div>
     {% endif %}
   </div>

+ 0 - 2
packages/app/src/server/views/widget/page_content.html

@@ -13,9 +13,7 @@
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
-  data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
-  data-page-is-able-to-delete-completely="{% if pageService.canDeleteCompletely(page.creator._id, user) %}true{% else %}false{% endif %}"
   data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-created-at="{{ page.createdAt|datetz('Y/m/d H:i:s') }}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"

+ 6 - 5
packages/app/src/stores/bookmark.ts

@@ -1,12 +1,13 @@
-import useSWR, { SWRResponse } from 'swr';
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
 import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
-export const useSWRBookmarkInfo = (pageId: string | null | undefined, isOpen = false): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWR(
-    pageId != null && isOpen
-      ? `/bookmarks/info?pageId=${pageId}` : null,
+export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWRImmutable(
+    pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
         sumOfBookmarks: response.data.sumOfBookmarks,

+ 0 - 8
packages/app/src/stores/context.tsx

@@ -59,18 +59,10 @@ export const useIsDeleted = (initialData?: boolean): SWRResponse<boolean, Error>
   return useStaticSWR<boolean, Error>('isDeleted', initialData, { fallbackData: false });
 };
 
-export const useIsDeletable = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isDeletable', initialData, { fallbackData: false });
-};
-
 export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 };
 
-export const useIsAbleToDeleteCompletely = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isAbleToDeleteCompletely', initialData, { fallbackData: false });
-};
-
 export const useIsForbidden = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
 };

+ 2 - 1
packages/app/src/stores/page-listing.tsx

@@ -1,4 +1,5 @@
 import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import {
@@ -47,7 +48,7 @@ export const useSWRxPageChildren = (
 
 export const useSWRxV5MigrationStatus = (
 ): SWRResponse<V5MigrationStatus, Error> => {
-  return useSWR(
+  return useSWRImmutable(
     '/pages/v5-migration-status',
     endpoint => apiv3Get(endpoint).then((response) => {
       return {

+ 16 - 34
packages/app/src/stores/page.tsx

@@ -1,14 +1,15 @@
 import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 
-import { IPage, IPageHasId } from '~/interfaces/page';
+import {
+  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing,
+} from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
 
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
-import { IPageInfo } from '../interfaces/page-info';
-import { useIsGuestUser } from './context';
 
 
 export const useSWRxPageByPath = (path: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
@@ -48,48 +49,29 @@ export const useSWRxPageList = (
   );
 };
 
-export const useSWRPageInfo = (pageId: string | null): SWRResponse<IPageInfo, Error> => {
-  return useSWR(pageId != null ? `/page/info?pageId=${pageId}` : null, endpoint => apiv3Get(endpoint).then((response) => {
-    return {
-      sumOfLikers: response.data.sumOfLikers,
-      likerIds: response.data.likerIds,
-      seenUserIds: response.data.seenUserIds,
-      sumOfSeenUsers: response.data.sumOfSeenUsers,
-      isSeen: response.data.isSeen,
-      isLiked: response.data?.isLiked,
-    };
-  }));
-};
-
 export const useSWRTagsInfo = (pageId: string | null | undefined): SWRResponse<IPageTagsInfo, Error> => {
   const key = pageId == null ? null : `/pages.getPageTag?pageId=${pageId}`;
 
-  return useSWR(key, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {
+  return useSWRImmutable(key, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {
     return {
       tags: response.tags,
     };
   }));
 };
-type GetSubscriptionStatusResult = { subscribing: boolean };
 
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRResponse<{status: boolean | null}, Error> => {
-  const { data: isGuestUser } = useIsGuestUser();
-  const key = isGuestUser === false ? ['/page/subscribe', pageId] : null;
-  return useSWR(
-    key,
-    (endpoint, pageId) => apiv3Get<GetSubscriptionStatusResult>(endpoint, { pageId }).then((response) => {
-      return {
-        status: response.data.subscribing,
-      };
-    }),
+export const useSWRxPageInfo = (pageId: string | null | undefined): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
+  return useSWRImmutable(
+    pageId != null ? ['/page/info', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
   );
 };
 
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxPageInfo = <Data, Error>(pageId: string | undefined): SWRResponse<IPageInfo, Error> => {
-  return useSWR(
-    pageId != null ? ['/page/info', pageId] : null,
-    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+export const useSWRxPageInfoForList = (pageIds: string[] | null | undefined): SWRResponse<Record<string, IPageInfo | IPageInfoForListing>, Error> => {
+
+  const shouldFetch = pageIds != null && pageIds.length > 0;
+
+  return useSWRImmutable(
+    shouldFetch ? ['/page-listing/info', pageIds] : null,
+    (endpoint, pageIds) => apiv3Get(endpoint, { pageIds }).then(response => response.data),
   );
 };

+ 122 - 2
packages/app/src/stores/ui.tsx

@@ -252,6 +252,7 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
   return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
 };
 
+// PageCreateModal
 type CreateModalStatus = {
   isOpened: boolean,
   path?: string,
@@ -264,7 +265,7 @@ type CreateModalStatusUtils = {
 
 export const useCreateModalStatus = (status?: CreateModalStatus): SWRResponse<CreateModalStatus, Error> & CreateModalStatusUtils => {
   const initialData: CreateModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<CreateModalStatus, Error>('modalStatus', status, { fallbackData: initialData });
+  const swrResponse = useStaticSWR<CreateModalStatus, Error>('pageCreateModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -276,7 +277,7 @@ export const useCreateModalStatus = (status?: CreateModalStatus): SWRResponse<Cr
 export const useCreateModalOpened = (): SWRResponse<boolean, Error> => {
   const { data } = useCreateModalStatus();
   return useSWR(
-    data != null ? ['isModalOpened', data] : null,
+    data != null ? ['isCreaateModalOpened', data] : null,
     () => {
       return data != null ? data.isOpened : false;
     },
@@ -295,6 +296,125 @@ export const useCreateModalPath = (): SWRResponse<string | null | undefined, Err
   );
 };
 
+// PageDeleteModal
+export type IPageForPageDeleteModal = {
+  pageId: string,
+  revisionId: string,
+  path: string
+}
+
+type DeleteModalStatus = {
+  isOpened: boolean,
+  pages?: IPageForPageDeleteModal[],
+}
+
+type DeleteModalStatusUtils = {
+  open(pages?: IPageForPageDeleteModal[]): Promise<DeleteModalStatus | undefined>
+  close(): Promise<DeleteModalStatus | undefined>
+}
+
+export const usePageDeleteModalStatus = (status?: DeleteModalStatus): SWRResponse<DeleteModalStatus, Error> & DeleteModalStatusUtils => {
+  const initialData: DeleteModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pages?: IPageForPageDeleteModal[]) => swrResponse.mutate({ isOpened: true, pages }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+export const usePageDeleteModalOpened = (): SWRResponse<boolean, Error> => {
+  const { data } = usePageDeleteModalStatus();
+  return useSWRImmutable(
+    data != null ? ['isDeleteModalOpened', data] : null,
+    () => {
+      return data != null ? data.isOpened : false;
+    },
+  );
+};
+
+// PageDuplicateModal
+export type IPageForPageDuplicateModal = {
+  pageId: string,
+  path: string
+}
+
+type DuplicateModalStatus = {
+  isOpened: boolean,
+  pageId?: string,
+  path?: string,
+}
+
+type DuplicateModalStatusUtils = {
+  open(pageId: string, path: string): Promise<DuplicateModalStatus | undefined>
+  close(): Promise<DuplicateModalStatus | undefined>
+}
+
+export const usePageDuplicateModalStatus = (status?: DuplicateModalStatus): SWRResponse<DuplicateModalStatus, Error> & DuplicateModalStatusUtils => {
+  const initialData: DuplicateModalStatus = { isOpened: false, pageId: '', path: '' };
+  const swrResponse = useStaticSWR<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pageId: string, path: string) => swrResponse.mutate({ isOpened: true, pageId, path }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+export const usePageDuplicateModalOpened = (): SWRResponse<boolean, Error> => {
+  const { data } = usePageDuplicateModalStatus();
+  return useSWRImmutable(
+    data != null ? ['isDuplicateModalOpened', data] : null,
+    () => {
+      return data != null ? data.isOpened : false;
+    },
+  );
+};
+
+// PageRenameModal
+export type IPageForPageRenameModal = {
+  pageId: string,
+  revisionId: string,
+  path: string
+}
+
+type RenameModalStatus = {
+  isOpened: boolean,
+  pageId?: string,
+  revisionId?: string
+  path?: string,
+}
+
+type RenameModalStatusUtils = {
+  open(pageId: string, revisionId: string, path: string): Promise<RenameModalStatus | undefined>
+  close(): Promise<RenameModalStatus | undefined>
+}
+
+export const usePageRenameModalStatus = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
+  const initialData: RenameModalStatus = {
+    isOpened: false, pageId: '', revisionId: '', path: '',
+  };
+  const swrResponse = useStaticSWR<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pageId: string, revisionId: string, path: string) => swrResponse.mutate({
+      isOpened: true, pageId, revisionId, path,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+export const usePageRenameModalOpened = (): SWRResponse<boolean, Error> => {
+  const { data } = usePageRenameModalStatus();
+  return useSWRImmutable(
+    data != null ? ['isRenameModalOpened', data] : null,
+    () => {
+      return data != null ? data.isOpened : false;
+    },
+  );
+};
 
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);

+ 1 - 11
packages/app/src/stores/user.tsx

@@ -5,17 +5,7 @@ import { IUserHasId } from '~/interfaces/user';
 
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 
-import { apiGet } from '../client/util/apiv1-client';
-
-export const useSWRxLikerList = (likerIds: string[] = []): SWRResponse<IUserHasId[], Error> => {
-  const shouldFetch = likerIds.length > 0;
-  return useSWR(shouldFetch ? ['/users.list', [...likerIds].join(',')] : null, (endpoint:string, userIds:string) => {
-    return apiGet(endpoint, { user_ids: userIds }).then((response:any) => response.users);
-  });
-};
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxUsersList = <Data, Error>(userIds: string[]): SWRResponse<IUserHasId[], Error> => {
+export const useSWRxUsersList = (userIds: string[]): SWRResponse<IUserHasId[], Error> => {
   const distinctUserIds = userIds.length > 0 ? Array.from(new Set(userIds)).sort() : [];
   return useSWR(
     distinctUserIds.length > 0 ? ['/users/list', distinctUserIds] : null,

+ 0 - 27
packages/app/src/styles/_page-accessories-control.scss

@@ -15,31 +15,4 @@
     height: 25px;
     border-left: solid 1px transparent;
   }
-
-  .seen-user-count {
-    font-size: 12px;
-    font-weight: bolder;
-  }
-  .grw-seen-user-info {
-    .btn {
-      white-space: nowrap;
-    }
-  }
-
-  .seen-user-popover {
-    max-width: 200px;
-
-    .user-list-content {
-      direction: rtl;
-
-      .liker-user-count,
-      .seen-user-count {
-        font-size: 12px;
-        font-weight: bolder;
-      }
-    }
-    .cls-1 {
-      isolation: isolate;
-    }
-  }
 }

+ 46 - 7
packages/app/src/styles/_subnav.scss

@@ -38,22 +38,44 @@
     }
   }
 
-  .btn-like,
-  .btn-bookmark,
   .btn-subscribe {
     height: 40px;
     font-size: 20px;
   }
-  .grw-btn-page-management {
+
+  .btn-like,
+  .btn-bookmark,
+  .btn-seen-user {
     height: 40px;
-    font-size: 16px;
+    padding-right: 6px;
+    padding-left: 8px;
+    font-size: 20px;
+    svg {
+      width: 20px;
+      height: 20px;
+    }
   }
-
   .total-likes,
   .total-bookmarks {
-    font-size: 17px;
+    display: flex;
+    align-items: end;
+    padding-right: 8px;
+    padding-left: 6px;
+    font-size: 14px;
     font-weight: $font-weight-bold;
   }
+  .seen-user-count {
+    padding-right: 6px;
+    padding-left: 6px;
+    font-size: 14px;
+    font-weight: $font-weight-bold;
+    vertical-align: bottom;
+  }
+
+  .btn-page-item-control {
+    height: 40px;
+    font-size: 16px;
+  }
 
   ul.authors {
     li {
@@ -95,7 +117,7 @@
       padding: 4px;
       font-size: 16px;
     }
-    .grw-btn-page-management {
+    .btn-page-item-control {
       width: 32px;
       height: 32px;
       padding: 4px;
@@ -133,3 +155,20 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     }
   }
 }
+
+.user-list-popover {
+  max-width: 200px;
+
+  .user-list-content {
+    direction: rtl;
+
+    .liker-user-count,
+    .seen-user-count {
+      font-size: 12px;
+      font-weight: bolder;
+    }
+  }
+  .cls-1 {
+    isolation: isolate;
+  }
+}

+ 3 - 9
packages/app/src/styles/atoms/_buttons.scss

@@ -1,5 +1,5 @@
 .btn.btn-like {
-  @include button-outline-variant($secondary, lighten($red, 15%), rgba(lighten($red, 10%), 0.15), rgba(lighten($red, 10%), 0.5));
+  @include button-outline-variant(rgba($secondary, 50%), lighten($red, 15%), rgba(lighten($red, 10%), 0.15), rgba(lighten($red, 10%), 0.5));
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
     color: lighten($red, 15%);
@@ -11,7 +11,7 @@
 }
 
 .btn.btn-bookmark {
-  @include button-outline-variant($secondary, $orange, rgba(lighten($orange, 20%), 0.5), rgba(lighten($orange, 20%), 0.5));
+  @include button-outline-variant(rgba($secondary, 50%), $orange, rgba(lighten($orange, 20%), 0.5), rgba(lighten($orange, 20%), 0.5));
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
     color: $orange;
@@ -23,7 +23,7 @@
 }
 
 .btn.btn-subscribe {
-  @include button-outline-variant($secondary, $success, rgba(lighten($success, 10%), 0.15), rgba(lighten($success, 10%), 0.5));
+  @include button-outline-variant(rgba($secondary, 50%), $success, rgba(lighten($success, 10%), 0.15), rgba(lighten($success, 10%), 0.5));
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
     color: lighten($success, 15%);
@@ -105,12 +105,6 @@
   }
 }
 
-// Page Management Dropdown icon
-.grw-btn-page-management {
-  background-color: transparent;
-  transition: 0.3s;
-}
-
 // define disabled button w/o pointer-events, see _override-bootstrap.scss
 .btn.disabled,
 .btn[disabled],

+ 1 - 1
packages/app/src/styles/theme/_apply-colors.scss

@@ -713,7 +713,7 @@ mark.rbt-highlight-text {
 }
 
 // Page Management Dropdown icon
-.grw-btn-page-management {
+.btn-page-item-control {
   &:hover,
   &:focus {
     background-color: rgba($color-link, 0.15);

+ 1 - 0
packages/app/test/integration/models/page.test.js

@@ -158,6 +158,7 @@ describe('Page', () => {
 
   describe('.isDeletableName', () => {
     test('should decide deletable or not', () => {
+      expect(Page.isDeletableName('/')).toBeFalsy();
       expect(Page.isDeletableName('/hoge')).toBeTruthy();
       expect(Page.isDeletableName('/user/xxx')).toBeFalsy();
       expect(Page.isDeletableName('/user/xxx123')).toBeFalsy();

+ 43 - 135
packages/app/test/integration/service/page.test.js

@@ -277,12 +277,12 @@ describe('PageService', () => {
     await Revision.insertMany([
       {
         _id: '600d395667536503354cbe91',
-        path: parentForDuplicate.path,
+        pageId: parentForDuplicate._id,
         body: 'duplicateBody',
       },
       {
         _id: '600d395667536503354cbe92',
-        path: childForDuplicate.path,
+        pageId: childForDuplicate._id,
         body: 'duplicateChildBody',
       },
     ]);
@@ -292,7 +292,7 @@ describe('PageService', () => {
 
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
     test('rename page with different tree with isRecursively [deeper]', async() => {
-      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, {}, true);
+      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, {});
       const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
       const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
       const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
@@ -304,32 +304,28 @@ describe('PageService', () => {
       expect(wrongPage).toBeNull();
     });
 
-    /*
-     * TODO: rewrite test when modify rename function
-     */
-    // test('rename page with different tree with isRecursively [shallower]', async() => {
-    //   // setup
-    //   expect(await Page.findOne({ path: '/level1' })).toBeNull();
-    //   expect(await Page.findOne({ path: '/level1/level2' })).not.toBeNull();
-    //   expect(await Page.findOne({ path: '/level1/level2/child' })).not.toBeNull();
-    //   expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
-    //   expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
-
-    //   // when
-    //   //   rename /level1/level2 --> /level1
-    //   await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {}, true);
-
-    //   // then
-    //   expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
-    //   expect(await Page.findOne({ path: '/level1/child' })).not.toBeNull();
-    //   expect(await Page.findOne({ path: '/level1/level2' })).toBeNull();
-    //   expect(await Page.findOne({ path: '/level1/level2/child' })).toBeNull();
-    //   // The changed path is duplicated with the existing path (/level1/level2), so it will not be changed
-    //   expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
-
-    //   // Check that pages that are not to be renamed have not been renamed
-    //   expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
-    // });
+    test('rename page with different tree with isRecursively [shallower]', async() => {
+      // setup
+      expect(await Page.findOne({ path: '/level1' })).toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2/child' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
+
+      // when
+      //   rename /level1/level2 --> /level1
+      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {});
+
+      // then
+      expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/child' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2/child' })).toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2/level2' })).toBeNull();
+
+      // Check that pages that are not to be renamed have not been renamed
+      expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
+    });
   });
 
   describe('rename page', () => {
@@ -349,69 +345,47 @@ describe('PageService', () => {
       test('rename page without options', async() => {
 
         const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
-        const redirectedFromPage = await Page.findOne({ path: '/parentForRename1' });
-        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1' });
 
         expect(xssSpy).toHaveBeenCalled();
-        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled(); // single rename is deprecated
 
         expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename1, testUser2);
 
         expect(resultPage.path).toBe('/renamed1');
         expect(resultPage.updatedAt).toEqual(parentForRename1.updatedAt);
         expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
-
-        expect(redirectedFromPage).toBeNull();
-        expect(redirectedFromPageRevision).toBeNull();
       });
 
       test('rename page with updateMetadata option', async() => {
 
         const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
-        const redirectedFromPage = await Page.findOne({ path: '/parentForRename2' });
-        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2' });
 
         expect(xssSpy).toHaveBeenCalled();
-        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
 
         expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename2, testUser2);
 
         expect(resultPage.path).toBe('/renamed2');
         expect(resultPage.updatedAt).toEqual(dateToUse);
         expect(resultPage.lastUpdateUser).toEqual(testUser2._id);
-
-        expect(redirectedFromPage).toBeNull();
-        expect(redirectedFromPageRevision).toBeNull();
       });
 
       test('rename page with createRedirectPage option', async() => {
 
         const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
-        const redirectedFromPage = await Page.findOne({ path: '/parentForRename3' });
-        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3' });
 
         expect(xssSpy).toHaveBeenCalled();
-        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
         expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename3, testUser2);
 
         expect(resultPage.path).toBe('/renamed3');
         expect(resultPage.updatedAt).toEqual(parentForRename3.updatedAt);
         expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
-
-        expect(redirectedFromPage).not.toBeNull();
-        expect(redirectedFromPage.path).toBe('/parentForRename3');
-        expect(redirectedFromPage.redirectTo).toBe('/renamed3');
-
-        expect(redirectedFromPageRevision).not.toBeNull();
-        expect(redirectedFromPageRevision.path).toBe('/parentForRename3');
-        expect(redirectedFromPageRevision.body).toBe('redirect /renamed3');
       });
 
       test('rename page with isRecursively', async() => {
 
         const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { }, true);
-        const redirectedFromPage = await Page.findOne({ path: '/parentForRename4' });
-        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename4' });
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -420,9 +394,6 @@ describe('PageService', () => {
         expect(resultPage.path).toBe('/renamed4');
         expect(resultPage.updatedAt).toEqual(parentForRename4.updatedAt);
         expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
-
-        expect(redirectedFromPage).toBeNull();
-        expect(redirectedFromPageRevision).toBeNull();
       });
 
       test('rename page with different tree with isRecursively', async() => {
@@ -443,8 +414,6 @@ describe('PageService', () => {
 
       await crowi.pageService.renameDescendants([childForRename1], testUser2, {}, oldPagePathPrefix, newPagePathPrefix);
       const resultPage = await Page.findOne({ path: '/renamed1/child' });
-      const redirectedFromPage = await Page.findOne({ path: '/parentForRename1/child' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1/child' });
 
       expect(resultPage).not.toBeNull();
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename1], testUser2);
@@ -452,9 +421,6 @@ describe('PageService', () => {
       expect(resultPage.path).toBe('/renamed1/child');
       expect(resultPage.updatedAt).toEqual(childForRename1.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
-
-      expect(redirectedFromPage).toBeNull();
-      expect(redirectedFromPageRevision).toBeNull();
     });
 
     test('renameDescendants with updateMetadata option', async() => {
@@ -463,8 +429,6 @@ describe('PageService', () => {
 
       await crowi.pageService.renameDescendants([childForRename2], testUser2, { updateMetadata: true }, oldPagePathPrefix, newPagePathPrefix);
       const resultPage = await Page.findOne({ path: '/renamed2/child' });
-      const redirectedFromPage = await Page.findOne({ path: '/parentForRename2/child' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2/child' });
 
       expect(resultPage).not.toBeNull();
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename2], testUser2);
@@ -472,9 +436,6 @@ describe('PageService', () => {
       expect(resultPage.path).toBe('/renamed2/child');
       expect(resultPage.updatedAt).toEqual(dateToUse);
       expect(resultPage.lastUpdateUser).toEqual(testUser2._id);
-
-      expect(redirectedFromPage).toBeNull();
-      expect(redirectedFromPageRevision).toBeNull();
     });
 
     test('renameDescendants with createRedirectPage option', async() => {
@@ -483,8 +444,6 @@ describe('PageService', () => {
 
       await crowi.pageService.renameDescendants([childForRename3], testUser2, { createRedirectPage: true }, oldPagePathPrefix, newPagePathPrefix);
       const resultPage = await Page.findOne({ path: '/renamed3/child' });
-      const redirectedFromPage = await Page.findOne({ path: '/parentForRename3/child' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3/child' });
 
       expect(resultPage).not.toBeNull();
       expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename3], testUser2);
@@ -492,23 +451,16 @@ describe('PageService', () => {
       expect(resultPage.path).toBe('/renamed3/child');
       expect(resultPage.updatedAt).toEqual(childForRename3.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
-
-      expect(redirectedFromPage).not.toBeNull();
-      expect(redirectedFromPage.path).toBe('/parentForRename3/child');
-      expect(redirectedFromPage.redirectTo).toBe('/renamed3/child');
-
-      expect(redirectedFromPageRevision).not.toBeNull();
-      expect(redirectedFromPageRevision.path).toBe('/parentForRename3/child');
-      expect(redirectedFromPageRevision.body).toBe('redirect /renamed3/child');
     });
   });
 
   describe('duplicate page', () => {
     let duplicateDescendantsWithStreamSpy;
 
-    jest.mock('~/server/models/serializers/page-serializer');
-    const { serializePageSecurely } = require('~/server/models/serializers/page-serializer');
-    serializePageSecurely.mockImplementation(page => page);
+    // TODO https://redmine.weseek.co.jp/issues/87537 : activate outer module mockImplementation
+    // jest.mock('~/server/models/serializers/page-serializer');
+    // const { serializePageSecurely } = require('~/server/models/serializers/page-serializer');
+    // serializePageSecurely.mockImplementation(page => page);
 
     beforeEach(async() => {
       duplicateDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'duplicateDescendantsWithStream').mockImplementation();
@@ -522,11 +474,12 @@ describe('PageService', () => {
       jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
 
       const resultPage = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicate', testUser2, false);
-      const duplicatedToPageRevision = await Revision.findOne({ path: '/newParentDuplicate' });
+      const duplicatedToPageRevision = await Revision.findOne({ pageId: resultPage._id });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).not.toHaveBeenCalled();
-      expect(serializePageSecurely).toHaveBeenCalled();
+      // TODO https://redmine.weseek.co.jp/issues/87537 : activate outer module mockImplementation
+      // expect(serializePageSecurely).toHaveBeenCalled();
       expect(resultPage.path).toBe('/newParentDuplicate');
       expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
       expect(duplicatedToPageRevision._id).not.toEqual(parentForDuplicate.revision._id);
@@ -542,11 +495,12 @@ describe('PageService', () => {
       jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
 
       const resultPageRecursivly = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicateRecursively', testUser2, true);
-      const duplicatedRecursivelyToPageRevision = await Revision.findOne({ path: '/newParentDuplicateRecursively' });
+      const duplicatedRecursivelyToPageRevision = await Revision.findOne({ pageId: resultPageRecursivly._id });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).toHaveBeenCalled();
-      expect(serializePageSecurely).toHaveBeenCalled();
+      // TODO https://redmine.weseek.co.jp/issues/87537 : activate outer module mockImplementation
+      // expect(serializePageSecurely).toHaveBeenCalled();
       expect(resultPageRecursivly.path).toBe('/newParentDuplicateRecursively');
       expect(resultPageRecursivly.lastUpdateUser._id).toEqual(testUser2._id);
       expect(duplicatedRecursivelyToPageRevision._id).not.toEqual(parentForDuplicate.revision._id);
@@ -558,16 +512,16 @@ describe('PageService', () => {
       const duplicateTagsMock = await jest.spyOn(crowi.pageService, 'duplicateTags').mockImplementationOnce();
       await crowi.pageService.duplicateDescendants([childForDuplicate], testUser2, parentForDuplicate.path, '/newPathPrefix');
 
-      const childForDuplicateRevision = await Revision.findOne({ path: childForDuplicate.path });
+      const childForDuplicateRevision = await Revision.findOne({ pageId: childForDuplicate._id });
       const insertedPage = await Page.findOne({ path: '/newPathPrefix/child' });
-      const insertedRevision = await Revision.findOne({ path: '/newPathPrefix/child' });
+      const insertedRevision = await Revision.findOne({ pageId: insertedPage._id });
 
       expect(insertedPage).not.toBeNull();
       expect(insertedPage.path).toEqual('/newPathPrefix/child');
       expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
 
       expect([insertedRevision]).not.toBeNull();
-      expect(insertedRevision.path).toEqual('/newPathPrefix/child');
+      expect(insertedRevision.pageId).toEqual(insertedPage._id);
       expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
       expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
 
@@ -600,8 +554,6 @@ describe('PageService', () => {
 
     test('delete page without options', async() => {
       const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
-      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete1' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete1' });
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -613,23 +565,12 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete1.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(redirectedFromPage).not.toBeNull();
-      expect(redirectedFromPage.path).toBe('/parentForDelete1');
-      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete1');
-
-      expect(redirectedFromPageRevision).not.toBeNull();
-      expect(redirectedFromPageRevision.path).toBe('/parentForDelete1');
-      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete1');
-
       expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
-
     });
 
     test('delete page with isRecursively', async() => {
       const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
-      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete2' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete2' });
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -641,25 +582,14 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete2.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(redirectedFromPage).not.toBeNull();
-      expect(redirectedFromPage.path).toBe('/parentForDelete2');
-      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete2');
-
-      expect(redirectedFromPageRevision).not.toBeNull();
-      expect(redirectedFromPageRevision.path).toBe('/parentForDelete2');
-      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete2');
-
       expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2);
       expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
-
     });
 
 
     test('deleteDescendants', async() => {
       await crowi.pageService.deleteDescendants([childForDelete], testUser2);
       const resultPage = await Page.findOne({ path: '/trash/parentForDelete/child' });
-      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete/child' });
-      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete/child' });
 
       expect(resultPage.status).toBe(Page.STATUS_DELETED);
       expect(resultPage.path).toBe('/trash/parentForDelete/child');
@@ -667,14 +597,6 @@ describe('PageService', () => {
       expect(resultPage.deletedAt).toEqual(dateToUse);
       expect(resultPage.updatedAt).toEqual(childForDelete.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
-
-      expect(redirectedFromPage).not.toBeNull();
-      expect(redirectedFromPage.path).toBe('/parentForDelete/child');
-      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete/child');
-
-      expect(redirectedFromPageRevision).not.toBeNull();
-      expect(redirectedFromPageRevision.path).toBe('/parentForDelete/child');
-      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete/child');
     });
   });
 
@@ -712,10 +634,9 @@ describe('PageService', () => {
       expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ path: { $in: [parentForDeleteCompletely.path] } });
+      expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyPageSpy).toHaveBeenCalledWith({
         $or: [{ path: { $in: [parentForDeleteCompletely.path] } },
-              { path: { $in: [] } },
               { _id: { $in: [parentForDeleteCompletely._id] } }],
       });
       expect(removeAllAttachmentsSpy).toHaveBeenCalled();
@@ -755,16 +676,9 @@ describe('PageService', () => {
     });
 
     test('revert deleted page when the redirect from page exists', async() => {
-
-      findByPathSpy = jest.spyOn(Page, 'findByPath').mockImplementation(() => {
-        return { redirectTo: '/trash/parentForRevert1' };
-      });
-
       const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2);
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
-      expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert1');
-      expect(deleteCompletelySpy).toHaveBeenCalled();
       expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
 
       expect(resultPage.path).toBe('/parentForRevert1');
@@ -795,18 +709,12 @@ describe('PageService', () => {
     });
 
     test('revert deleted descendants', async() => {
-
-      findSpy = jest.spyOn(Page, 'find').mockImplementation(() => {
-        return [{ path: '/parentForRevert/child', redirectTo: '/trash/parentForRevert/child' }];
-      });
-
       await crowi.pageService.revertDeletedDescendants([childForRevert], testUser2);
       const resultPage = await Page.findOne({ path: '/parentForRevert/child' });
       const revrtedFromPage = await Page.findOne({ path: '/trash/parentForRevert/child' });
-      const revrtedFromPageRevision = await Revision.findOne({ path: '/trash/parentForRevert/child' });
+      const revrtedFromPageRevision = await Revision.findOne({ pageId: resultPage._id });
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(childForRevert.path);
-      expect(findSpy).toHaveBeenCalledWith({ path: { $in: ['/parentForRevert/child'] } });
 
       expect(resultPage.path).toBe('/parentForRevert/child');
       expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);

+ 14 - 2
packages/app/test/integration/service/v5-migration.test.js

@@ -5,6 +5,7 @@ const { getInstance } = require('../setup-crowi');
 describe('V5 page migration', () => {
   let crowi;
   let Page;
+  let User;
 
   let testUser1;
 
@@ -13,10 +14,14 @@ describe('V5 page migration', () => {
 
     crowi = await getInstance();
     Page = mongoose.model('Page');
+    User = mongoose.model('User');
+
+    await User.insertMany([{ name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' }]);
+    testUser1 = await User.findOne({ username: 'testUser1' });
   });
 
 
-  describe('v5MigrationByPageIds()', () => {
+  describe('normalizeParentRecursivelyByPageIds()', () => {
     test('should migrate all pages specified by pageIds', async() => {
       jest.restoreAllMocks();
 
@@ -27,30 +32,34 @@ describe('V5 page migration', () => {
           grant: Page.GRANT_OWNER,
           creator: testUser1,
           lastUpdateUser: testUser1,
+          grantedUsers: [testUser1._id],
         },
         {
           path: '/dummyParent/private1',
           grant: Page.GRANT_OWNER,
           creator: testUser1,
           lastUpdateUser: testUser1,
+          grantedUsers: [testUser1._id],
         },
         {
           path: '/dummyParent/private1/private2',
           grant: Page.GRANT_OWNER,
           creator: testUser1,
           lastUpdateUser: testUser1,
+          grantedUsers: [testUser1._id],
         },
         {
           path: '/dummyParent/private1/private3',
           grant: Page.GRANT_OWNER,
           creator: testUser1,
           lastUpdateUser: testUser1,
+          grantedUsers: [testUser1._id],
         },
       ]);
 
       const pageIds = pages.map(page => page._id);
       // migrate
-      await crowi.pageService.v5MigrationByPageIds(pageIds);
+      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
 
       const migratedPages = await Page.find({
         path: {
@@ -67,6 +76,7 @@ describe('V5 page migration', () => {
   });
 
   describe('v5InitialMigration()', () => {
+    jest.setTimeout(60000);
     let createPagePaths;
     let allPossiblePagePaths;
     beforeAll(async() => {
@@ -88,6 +98,7 @@ describe('V5 page migration', () => {
           grant: Page.GRANT_OWNER,
           creator: testUser1,
           lastUpdateUser: testUser1,
+          grantedUsers: [testUser1._id],
         },
         {
           path: '/publicA/privateB/publicC',
@@ -122,6 +133,7 @@ describe('V5 page migration', () => {
 
       // migrate
       await crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC);
+      jest.setTimeout(30000);
     });
 
     test('should migrate all public pages', async() => {

+ 1 - 2
packages/plugin-attachment-refs/src/server/routes/refs.js

@@ -166,8 +166,7 @@ module.exports = (crowi) => {
     if (prefix != null) {
       builder = new PageQueryBuilder(Page.find())
         .addConditionToListWithDescendants(prefix)
-        .addConditionToExcludeTrashed()
-        .addConditionToExcludeRedirect();
+        .addConditionToExcludeTrashed();
     }
     // builder to get single page
     else {

+ 1 - 2
packages/plugin-lsx/src/server/routes/lsx.js

@@ -195,8 +195,7 @@ module.exports = (crowi, app) => {
     }
 
     builder
-      .addConditionToExcludeTrashed()
-      .addConditionToExcludeRedirect();
+      .addConditionToExcludeTrashed();
 
     return Page.addConditionToFilteringByViewerForList(builder, user);
   }

+ 2 - 2
packages/ui/src/components/SearchPage/FootstampIcon.jsx

@@ -3,8 +3,8 @@ import React from 'react';
 export const FootstampIcon = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
-    width="16"
-    height="16"
+    width={16}
+    height={16}
     viewBox="0 0 16 16"
   >
     <path d="M7.34,8,3.31,9a1.83,1.83,0,0,1-1.24-.08A1.28,1.28,0,0,1,1.34,8a3.24,3.24,0,0,1,.2-1.82A6.06,6.06,0,0,1,2.6,4.35h0a2.56,