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

Merge branch 'master' into fix/gw7821-openid-connect-fails

Mudana-Grune 3 лет назад
Родитель
Сommit
db0630b98c
46 измененных файлов с 1985 добавлено и 533 удалено
  1. 0 0
      packages/app/public/static/locales/en_US/admin/admin.json
  2. 0 0
      packages/app/public/static/locales/en_US/meta.json
  3. 11 1
      packages/app/public/static/locales/en_US/translation.json
  4. 0 0
      packages/app/public/static/locales/index.js
  5. 0 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  6. 0 0
      packages/app/public/static/locales/ja_JP/meta.json
  7. 11 1
      packages/app/public/static/locales/ja_JP/translation.json
  8. 0 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  9. 0 0
      packages/app/public/static/locales/zh_CN/meta.json
  10. 11 1
      packages/app/public/static/locales/zh_CN/translation.json
  11. 36 40
      packages/app/src/client/admin.jsx
  12. 4 17
      packages/app/src/client/services/AppContainer.js
  13. 0 23
      packages/app/src/client/services/PageContainer.js
  14. 9 1
      packages/app/src/client/services/page-operation.ts
  15. 3 2
      packages/app/src/client/util/i18n.js
  16. 0 79
      packages/app/src/components/Admin/Users/RemoveAdminButton.jsx
  17. 62 0
      packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx
  18. 60 0
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  19. 0 78
      packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx
  20. 12 9
      packages/app/src/components/Admin/Users/UserMenu.jsx
  21. 40 3
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  22. 5 40
      packages/app/src/components/Page/TrashPageAlert.jsx
  23. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  24. 35 12
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  25. 1 1
      packages/app/src/components/TrashPageList.jsx
  26. 15 0
      packages/app/src/interfaces/page-operation.ts
  27. 2 1
      packages/app/src/interfaces/page.ts
  28. 10 0
      packages/app/src/next-i18next.config.ts
  29. 5 4
      packages/app/src/server/crowi/dev.js
  30. 5 4
      packages/app/src/server/crowi/express-init.js
  31. 14 1
      packages/app/src/server/crowi/index.js
  32. 35 1
      packages/app/src/server/models/page-operation.ts
  33. 19 96
      packages/app/src/server/models/page.ts
  34. 2 8
      packages/app/src/server/models/user.js
  35. 3 4
      packages/app/src/server/routes/apiv3/app-settings.js
  36. 9 10
      packages/app/src/server/routes/apiv3/page-listing.ts
  37. 26 0
      packages/app/src/server/routes/apiv3/pages.js
  38. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  39. 124 18
      packages/app/src/server/service/page-operation.ts
  40. 152 5
      packages/app/src/server/service/page.ts
  41. 31 18
      packages/app/src/stores/ui.tsx
  42. 0 50
      packages/app/src/utils/locale-utils.ts
  43. 82 2
      packages/app/test/integration/global-setup.js
  44. 861 0
      packages/app/test/integration/service/v5.page.test.ts
  45. 267 0
      packages/app/test/integration/service/v5.public-page.test.ts
  46. 20 0
      packages/core/src/utils/page-path-utils.ts

+ 0 - 0
packages/app/resource/locales/en_US/admin/admin.json → packages/app/public/static/locales/en_US/admin/admin.json


+ 0 - 0
packages/app/resource/locales/en_US/meta.json → packages/app/public/static/locales/en_US/meta.json


+ 11 - 1
packages/app/resource/locales/en_US/translation.json → packages/app/public/static/locales/en_US/translation.json

@@ -5,6 +5,7 @@
   "Delete": "Delete",
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
+  "PathRecovery": "Path recovery",
   "Copy": "Copy",
   "preview":"Preview",
   "desktop":"Desktop",
@@ -1111,6 +1112,15 @@
     "cancel_bookmark": "Cancel Bookmark",
     "receive_notifications": "Receive Notifications",
     "stop_notification": "Stop Notification",
-    "footprints": "Footprints"
+    "footprints": "Footprints",
+    "operation": {
+      "attention": {
+        "rename": "Renaming paths of descendant pages was not successful, please open the menu from the 3-point reader and select 'Path recovery'"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "Paths recovered successfully",
+    "path_recovery_failed":"Path recovery failed"
   }
 }

+ 0 - 0
packages/app/resource/locales/index.js → packages/app/public/static/locales/index.js


+ 0 - 0
packages/app/resource/locales/ja_JP/admin/admin.json → packages/app/public/static/locales/ja_JP/admin/admin.json


+ 0 - 0
packages/app/resource/locales/ja_JP/meta.json → packages/app/public/static/locales/ja_JP/meta.json


+ 11 - 1
packages/app/resource/locales/ja_JP/translation.json → packages/app/public/static/locales/ja_JP/translation.json

@@ -5,6 +5,7 @@
   "Delete": "削除",
   "delete_all": "全て削除",
   "Duplicate": "複製",
+  "PathRecovery": "パスを修復",
   "Copy": "コピー",
   "preview":"プレビュー",
   "desktop":"パソコン",
@@ -1104,6 +1105,15 @@
     "cancel_bookmark": "ブックマークを取り消す",
     "receive_notifications": "通知を受け取る",
     "stop_notification": "通知を止める",
-    "footprints": "足跡"
+    "footprints": "足跡",
+    "operation": {
+      "attention": {
+        "rename": "配下のページパスの更新が正常に行われませんでした。3点リーダーからメニューを開き、「パスを修復」を選択してしてください。"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "パスを修復しました",
+    "path_recovery_failed":"パスを修復できませんでした"
   }
 }

+ 0 - 0
packages/app/resource/locales/zh_CN/admin/admin.json → packages/app/public/static/locales/zh_CN/admin/admin.json


+ 0 - 0
packages/app/resource/locales/zh_CN/meta.json → packages/app/public/static/locales/zh_CN/meta.json


+ 11 - 1
packages/app/resource/locales/zh_CN/translation.json → packages/app/public/static/locales/zh_CN/translation.json

@@ -5,6 +5,7 @@
 	"Delete": "删除",
 	"delete_all": "删除所有",
 	"Duplicate": "复制",
+  "PathRecovery": "路径恢复",
 	"Copy": "复制",
   "preview":"预览",
   "desktop":"电脑",
@@ -1114,6 +1115,15 @@
     "cancel_bookmark": "取消书签",
     "receive_notifications": "接收通知",
     "stop_notification": "停止通知",
-    "footprints": "脚印"
+    "footprints": "脚印",
+    "operation": {
+      "attention": {
+        "rename": "重命名子孙页的路径没有成功,请从三点式阅读器上打开菜单,选择 '路径恢复'。"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "成功恢复了页面路径",
+    "path_recovery_failed":"路径恢复失败"
   }
 }

+ 36 - 40
packages/app/src/client/admin.jsx

@@ -1,55 +1,52 @@
 import React from 'react';
+
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
-
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
-import loggerFactory from '~/utils/logger';
-import { swrGlobalConfiguration } from '~/utils/swr-utils';
-
-import ErrorBoundary from '../components/ErrorBoudary';
-
-import AdminHome from '../components/Admin/AdminHome/AdminHome';
-import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
-import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
-import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
-import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
-import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
-import UserManagement from '../components/Admin/UserManagement';
-import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
-import SecurityManagement from '../components/Admin/Security/SecurityManagement';
-import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
-import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
-import Customize from '../components/Admin/Customize/Customize';
-import ImportDataPage from '../components/Admin/ImportDataPage';
-import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
-import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
-import AdminNavigation from '../components/Admin/Common/AdminNavigation';
-
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-
+import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
+import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import AdminHome from '../components/Admin/AdminHome/AdminHome';
+import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import AdminNavigation from '../components/Admin/Common/AdminNavigation';
+import Customize from '../components/Admin/Customize/Customize';
+import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
+import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
+import ImportDataPage from '../components/Admin/ImportDataPage';
+import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
+import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
+import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
+import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
+import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
+import SecurityManagement from '../components/Admin/Security/SecurityManagement';
+import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
+import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
+import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
+import UserManagement from '../components/Admin/UserManagement';
+import ErrorBoundary from '../components/ErrorBoudary';
 
 import { appContainer, componentMappings } from './base';
 
@@ -58,7 +55,6 @@ const logger = loggerFactory('growi:admin');
 appContainer.initContents();
 
 const { i18n } = appContainer;
-
 // create unstated container instance
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);

+ 4 - 17
packages/app/src/client/services/AppContainer.js

@@ -15,12 +15,13 @@ export default class AppContainer extends Container {
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
+    // init i18n
     const currentUserElem = document.getElementById('growi-current-user');
+    let userLocaleId;
     if (currentUserElem != null) {
-      this.currentUser = JSON.parse(currentUserElem.textContent);
+      const currentUser = JSON.parse(currentUserElem.textContent);
+      userLocaleId = currentUser?.lang;
     }
-
-    const userLocaleId = this.currentUser?.lang;
     this.i18n = i18nFactory(userLocaleId);
 
     this.containerInstances = {};
@@ -71,20 +72,6 @@ export default class AppContainer extends Container {
     window.crowiPlugin = window.growiPlugin;
   }
 
-  get currentUserId() {
-    if (this.currentUser == null) {
-      return null;
-    }
-    return this.currentUser._id;
-  }
-
-  get currentUsername() {
-    if (this.currentUser == null) {
-      return null;
-    }
-    return this.currentUser.username;
-  }
-
   getConfig() {
     return this.config;
   }

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

@@ -136,29 +136,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
   }
 
-  /**
-   * whether to Empty Trash Page
-   * not displayed when guest user and not on trash page
-   */
-  get isAbleToShowEmptyTrashButton() {
-    const { currentUser } = this.appContainer;
-    const { path, hasChildren } = this.state;
-
-    return (currentUser != null && currentUser.admin && path === '/trash' && hasChildren);
-  }
-
-  /**
-   * whether to display trash management buttons
-   * ex.) undo, delete completly
-   * not displayed when guest user
-   */
-  get isAbleToShowTrashPageManagementButtons() {
-    const { currentUser } = this.appContainer;
-    const { isDeleted } = this.state;
-
-    return (isDeleted && currentUser != null);
-  }
-
   /**
    * initialize state for markdown data
    */

+ 9 - 1
packages/app/src/client/services/page-operation.ts

@@ -3,7 +3,8 @@ import urljoin from 'url-join';
 import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 import { toastError } from '../util/apiNotification';
-import { apiv3Put } from '../util/apiv3-client';
+import { apiv3Post, apiv3Put } from '../util/apiv3-client';
+
 
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
@@ -60,3 +61,10 @@ export const exportAsMarkdown = (pageId: string, revisionId: string, format: str
   url.searchParams.append('revisionId', revisionId);
   window.location.href = url.href;
 };
+
+/**
+ * send request to fix broken paths caused by unexpected events such as server shutdown while renaming page paths
+ */
+export const resumeRenameOperation = async(pageId: string): Promise<void> => {
+  await apiv3Post('/pages/resume-rename', { pageId });
+};

+ 3 - 2
packages/app/src/client/util/i18n.js

@@ -1,7 +1,8 @@
 import i18n from 'i18next';
 import LanguageDetector from 'i18next-browser-languagedetector';
 import { initReactI18next } from 'react-i18next';
-import locales from '^/resource/locales';
+
+import locales from '^/public/static/locales';
 
 const aliasesMapping = {};
 Object.values(locales).forEach((locale) => {
@@ -13,7 +14,7 @@ Object.values(locales).forEach((locale) => {
   });
 });
 
-// extract metadata list from 'resource/locales/${locale}/meta.json'
+// extract metadata list from 'public/static/locales/${locale}/meta.json'
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);
 
 export const i18nFactory = (userLocaleId) => {

+ 0 - 79
packages/app/src/components/Admin/Users/RemoveAdminButton.jsx

@@ -1,79 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-class RemoveAdminButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickRemoveAdminBtn = this.onClickRemoveAdminBtn.bind(this);
-  }
-
-  async onClickRemoveAdminBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.removeUserAdmin(this.props.user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  renderRemoveAdminBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickRemoveAdminBtn() }}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
-      </button>
-    );
-  }
-
-  renderRemoveAdminAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderRemoveAdminBtn()
-          : this.renderRemoveAdminAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-/**
-* Wrapper component for using unstated
-*/
-const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AppContainer, AdminUsersContainer]);
-
-RemoveAdminButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(RemoveAdminButtonWrapper);

+ 62 - 0
packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx

@@ -0,0 +1,62 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserHasId } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+
+const RemoveAdminAlert = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="px-4">
+      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+    </div>
+  );
+});
+
+
+type Props = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { adminUsersContainer, user } = props;
+
+  const { data: currentUser } = useCurrentUser();
+
+  const clickRemoveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.removeUserAdmin(user._id);
+      toastSuccess(t('toaster.remove_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+
+  return user.username !== currentUser?.username
+    ? (
+      <button className="dropdown-item" type="button" onClick={clickRemoveAdminBtnHandler}>
+        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
+      </button>
+    )
+    : <RemoveAdminAlert />;
+};
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminMenuItemWrapper = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
+
+export default RemoveAdminMenuItemWrapper;

+ 60 - 0
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -0,0 +1,60 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { withUnstatedContainers } from '~/components/UnstatedUtils';
+import { IUserHasId } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
+
+
+const SuspendAlert = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="px-4">
+      <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
+    </div>
+  );
+});
+
+
+type Props = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const StatusSuspendMenuItem = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { adminUsersContainer, user } = props;
+
+  const { data: currentUser } = useCurrentUser();
+
+  const clickDeactiveBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.deactivateUser(user._id);
+      toastSuccess(t('toaster.deactivate_user_success', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return user.username !== currentUser?.username
+    ? (
+      <button className="dropdown-item" type="button" onClick={clickDeactiveBtnHandler}>
+        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
+      </button>
+    )
+    : <SuspendAlert />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusSuspendMenuItemWrapper = withUnstatedContainers(StatusSuspendMenuItem, [AdminUsersContainer]);
+
+export default StatusSuspendMenuItemWrapper;

+ 0 - 78
packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx

@@ -1,78 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-class StatusSuspendedButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickDeactiveBtn = this.onClickDeactiveBtn.bind(this);
-  }
-
-  async onClickDeactiveBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.deactivateUser(this.props.user._id);
-      toastSuccess(t('toaster.deactivate_user_success', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  renderSuspendedBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickDeactiveBtn() }}>
-        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
-      </button>
-    );
-  }
-
-  renderSuspendedAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderSuspendedBtn()
-          : this.renderSuspendedAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const StatusSuspendedFormWrapper = withUnstatedContainers(StatusSuspendedButton, [AppContainer, AdminUsersContainer]);
-
-StatusSuspendedButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(StatusSuspendedFormWrapper);

+ 12 - 9
packages/app/src/components/Admin/Users/UserMenu.jsx

@@ -1,20 +1,23 @@
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import {
   UncontrolledDropdown, DropdownToggle, DropdownMenu,
 } from 'reactstrap';
 
-import StatusActivateButton from './StatusActivateButton';
-import StatusSuspendedButton from './StatusSuspendedButton';
-import UserRemoveButton from './UserRemoveButton';
-import RemoveAdminButton from './RemoveAdminButton';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 import GiveAdminButton from './GiveAdminButton';
+import RemoveAdminMenuItem from './RemoveAdminMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
+import StatusActivateButton from './StatusActivateButton';
+import StatusSuspendedMenuItem from './StatusSuspendMenuItem';
+import UserRemoveButton from './UserRemoveButton';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 class UserMenu extends React.Component {
 
@@ -63,7 +66,7 @@ class UserMenu extends React.Component {
         <li className="dropdown-header">{t('status')}</li>
         <li>
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
-          {user.status === 2 && <StatusSuspendedButton user={user} />}
+          {user.status === 2 && <StatusSuspendedMenuItem user={user} />}
           {user.status === 5 && (
             <SendInvitationEmailButton
               user={user}
@@ -85,7 +88,7 @@ class UserMenu extends React.Component {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('admin:user_management.user_table.administrator_menu')}</li>
         <li>
-          {user.admin === true && <RemoveAdminButton user={user} />}
+          {user.admin === true && <RemoveAdminMenuItem user={user} />}
           {user.admin === false && <GiveAdminButton user={user} />}
         </li>
       </Fragment>

+ 40 - 3
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -8,6 +8,7 @@ import {
 import {
   IPageInfoAll, isIPageInfoForOperation,
 } from '~/interfaces/page';
+import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
@@ -20,6 +21,7 @@ export const MenuItemType = {
   DUPLICATE: 'duplicate',
   DELETE: 'delete',
   REVERT: 'revert',
+  PATH_RECOVERY: 'pathRecovery',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -37,6 +39,7 @@ type CommonProps = {
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
@@ -47,6 +50,7 @@ type CommonProps = {
 type DropdownMenuProps = CommonProps & {
   pageId: string,
   isLoading?: boolean,
+  operationProcessData?: IPageOperationProcessData,
 }
 
 const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
@@ -54,8 +58,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   const {
     pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
+    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
   } = props;
 
@@ -108,6 +112,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickDeleteMenuItem(pageId, pageInfo);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const pathRecoveryItemClickedHandler = useCallback(async() => {
+    if (onClickPathRecoveryMenuItem == null) {
+      return;
+    }
+    await onClickPathRecoveryMenuItem(pageId);
+  }, [onClickPathRecoveryMenuItem, pageId]);
+
   let contents = <></>;
 
   if (isLoading) {
@@ -122,6 +134,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
     const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
 
+    // PathRecovery
+    // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+
     contents = (
       <>
         { !isEnableActions && (
@@ -185,6 +201,17 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </>
         ) }
 
+        {/* PathRecovery */}
+        { !forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) && isEnableActions && shouldShowPathRecoveryButton && (
+          <DropdownItem
+            onClick={pathRecoveryItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-wrench grw-page-control-dropdown-icon"></i>
+            {t('PathRecovery')}
+          </DropdownItem>
+        ) }
+
         {/* divider */}
         {/* Delete */}
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
@@ -217,6 +244,7 @@ type PageItemControlSubstanceProps = CommonProps & {
   pageId: string,
   fetchOnInit?: boolean,
   children?: React.ReactNode,
+  operationProcessData?: IPageOperationProcessData,
 }
 
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
@@ -224,7 +252,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
@@ -276,6 +304,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
+  const pathRecoveryMenuItemClickHandler = useCallback(async() => {
+    if (onClickPathRecoveryMenuItem == null) {
+      return;
+    }
+    await onClickPathRecoveryMenuItem(pageId);
+  }, [onClickPathRecoveryMenuItem, pageId]);
+
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
@@ -292,6 +327,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickRenameMenuItem={renameMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
+        onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
       />
     </Dropdown>
   );
@@ -302,6 +338,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 type PageItemControlProps = CommonProps & {
   pageId?: string,
   children?: React.ReactNode,
+  operationProcessData?: IPageOperationProcessData,
 }
 
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {

+ 5 - 40
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,5 +1,4 @@
-import React, { useState } from 'react';
-
+import React from 'react';
 
 import { UserPicture } from '@growi/ui';
 import PropTypes from 'prop-types';
@@ -9,8 +8,8 @@ import PageContainer from '~/client/services/PageContainer';
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
+import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
-import EmptyTrashModal from '../EmptyTrashModal';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -27,6 +26,8 @@ const TrashPageAlert = (props) => {
   const {
     pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
+
+  const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
   const { data: shareLinkId } = useShareLinkId();
 
   /*
@@ -37,19 +38,10 @@ const TrashPageAlert = (props) => {
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
   const { data: updatedAt } = useCurrentUpdatedAt();
-  const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
-  function openEmptyTrashModalHandler() {
-    setIsEmptyTrashModalShown(true);
-  }
-
-  function closeEmptyTrashModalHandler() {
-    setIsEmptyTrashModalShown(false);
-  }
-
   function openPutbackPageModalHandler() {
     const putBackedHandler = (path) => {
       window.location.reload();
@@ -69,20 +61,6 @@ const TrashPageAlert = (props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }
 
-  function renderEmptyButton() {
-    return (
-      <button
-        href="#"
-        type="button"
-        className="btn btn-danger rounded-pill btn-sm ml-auto"
-        data-target="#emptyTrash"
-        onClick={openEmptyTrashModalHandler}
-      >
-        <i className="icon-trash" aria-hidden="true"></i>{ t('modal_empty.empty_the_trash') }
-      </button>
-    );
-  }
-
   function renderTrashPageManagementButtons() {
     return (
       <>
@@ -106,17 +84,6 @@ const TrashPageAlert = (props) => {
     );
   }
 
-  function renderModals() {
-    return (
-      <>
-        <EmptyTrashModal
-          isOpen={isEmptyTrashModalShown}
-          onClose={closeEmptyTrashModalHandler}
-        />
-      </>
-    );
-  }
-
   return (
     <>
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
@@ -133,11 +100,9 @@ const TrashPageAlert = (props) => {
           )}
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
-          <span>{ pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}</span>
-          { pageContainer.isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+          { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
         </div>
       </div>
-      {renderModals()}
     </>
   );
 };

+ 1 - 1
packages/app/src/components/PrivateLegacyPages.tsx

@@ -441,7 +441,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
         appContainer={appContainer}
         pages={data?.data}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
-        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT]}
+        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT, MenuItemType.PATH_RECOVERY]}
         // Components
         searchControl={searchControl}
         searchResultListHead={searchResultListHead}

+ 35 - 12
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -7,10 +7,9 @@ import nodePath from 'path';
 import { pathUtils, pagePathUtils } from '@growi/core';
 import { useDrag, useDrop } from 'react-dnd';
 import { useTranslation } from 'react-i18next';
-import { DropdownToggle } from 'reactstrap';
+import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
-
-import { bookmark, unbookmark } from '~/client/services/page-operation';
+import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
@@ -109,7 +108,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [shouldHide, setShouldHide] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const [isRenaming, setRenaming] = useState(false);
   const [isCreating, setCreating] = useState(false);
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
@@ -271,7 +269,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     try {
       setRenameInputShown(false);
-      setRenaming(true);
       await apiv3Put('/pages/rename', {
         pageId: page._id,
         revisionId: page.revision,
@@ -288,11 +285,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       setRenameInputShown(true);
       toastError(err);
     }
-    finally {
-      setTimeout(() => {
-        setRenaming(false);
-      }, 1000);
-    }
   };
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
@@ -371,6 +363,24 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     return null;
   };
 
+  /**
+   * Users do not need to know if all pages have been renamed.
+   * Make resuming rename operation appears to be working fine to allow users for a seamless operation.
+   */
+  const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
+    try {
+      await resumeRenameOperation(pageId);
+
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
+      toastSuccess(t('page_operation.paths_recovered'));
+    }
+    catch {
+      toastError(t('page_operation.path_recovery_failed'));
+    }
+  };
 
   // didMount
   useEffect(() => {
@@ -398,6 +408,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
   }, [data, isOpen, targetPathOrId]);
 
+  // Rename process
+  // Icon that draw attention from users for some actions
+  const shouldShowAttentionIcon = !!page.processData?.Rename?.isProcessable;
+
   return (
     <div
       id={`pagetree-item-${page._id}`}
@@ -435,9 +449,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           )
           : (
             <>
-              { isRenaming && (
-                <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
+              { shouldShowAttentionIcon && (
+                <>
+                  <i id="path-recovery" className="fa fa-warning mr-2 text-warning"></i>
+                  <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
+                    {t('tooltip.operation.attention.rename')}
+                  </UncontrolledTooltip>
+                </>
               )}
+
               <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
                 <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
               </a>
@@ -456,7 +476,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
             isInstantRename
+            // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+            operationProcessData={page.processData}
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">

+ 1 - 1
packages/app/src/components/TrashPageList.jsx

@@ -24,7 +24,7 @@ const TrashPageList = () => {
 
   const emptyTrashButton = useMemo(() => {
     return <EmptyTrashButton />;
-  }, [t]);
+  }, []);
 
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">

+ 15 - 0
packages/app/src/interfaces/page-operation.ts

@@ -0,0 +1,15 @@
+export const PageActionType = {
+  Rename: 'Rename',
+  Duplicate: 'Duplicate',
+  Delete: 'Delete',
+  DeleteCompletely: 'DeleteCompletely',
+  Revert: 'Revert',
+  NormalizeParent: 'NormalizeParent',
+} as const;
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType]
+export type IPageOperationProcessData = Partial<{
+  [key in PageActionType]: {isProcessable: boolean}
+}>
+export type IPageOperationProcessInfo = {
+  [pageId: string]: IPageOperationProcessData,
+}

+ 2 - 1
packages/app/src/interfaces/page.ts

@@ -1,5 +1,6 @@
 import { Ref, Nullable } from './common';
 import { HasObjectId } from './has-object-id';
+import { IPageOperationProcessData } from './page-operation';
 import { IRevision, HasRevisionShortbody } from './revision';
 import { SubscriptionStatusType } from './subscription';
 import { ITag } from './tag';
@@ -43,7 +44,7 @@ export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
 
 export type IPageHasId = IPage & HasObjectId;
 
-export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
+export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 
 export type IPageInfo = {
   isV5Compatible: boolean,

+ 10 - 0
packages/app/src/next-i18next.config.ts

@@ -0,0 +1,10 @@
+import path from 'path';
+
+export const
+  i18n = {
+    defaultLocale: 'en_US',
+    locales: ['ja_JP', 'zh_CN'],
+  };
+export const defaultNS = 'translation';
+export const localePath = path.resolve('./public/static/locales');
+export const allLocales = [i18n.defaultLocale].concat(i18n.locales);

+ 5 - 4
packages/app/src/server/crowi/dev.js

@@ -1,9 +1,10 @@
 import path from 'path';
-import { listLocaleIds } from '~/utils/locale-utils';
+
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
-const swig = require('swig-templates');
 const onHeaders = require('on-headers');
+const swig = require('swig-templates');
 
 const logger = loggerFactory('growi:crowi:dev');
 
@@ -41,9 +42,9 @@ class CrowiDev {
    */
   requireForAutoReloadServer() {
     // load all json files for live reloading
-    listLocaleIds()
+    allLocales
       .forEach((localeId) => {
-        require(path.join(this.crowi.localeDir, localeId, 'translation.json'));
+        require(path.join(this.crowi.publicDir, 'static/locales', localeId, 'translation.json'));
       });
   }
 

+ 5 - 4
packages/app/src/server/crowi/express-init.js

@@ -1,5 +1,7 @@
 import mongoose from 'mongoose';
 
+import { allLocales, localePath } from '~/next-i18next.config';
+
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
@@ -24,7 +26,6 @@ module.exports = function(crowi, app) {
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
-  const { listLocaleIds } = require('~/utils/locale-utils');
 
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
@@ -41,9 +42,9 @@ module.exports = function(crowi, app) {
     .init({
       // debug: true,
       fallbackLng: ['en_US'],
-      whitelist: listLocaleIds(),
+      whitelist: allLocales,
       backend: {
-        loadPath: `${crowi.localeDir}{{lng}}/translation.json`,
+        loadPath: `${localePath}/{{lng}}/translation.json`,
       },
       detection: {
         order: ['userSettingDetector', 'header', 'navigator'],
@@ -81,7 +82,7 @@ module.exports = function(crowi, app) {
     res.locals.consts = {
       pageGrants: Page.getGrantLabels(),
       userStatus: User.getUserStatusLabels(),
-      language:   listLocaleIds(),
+      language:   allLocales,
       restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
       registrationMode: crowi.aclService.getRegistrationModeLabels(),
     };

+ 14 - 1
packages/app/src/server/crowi/index.js

@@ -16,6 +16,7 @@ import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import Activity from '../models/activity';
+import PageOperation, { PageActionType } from '../models/page-operation';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
@@ -147,6 +148,16 @@ Crowi.prototype.init = async function() {
   await this.autoInstall();
 };
 
+/**
+ * Execute functions that should be run after the express server is ready.
+ */
+Crowi.prototype.asyncAfterExpressServerReady = async function() {
+  if (this.pageOperationService != null) {
+    await this.pageOperationService.afterExpressServerReady();
+  }
+};
+
+
 Crowi.prototype.isPageId = function(pageId) {
   if (!pageId) {
     return false;
@@ -463,6 +474,9 @@ Crowi.prototype.start = async function() {
   // setup Global Error Handlers
   this.setupGlobalErrorHandlers();
 
+  // Execute this asynchronously after the express server is ready so it does not block the ongoing process
+  this.asyncAfterExpressServerReady();
+
   return serverListening;
 };
 
@@ -681,7 +695,6 @@ Crowi.prototype.setupPageService = async function() {
   }
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
-    // TODO: Remove this code when resuming feature is implemented
     await this.pageOperationService.init();
   }
 };

+ 35 - 1
packages/app/src/server/models/page-operation.ts

@@ -1,13 +1,20 @@
+import { getOrCreateModel } from '@growi/core';
+import { addSeconds } from 'date-fns';
 import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
-import { getOrCreateModel } from '@growi/core';
 
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
 } from '~/server/interfaces/page-operation';
+
+import loggerFactory from '../../utils/logger';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
+const TIME_TO_ADD_SEC = 10;
+
+const logger = loggerFactory('growi:models:page-operation');
+
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -39,6 +46,9 @@ export interface IPageOperation {
   user: IUserForResuming,
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
+  unprocessableExpiryDate: Date,
+
+  isProcessable(): boolean
 }
 
 export interface PageOperationDocument extends IPageOperation, Document {}
@@ -48,6 +58,8 @@ export type PageOperationDocumentHasId = PageOperationDocument & { _id: ObjectId
 export interface PageOperationModel extends Model<PageOperationDocument> {
   findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
   findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
+  deleteByActionTypes(deleteTypeList: PageActionType[]): Promise<void>
+  extendExpiryDate(operationId: ObjectIdLike): Promise<void>
 }
 
 const pageSchemaForResuming = new Schema<IPageForResuming>({
@@ -94,6 +106,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },
+  unprocessableExpiryDate: { type: Date, default: () => addSeconds(new Date(), 10) },
 });
 
 schema.statics.findByIdAndUpdatePageActionStage = async function(
@@ -116,4 +129,25 @@ schema.statics.findMainOps = async function(
   );
 };
 
+schema.statics.deleteByActionTypes = async function(
+    actionTypes: PageActionType[],
+): Promise<void> {
+
+  await this.deleteMany({ actionType: { $in: actionTypes } });
+  logger.info(`Deleted all PageOperation documents with actionType: [${actionTypes}]`);
+};
+
+/**
+ * add TIME_TO_ADD_SEC to current time and update unprocessableExpiryDate with it
+ */
+schema.statics.extendExpiryDate = async function(operationId: ObjectIdLike): Promise<void> {
+  const date = addSeconds(new Date(), TIME_TO_ADD_SEC);
+  await this.findByIdAndUpdate(operationId, { unprocessableExpiryDate: date });
+};
+
+schema.methods.isProcessable = function(): boolean {
+  const { unprocessableExpiryDate } = this;
+  return unprocessableExpiryDate == null || (unprocessableExpiryDate != null && new Date() > unprocessableExpiryDate);
+};
+
 export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);

+ 19 - 96
packages/app/src/server/models/page.ts

@@ -20,7 +20,9 @@ import Crowi from '../crowi';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 
 const { addTrailingSlash, normalizePath } = pathUtils;
-const { isTopPage, collectAncestorPaths } = pagePathUtils;
+const {
+  isTopPage, collectAncestorPaths, hasSlash,
+} = pagePathUtils;
 
 const logger = loggerFactory('growi:models:page');
 /*
@@ -58,8 +60,6 @@ export interface PageModel extends Model<PageDocument> {
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
-  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
-  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
   findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
@@ -114,22 +114,6 @@ const schema = new Schema<PageDocument, PageModel>({
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
-const hasSlash = (str: string): boolean => {
-  return str.includes('/');
-};
-
-/*
- * Generate RegExp instance for one level lower path
- */
-const generateChildrenRegExp = (path: string): RegExp => {
-  // https://regex101.com/r/laJGzj/1
-  // ex. /any_level1
-  if (isTopPage(path)) return new RegExp(/^\/[^/]+$/);
-
-  // https://regex101.com/r/mrDJrx/1
-  // ex. /parent/any_child OR /any_level1
-  return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
-};
 
 export class PageQueryBuilder {
 
@@ -361,6 +345,18 @@ export class PageQueryBuilder {
     return this;
   }
 
+  // add viewer condition to PageQueryBuilder instance
+  async addViewerCondition(user, userGroups = null): Promise<PageQueryBuilder> {
+    let relatedUserGroups = userGroups;
+    if (user != null && relatedUserGroups == null) {
+      const UserGroupRelation: any = mongoose.model('UserGroupRelation');
+      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    this.addConditionToFilteringByViewer(user, relatedUserGroups, false);
+    return this;
+  }
+
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const condition = generateGrantCondition(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
 
@@ -546,17 +542,6 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   return this.findById(newTarget._id);
 };
 
-// Utility function to add viewer condition to PageQueryBuilder instance
-const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
-  let relatedUserGroups = userGroups;
-  if (user != null && relatedUserGroups == null) {
-    const UserGroupRelation: any = mongoose.model('UserGroupRelation');
-    relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-
-  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
-};
-
 /*
  * Find pages by ID and viewer.
  */
@@ -564,7 +549,7 @@ schema.statics.findByIdsAndViewer = async function(pageIds: string[], user, user
   const baseQuery = this.find({ _id: { $in: pageIds } });
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
 
-  await addViewerCondition(queryBuilder, user, userGroups);
+  await queryBuilder.addViewerCondition(user, userGroups);
 
   return queryBuilder.query.exec();
 };
@@ -582,7 +567,7 @@ schema.statics.findByPathAndViewer = async function(
   const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
 
-  await addViewerCondition(queryBuilder, user, userGroups);
+  await queryBuilder.addViewerCondition(user, userGroups);
 
   return queryBuilder.query.exec();
 };
@@ -609,7 +594,7 @@ schema.statics.findRecentUpdatedPages = async function(
 
   queryBuilder.addConditionToListWithDescendants(path, options);
   queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-  await addViewerCondition(queryBuilder, user);
+  await queryBuilder.addViewerCondition(user);
   const pages = await Page.paginate(queryBuilder.query.clone(), {
     lean: true, sort: sortOpt, offset: options.offset, limit: options.limit,
   });
@@ -642,7 +627,7 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
 
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find(), true);
-  await addViewerCondition(queryBuilder, user, userGroups);
+  await queryBuilder.addViewerCondition(user, userGroups);
 
   const _targetAndAncestors: PageDocument[] = await queryBuilder
     .addConditionAsOnTree()
@@ -662,68 +647,6 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   return { targetAndAncestors, rootPage };
 };
 
-/*
- * Find all children by parent's path or id. Using id should be prioritized
- */
-schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
-  let queryBuilder: PageQueryBuilder;
-  if (hasSlash(parentPathOrId)) {
-    const path = parentPathOrId;
-    const regexp = generateChildrenRegExp(path);
-    queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp } }), true);
-  }
-  else {
-    const parentId = parentPathOrId;
-    queryBuilder = new PageQueryBuilder(this.find({ parent: parentId } as any), true); // TODO: improve type
-  }
-  await addViewerCondition(queryBuilder, user, userGroups);
-
-  return queryBuilder
-    .addConditionToSortPagesByAscPath()
-    .query
-    .lean()
-    .exec();
-};
-
-schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
-  const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
-
-  // get pages at once
-  const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }), true);
-  await addViewerCondition(queryBuilder, user, userGroups);
-  const _pages = await queryBuilder
-    .addConditionAsOnTree()
-    .addConditionToMinimizeDataForRendering()
-    .addConditionToSortPagesByAscPath()
-    .query
-    .lean()
-    .exec();
-  // mark target
-  const pages = _pages.map((page: PageDocument & { isTarget?: boolean }) => {
-    if (page.path === path) {
-      page.isTarget = true;
-    }
-    return page;
-  });
-
-  /*
-   * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
-   */
-  const pathToChildren: Record<string, PageDocument[]> = {};
-  const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
-  sortedPaths.every((path) => {
-    const children = pages.filter(page => nodePath.dirname(page.path) === path);
-    if (children.length === 0) {
-      return false; // break when children do not exist
-    }
-    pathToChildren[path] = children;
-    return true;
-  });
-
-  return pathToChildren;
-};
-
 /**
  * Create empty pages at paths at which no pages exist
  * @param paths Page paths

+ 2 - 8
packages/app/src/server/models/user.js

@@ -1,4 +1,5 @@
 /* eslint-disable no-use-before-define */
+import { allLocales } from '~/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
@@ -6,15 +7,12 @@ import loggerFactory from '~/utils/logger';
 const crypto = require('crypto');
 
 const debug = require('debug')('growi:models:user');
-const md5 = require('md5');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
-
 const { omitInsecureAttributes } = require('./serializers/user-serializer');
 
 const logger = loggerFactory('growi:models:user');
@@ -61,7 +59,7 @@ module.exports = function(crowi) {
     apiToken: { type: String, index: true },
     lang: {
       type: String,
-      enum: listLocaleIds(),
+      enum: allLocales,
       default: 'en_US',
     },
     status: {
@@ -78,10 +76,6 @@ module.exports = function(crowi) {
       },
     },
   });
-  // eslint-disable-next-line prefer-arrow-callback
-  userSchema.pre('validate', function() {
-    this.lang = migrateDeprecatedLocaleId(this.lang);
-  });
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(uniqueValidator);
 

+ 3 - 4
packages/app/src/server/routes/apiv3/app-settings.js

@@ -1,4 +1,6 @@
 import { body } from 'express-validator';
+
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -6,11 +8,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 const debug = require('debug')('growi:routes:admin');
-
 const express = require('express');
-
 const { pathUtils } = require('@growi/core');
-const { listLocaleIds } = require('~/utils/locale-utils');
 
 const router = express.Router();
 
@@ -157,7 +156,7 @@ module.exports = (crowi) => {
     appSetting: [
       body('title').trim(),
       body('confidential'),
-      body('globalLang').isIn(listLocaleIds()),
+      body('globalLang').isIn(allLocales),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
     ],

+ 9 - 10
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -1,18 +1,18 @@
 import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
-
 import mongoose from 'mongoose';
 
 import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import { IUserHasId } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 
+import Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
 import ErrorV3 from '../../models/vo/error-apiv3';
-import Crowi from '../../crowi';
-import { ApiV3Response } from './interfaces/apiv3-response';
 import PageService from '../../service/page';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { IUserHasId } from '~/interfaces/user';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
@@ -69,10 +69,9 @@ export default (crowi: Crowi): Router => {
   router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
-    const Page: PageModel = crowi.model('Page');
-
+    const pageService: PageService = crowi.pageService!;
     try {
-      const ancestorsChildren = await Page.findAncestorsChildrenByPathAndViewer(path as string, req.user);
+      const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
       return res.apiv3({ ancestorsChildren });
     }
     catch (err) {
@@ -89,10 +88,10 @@ export default (crowi: Crowi): Router => {
   router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
-    const Page: PageModel = crowi.model('Page');
+    const pageService: PageService = crowi.pageService!;
 
     try {
-      const pages = await Page.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
+      const pages = await pageService.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
       return res.apiv3({ children: pages });
     }
     catch (err) {

+ 26 - 0
packages/app/src/server/routes/apiv3/pages.js

@@ -180,6 +180,9 @@ module.exports = (crowi) => {
       body('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
       body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
+    resumeRenamePage: [
+      body('pageId').isMongoId().withMessage('pageId is required'),
+    ],
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
@@ -554,6 +557,29 @@ module.exports = (crowi) => {
     return res.apiv3(result);
   });
 
+  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, csrf, validator.resumeRenamePage, apiV3FormValidator, async(req, res) => {
+
+    const { pageId } = req.body;
+    const { user } = req;
+
+    // The user has permission to resume rename operation if page is returned.
+    const page = await Page.findByIdAndViewer(pageId, user, null, true);
+    if (page == null) {
+      const msg = 'The operation is forbidden for this user';
+      const code = 'forbidden-user';
+      return res.apiv3Err(new ErrorV3(msg, code), 403);
+    }
+
+    try {
+      await crowi.pageService.resumeRenameSubOperation(page);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+    return res.apiv3();
+  });
+
   /**
    * @swagger
    *

+ 2 - 2
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -1,6 +1,6 @@
 import { body } from 'express-validator';
 
-import { listLocaleIds } from '~/utils/locale-utils';
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 
@@ -83,7 +83,7 @@ module.exports = (crowi) => {
           if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
           return true;
         }),
-      body('lang').isString().isIn(listLocaleIds()),
+      body('lang').isString().isIn(allLocales),
       body('isEmailPublished').isBoolean(),
       body('slackMemberId').optional().isString(),
     ],

+ 124 - 18
packages/app/src/server/service/page-operation.ts

@@ -1,8 +1,19 @@
 import { pagePathUtils } from '@growi/core';
 
-import PageOperation from '~/server/models/page-operation';
+import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
+import PageOperation, { PageActionType, PageActionStage, PageOperationDocument } from '~/server/models/page-operation';
+import loggerFactory from '~/utils/logger';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+const logger = loggerFactory('growi:services:page-operation');
 
 const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const AUTO_UPDATE_INTERVAL_SEC = 5;
+
+const {
+  Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
+} = PageActionType;
 
 class PageOperationService {
 
@@ -12,9 +23,49 @@ class PageOperationService {
     this.crowi = crowi;
   }
 
-  // TODO: Remove this code when resuming feature is implemented
-  async init():Promise<void> {
-    await PageOperation.deleteMany({});
+  async init(): Promise<void> {
+    // cleanup PageOperation documents except ones with actionType: Rename
+    const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    await PageOperation.deleteByActionTypes(types);
+  }
+
+  /**
+   * Execute functions that should be run after the express server is ready.
+   */
+  async afterExpressServerReady(): Promise<void> {
+    try {
+      // execute rename operation
+      await this.executeAllRenameOperationBySystem();
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+
+  /**
+   * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
+   */
+  private async executeAllRenameOperationBySystem(): Promise<void> {
+    const Page = this.crowi.model('Page');
+
+    const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
+      .sort({ createdAt: 'asc' });
+    if (pageOps.length === 0) return;
+
+    for await (const pageOp of pageOps) {
+      const {
+        page, toPath, options, user,
+      } = pageOp;
+
+      const renamedPage = await Page.findById(pageOp.page._id);
+      if (renamedPage == null) {
+        logger.warn('operating page is not found');
+        continue;
+      }
+
+      // rename
+      await this.crowi.pageService.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+    }
   }
 
   /**
@@ -25,44 +76,99 @@ class PageOperationService {
    * @returns boolean
    */
   async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
-    const mainOps = await PageOperation.findMainOps();
+    const pageOperations = await PageOperation.find();
 
-    if (mainOps.length === 0) {
+    if (pageOperations.length === 0) {
       return true;
     }
 
-    const toPaths = mainOps.map(op => op.toPath).filter((p): p is string => p != null);
+    const fromPaths = pageOperations.map(op => op.fromPath).filter((p): p is string => p != null);
+    const toPaths = pageOperations.map(op => op.toPath).filter((p): p is string => p != null);
 
     if (isRecursively) {
-
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const flag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
-        if (flag) return false;
+        const fromFlag = fromPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (fromFlag) return false;
+
+        const toFlag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (toFlag) return false;
       }
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const flag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
-        if (flag) return false;
+        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (fromFlag) return false;
+
+        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (toFlag) return false;
       }
 
     }
     else {
-
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const flag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
-        if (flag) return false;
+        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        if (fromFlag) return false;
+
+        const toFlag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        if (toFlag) return false;
       }
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const flag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
-        if (flag) return false;
-      }
+        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (fromFlag) return false;
 
+        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (toFlag) return false;
+      }
     }
 
     return true;
   }
 
+  /**
+   * Generate object that connects page id with processData of PageOperation.
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  generateProcessInfo(pageOps: PageOperationDocument[]): IPageOperationProcessInfo {
+    const processInfo: IPageOperationProcessInfo = {};
+
+    pageOps.forEach((pageOp) => {
+      const pageId = pageOp.page._id.toString();
+
+      const actionType = pageOp.actionType;
+      const isProcessable = pageOp.isProcessable();
+
+      // processData for processInfo
+      const processData: IPageOperationProcessData = { [actionType]: { isProcessable } };
+
+      // Merge processData if other processData exist
+      if (processInfo[pageId] != null) {
+        const otherProcessData = processInfo[pageId];
+        processInfo[pageId] = { ...otherProcessData, ...processData };
+        return;
+      }
+      // add new process data to processInfo
+      processInfo[pageId] = processData;
+    });
+
+    return processInfo;
+  }
+
+  /**
+   * Set interval to update unprocessableExpiryDate every AUTO_UPDATE_INTERVAL_SEC seconds.
+   * This is used to prevent the same page operation from being processed multiple times at once
+   */
+  autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout {
+    // https://github.com/Microsoft/TypeScript/issues/30128#issuecomment-651877225
+    const timerObj = global.setInterval(async() => {
+      await PageOperation.extendExpiryDate(operationId);
+    }, AUTO_UPDATE_INTERVAL_SEC * 1000);
+    return timerObj;
+  }
+
+  clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void {
+    clearInterval(timerObj);
+  }
+
 }
 
 export default PageOperationService;

+ 152 - 5
packages/app/src/server/service/page.ts

@@ -16,11 +16,12 @@ import {
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
+import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
 import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
-  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision,
+  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
@@ -39,7 +40,7 @@ const debug = require('debug')('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 const {
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  collectAncestorPaths, isMovablePage, canMoveByPath,
+  collectAncestorPaths, isMovablePage, canMoveByPath, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -538,6 +539,11 @@ class PageService {
     }
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
 
+    // 5.increase parent's descendantCount.
+    // see: https://dev.growi.org/62149d019311629d4ecd91cf#Handling%20of%20descendantCount%20in%20case%20of%20unexpected%20process%20interruption
+    const nToIncreaseForOperationInterruption = 1;
+    await Page.incrementDescendantCountOfPageIds([newParent._id], nToIncreaseForOperationInterruption);
+
     // create page redirect
     if (options.createRedirectPage) {
       const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
@@ -564,10 +570,24 @@ class PageService {
 
     const exParentId = page.parent;
 
+    const timerObj = this.crowi.pageOperationService.autoUpdateExpiryDate(pageOpId);
+    try {
     // update descendants first
-    await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+      await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+    }
+    catch (err) {
+      logger.warn(err);
+      throw Error(err);
+    }
+    finally {
+      this.crowi.pageOperationService.clearAutoUpdateInterval(timerObj);
+    }
+
+    // reduce parent's descendantCount
+    // see: https://dev.growi.org/62149d019311629d4ecd91cf#Handling%20of%20descendantCount%20in%20case%20of%20unexpected%20process%20interruption
+    const nToReduceForOperationInterruption = -1;
+    await Page.incrementDescendantCountOfPageIds([renamedPage.parent], nToReduceForOperationInterruption);
 
-    // reduce ancestore's descendantCount
     const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
     await this.updateDescendantCountOfAncestors(exParentId, nToReduce, true);
 
@@ -577,13 +597,39 @@ class PageService {
 
     // Remove leaf empty pages if not moving to under the ex-target position
     if (!this.isRenamingToUnderTarget(page.path, newPagePath)) {
-      // remove empty pages at leaf position
+    // remove empty pages at leaf position
       await Page.removeLeafEmptyPagesRecursively(page.parent);
     }
 
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
+  async resumeRenameSubOperation(renamedPage: PageDocument): Promise<void> {
+
+    // findOne PageOperation
+    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': renamedPage._id };
+    const pageOp = await PageOperation.findOne(filter);
+    if (pageOp == null) {
+      throw Error('There is nothing to be processed right now');
+    }
+    const isProcessable = pageOp.isProcessable();
+    if (!isProcessable) {
+      throw Error('This page operation is currently being processed');
+    }
+
+    const {
+      page, toPath, options, user,
+    } = pageOp;
+
+    // check property
+    if (toPath == null) {
+      throw Error(`Property toPath is missing which is needed to resume page operation(${pageOp._id})`);
+    }
+
+    this.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+
+  }
+
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
     const pathToTest = escapeStringRegexp(addTrailingSlash(fromPath));
     const pathToBeTested = toPath;
@@ -3464,6 +3510,107 @@ class PageService {
     return savedPage;
   }
 
+  /*
+   * Find all children by parent's path or id. Using id should be prioritized
+   */
+  async findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    let queryBuilder: PageQueryBuilder;
+    if (hasSlash(parentPathOrId)) {
+      const path = parentPathOrId;
+      const regexp = generateChildrenRegExp(path);
+      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
+    }
+    else {
+      const parentId = parentPathOrId;
+      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
+      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } } as any), true); // TODO: improve type
+    }
+    await queryBuilder.addViewerCondition(user, userGroups);
+
+    const pages = await queryBuilder
+      .addConditionToSortPagesByAscPath()
+      .query
+      .lean()
+      .exec();
+
+    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    return pages;
+  }
+
+  async findAncestorsChildrenByPathAndViewer(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
+    const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
+
+    // get pages at once
+    const queryBuilder = new PageQueryBuilder(Page.find({ path: { $in: regexps } }), true);
+    await queryBuilder.addViewerCondition(user, userGroups);
+    const pages = await queryBuilder
+      .addConditionAsOnTree()
+      .addConditionToMinimizeDataForRendering()
+      .addConditionToSortPagesByAscPath()
+      .query
+      .lean()
+      .exec();
+
+    this.injectIsTargetIntoPages(pages, path);
+    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    /*
+     * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
+     */
+    const pathToChildren: Record<string, PageDocument[]> = {};
+    const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
+    sortedPaths.every((path) => {
+      const children = pages.filter(page => pathlib.dirname(page.path) === path);
+      if (children.length === 0) {
+        return false; // break when children do not exist
+      }
+      pathToChildren[path] = children;
+      return true;
+    });
+
+    return pathToChildren;
+  }
+
+  private injectIsTargetIntoPages(pages: (PageDocument & {isTarget?: boolean})[], path): void {
+    pages.forEach((page) => {
+      if (page.path === path) {
+        page.isTarget = true;
+      }
+    });
+  }
+
+  /**
+   * Inject processData into page docuements
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  private async injectProcessDataIntoPagesByActionTypes(
+      pages: (PageDocument & { processData?: IPageOperationProcessData })[],
+      actionTypes: PageActionType[],
+  ): Promise<void> {
+
+    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    if (pageOperations == null || pageOperations.length === 0) {
+      return;
+    }
+
+    const processInfo: IPageOperationProcessInfo = this.crowi.pageOperationService.generateProcessInfo(pageOperations);
+    const operatingPageIds: string[] = Object.keys(processInfo);
+
+    // inject processData into pages
+    pages.forEach((page) => {
+      const pageId = page._id.toString();
+      if (operatingPageIds.includes(pageId)) {
+        const processData: IPageOperationProcessData = processInfo[pageId];
+        page.processData = processData;
+      }
+    });
+  }
+
 }
 
 export default PageService;

+ 31 - 18
packages/app/src/stores/ui.tsx

@@ -17,7 +17,7 @@ import loggerFactory from '~/utils/logger';
 
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser, useIsDeleted,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
@@ -303,6 +303,36 @@ export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWR
   return useStaticSWR('globalSearchTypeahead', initialData);
 };
 
+type PageTreeDescCountMapUtils = {
+  update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
+  getDescCount(pageId?: string): number | null | undefined
+}
+
+export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
+  const key = 'pageTreeDescCountMap';
+
+  const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
+
+  return {
+    ...swrResponse,
+    getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
+    update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
+  };
+};
+
+
+/** **********************************************************
+ *                          SWR Hooks
+ *                Determined value by context
+ *********************************************************** */
+
+export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
+  const { data: currentUser } = useCurrentUser();
+  const { data: isDeleted } = useIsDeleted();
+
+  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isDeleted && currentUser != null);
+};
+
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageManagement';
   const { data: currentPageId } = useCurrentPageId();
@@ -367,20 +397,3 @@ export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
     () => isPageExist && !isUserPage,
   );
 };
-
-type PageTreeDescCountMapUtils = {
-  update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
-  getDescCount(pageId?: string): number | null | undefined
-}
-
-export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
-  const key = 'pageTreeDescCountMap';
-
-  const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
-
-  return {
-    ...swrResponse,
-    getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
-    update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
-  };
-};

+ 0 - 50
packages/app/src/utils/locale-utils.ts

@@ -1,50 +0,0 @@
-import fs from 'fs';
-
-import { resolveFromRoot } from '~/utils/project-dir-utils';
-
-const MIGRATE_LOCALE_MAP = {
-  en: 'en_US',
-  ja: 'ja_JP',
-};
-
-/**
- * List locales dirents
- */
-function listLocaleDirents() {
-  const allDirents = fs.readdirSync(resolveFromRoot('./resource/locales'), { withFileTypes: true });
-  return allDirents
-    .filter(dirent => dirent.isDirectory());
-}
-
-/**
- * List locales aliases
- */
-function listLocaleMetadatas() {
-  return listLocaleDirents()
-    .map(dir => dir.name)
-    .map(localeDirName => require(`../../resource/locales/${localeDirName}/meta.json`));
-}
-
-/**
- * List locales IDs (=subdir names)
- */
-function listLocaleIds() {
-  return listLocaleMetadatas()
-    .map(meta => meta.id);
-}
-
-function migrateDeprecatedLocaleId(localeId) {
-  const toValue = MIGRATE_LOCALE_MAP[localeId];
-
-  if (toValue != null) {
-    return toValue;
-  }
-
-  return localeId;
-}
-
-module.exports = {
-  listLocaleMetadatas,
-  listLocaleIds,
-  migrateDeprecatedLocaleId,
-};

+ 82 - 2
packages/app/test/integration/global-setup.js

@@ -7,9 +7,8 @@
 
 import 'tsconfig-paths/register';
 
-import mongoose from 'mongoose';
-
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
 
 // check env
 if (process.env.NODE_ENV !== 'test') {
@@ -27,12 +26,93 @@ module.exports = async() => {
   // init DB
   const pageCollection = mongoose.connection.collection('pages');
   const userCollection = mongoose.connection.collection('users');
+  const userGroupCollection = mongoose.connection.collection('usergroups');
+  const userGroupRelationsCollection = mongoose.connection.collection('usergrouprelations');
 
   // create global user & rootPage
   const globalUser = (await userCollection.insertMany([{ name: 'globalUser', username: 'globalUser', email: 'globalUser@example.com' }]))[0];
+  const gGroupUserId1 = new mongoose.Types.ObjectId();
+  const gGroupUserId2 = new mongoose.Types.ObjectId();
+  const gGroupUserId3 = new mongoose.Types.ObjectId();
+
   await userCollection.insertMany([
     { name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' },
     { name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' },
+    {
+      _id: gGroupUserId1, name: 'gGroupUser1', username: 'gGroupUser1', email: 'gGroupUser1@example.com',
+    },
+    {
+      _id: gGroupUserId2, name: 'gGroupUser2', username: 'gGroupUser2', email: 'gGroupUser2@example.com',
+    },
+    {
+      _id: gGroupUserId3, name: 'gGroupUser3', username: 'gGroupUser3', email: 'gGroupUser3@example.com',
+    },
+  ]);
+  const gGroupIdIsolate = new mongoose.Types.ObjectId();
+  const gGroupIdA = new mongoose.Types.ObjectId();
+  const gGroupIdB = new mongoose.Types.ObjectId();
+  const gGroupIdC = new mongoose.Types.ObjectId();
+  await userGroupCollection.insertMany([
+    {
+      _id: gGroupIdIsolate,
+      name: 'globalGroupIsolate',
+    },
+    {
+      _id: gGroupIdA,
+      name: 'globalGroupA',
+    },
+    {
+      _id: gGroupIdB,
+      name: 'globalGroupB',
+      parent: gGroupIdA,
+    },
+    {
+      _id: gGroupIdC,
+      name: 'globalGroupC',
+      parent: gGroupIdB,
+    },
+  ]);
+  await userGroupRelationsCollection.insertMany([
+    {
+      relatedGroup: gGroupIdIsolate,
+      relatedUser: gGroupUserId1,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdIsolate,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId1,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdB,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdB,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdC,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
   ]);
   await pageCollection.insertMany([{
     path: '/',

+ 861 - 0
packages/app/test/integration/service/v5.page.test.ts

@@ -0,0 +1,861 @@
+import { addSeconds } from 'date-fns';
+import mongoose from 'mongoose';
+
+import { PageActionStage, PageActionType } from '../../../src/server/models/page-operation';
+import { getInstance } from '../setup-crowi';
+
+
+describe('Test page service methods', () => {
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let UserGroup;
+  let UserGroupRelation;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let PageOperation;
+  let xssSpy;
+
+  let rootPage;
+
+  let dummyUser1;
+  let dummyUser2;
+  let globalGroupUser1;
+  let globalGroupUser2;
+  let globalGroupUser3;
+  let globalGroupIsolate;
+  let globalGroupA;
+  let globalGroupB;
+  let globalGroupC;
+
+  let pageOpId1;
+  let pageOpId2;
+  let pageOpId3;
+  let pageOpId4;
+  let pageOpId5;
+  let pageOpId6;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    User = mongoose.model('User');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+    Page = mongoose.model('Page');
+    Revision = mongoose.model('Revision');
+    Tag = mongoose.model('Tag');
+    PageTagRelation = mongoose.model('PageTagRelation');
+    Bookmark = mongoose.model('Bookmark');
+    Comment = mongoose.model('Comment');
+    ShareLink = mongoose.model('ShareLink');
+    PageRedirect = mongoose.model('PageRedirect');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+    PageOperation = mongoose.model('PageOperation');
+
+    /*
+     * Common
+     */
+    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+    // ***********************************************************************************************************
+    // * Do NOT change properties of globally used documents. Otherwise, it might cause some errors in other tests
+    // ***********************************************************************************************************
+    // users
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
+    globalGroupUser1 = await User.findOne({ username: 'gGroupUser1' });
+    globalGroupUser2 = await User.findOne({ username: 'gGroupUser2' });
+    globalGroupUser3 = await User.findOne({ username: 'gGroupUser3' });
+    // groups
+    globalGroupIsolate = await UserGroup.findOne({ name: 'globalGroupIsolate' });
+    globalGroupA = await UserGroup.findOne({ name: 'globalGroupA' });
+    globalGroupB = await UserGroup.findOne({ name: 'globalGroupB' });
+    globalGroupC = await UserGroup.findOne({ name: 'globalGroupC' });
+    // page
+    rootPage = await Page.findOne({ path: '/' });
+
+
+    /**
+     * pages
+     */
+    const pageId0 = new mongoose.Types.ObjectId();
+    const pageId1 = new mongoose.Types.ObjectId();
+    const pageId2 = new mongoose.Types.ObjectId();
+    const pageId3 = new mongoose.Types.ObjectId();
+    const pageId4 = new mongoose.Types.ObjectId();
+    const pageId5 = new mongoose.Types.ObjectId();
+    const pageId6 = new mongoose.Types.ObjectId();
+    const pageId7 = new mongoose.Types.ObjectId();
+    const pageId8 = new mongoose.Types.ObjectId();
+    const pageId9 = new mongoose.Types.ObjectId();
+    const pageId10 = new mongoose.Types.ObjectId();
+    const pageId11 = new mongoose.Types.ObjectId();
+    const pageId12 = new mongoose.Types.ObjectId();
+    const pageId13 = new mongoose.Types.ObjectId();
+    const pageId14 = new mongoose.Types.ObjectId();
+    const pageId15 = new mongoose.Types.ObjectId();
+    const pageId16 = new mongoose.Types.ObjectId();
+    const pageId17 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageId0,
+        path: '/resume_rename_0',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId1,
+        path: '/resume_rename_0/resume_rename_1',
+        parent: pageId0,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId2,
+        path: '/resume_rename_1/resume_rename_2',
+        parent: pageId1,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId3,
+        path: '/resume_rename_1/resume_rename_2/resume_rename_3',
+        parent: pageId2,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId4,
+        path: '/resume_rename_4',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId5,
+        path: '/resume_rename_4/resume_rename_5',
+        parent: pageId0,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId6,
+        path: '/resume_rename_5/resume_rename_6',
+        parent: pageId5,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId7,
+        path: '/resume_rename_7',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId8,
+        path: '/resume_rename_8',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId9,
+        path: '/resume_rename_8/resume_rename_9',
+        parent: pageId8,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        path: '/resume_rename_9/resume_rename_10',
+        parent: pageId9,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId10,
+        path: '/resume_rename_11',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 3,
+        isEmpty: false,
+      },
+      {
+        _id: pageId11,
+        path: '/resume_rename_11/resume_rename_12',
+        parent: pageId10,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId12,
+        path: '/resume_rename_11/resume_rename_12/resume_rename_13',
+        parent: pageId11,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        path: '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14',
+        parent: pageId12,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId13,
+        path: '/resume_rename_15',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId14,
+        path: '/resume_rename_15/resume_rename_16',
+        parent: pageId13,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId15,
+        path: '/resume_rename_15/resume_rename_17',
+        parent: pageId13,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId16,
+        path: '/resume_rename_15/resume_rename_17/resume_rename_18',
+        parent: pageId15,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId17,
+        path: '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19',
+        parent: pageId16,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+    ]);
+
+    /**
+     * PageOperation
+     */
+    pageOpId1 = new mongoose.Types.ObjectId();
+    pageOpId2 = new mongoose.Types.ObjectId();
+    pageOpId3 = new mongoose.Types.ObjectId();
+    pageOpId4 = new mongoose.Types.ObjectId();
+    pageOpId5 = new mongoose.Types.ObjectId();
+    pageOpId6 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId1 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId2 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId3 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId4 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId5 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId6 = new mongoose.Types.ObjectId();
+
+    await PageOperation.insertMany([
+      {
+        _id: pageOpId1,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_1',
+        toPath: '/resume_rename_0/resume_rename_1',
+        page: {
+          _id: pageId1,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_1',
+          revision: pageOpRevisionId1,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: null,
+      },
+      {
+        _id: pageOpId2,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_5',
+        toPath: '/resume_rename_4/resume_rename_5',
+        page: {
+          _id: pageId5,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_5',
+          revision: pageOpRevisionId2,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId3,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_7',
+        // toPath NOT exist
+        page: {
+          _id: pageId7,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_7',
+          revision: pageOpRevisionId3,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId4,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_9',
+        toPath: '/resume_rename_8/resume_rename_9',
+        page: {
+          _id: pageId9,
+          parent: rootPage._id,
+          descendantCount: 1,
+          isEmpty: false,
+          path: '/resume_rename_9',
+          revision: pageOpRevisionId4,
+          status: 'published',
+          grant: Page.GRANT_PUBLIC,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: null,
+      },
+      {
+        _id: pageOpId5,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_11/resume_rename_13',
+        toPath: '/resume_rename_11/resume_rename_12/resume_rename_13',
+        page: {
+          _id: pageId12,
+          parent: pageId10,
+          descendantCount: 1,
+          isEmpty: false,
+          path: '/resume_rename_11/resume_rename_13',
+          revision: pageOpRevisionId5,
+          status: 'published',
+          grant: Page.GRANT_PUBLIC,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId6,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_15/resume_rename_16/resume_rename_18',
+        toPath: '/resume_rename_15/resume_rename_17/resume_rename_18',
+        page: {
+          _id: pageId16,
+          parent: pageId14,
+          descendantCount: 1,
+          isEmpty: false,
+          path: '/resume_rename_15/resume_rename_16/resume_rename_18',
+          revision: pageOpRevisionId6,
+          status: 'published',
+          grant: Page.GRANT_PUBLIC,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+    ]);
+  });
+
+  describe('restart renameOperation', () => {
+    const resumeRenameSubOperation = async(page) => {
+      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
+      await crowi.pageService.resumeRenameSubOperation(page);
+
+      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+
+      mockedRenameSubOperation.mockRestore();
+      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+    };
+
+    test('it should successfully restart rename operation', async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_0'; // out of renaming scope
+      const _path1 = '/resume_rename_0/resume_rename_1'; // renamed already
+      const _path2 = '/resume_rename_1/resume_rename_2'; // not renamed yet
+      const _path3 = '/resume_rename_1/resume_rename_2/resume_rename_3'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/resume_rename_0';
+      const path1 = '/resume_rename_0/resume_rename_1';
+      const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
+      const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      const _page3 = await Page.findOne({ path: _path3 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(2);
+      expect(_page2.descendantCount).toBe(1);
+      expect(_page3.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_1';
+      const toPath = '/resume_rename_0/resume_rename_1';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId1, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page1);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      const page3 = await Page.findById(_page3._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      // check paths after renaming
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page3.path).toBe(path3);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      expect(page0.descendantCount).toBe(3);
+      expect(page1.descendantCount).toBe(2);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+    });
+    test('it should successfully restart rename operation when unprocessableExpiryDate is null', async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_8'; // out of renaming scope
+      const _path1 = '/resume_rename_8/resume_rename_9'; // renamed already
+      const _path2 = '/resume_rename_9/resume_rename_10'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/resume_rename_8';
+      const path1 = '/resume_rename_8/resume_rename_9';
+      const path2 = '/resume_rename_8/resume_rename_9/resume_rename_10';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(1);
+      expect(_page2.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_9';
+      const toPath = '/resume_rename_8/resume_rename_9';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId4, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page1);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      // check paths after renaming
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      // others
+      expect(page1.parent).toStrictEqual(page0._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+      expect(page0.descendantCount).toBe(2);
+      expect(page1.descendantCount).toBe(1);
+      expect(page2.descendantCount).toBe(0);
+    });
+
+    test('it should fail and throw error if PageOperation is not found', async() => {
+      // create dummy page operation data not stored in DB
+      const notExistPageOp = {
+        _id: new mongoose.Types.ObjectId(),
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/FROM_NOT_EXIST',
+        toPath: 'TO_NOT_EXIST',
+        page: {
+          _id: new mongoose.Types.ObjectId(),
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/NOT_EXIST_PAGE',
+          revision: new mongoose.Types.ObjectId(),
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: false,
+        },
+        unprocessableExpiryDate: new Date(),
+      };
+
+      await expect(resumeRenameSubOperation(notExistPageOp))
+        .rejects.toThrow(new Error('There is nothing to be processed right now'));
+    });
+
+    test('it should fail and throw error if the current time is behind unprocessableExpiryDate', async() => {
+      // path before renaming
+      const _path0 = '/resume_rename_4'; // out of renaming scope
+      const _path1 = '/resume_rename_4/resume_rename_5'; // renamed already
+      const _path2 = '/resume_rename_5/resume_rename_6'; // not renamed yet
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      // page operation
+      const fromPath = '/resume_rename_5';
+      const toPath = '/resume_rename_4/resume_rename_5';
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(pageOperation).toBeTruthy();
+
+      // Make `unprocessableExpiryDate` 15 seconds ahead of current time.
+      // The number 15 seconds has no meaning other than placing time in the furue.
+      await PageOperation.findByIdAndUpdate(pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) });
+
+      await expect(resumeRenameSubOperation(_page1)).rejects.toThrow(new Error('This page operation is currently being processed'));
+
+      // cleanup
+      await PageOperation.findByIdAndDelete(pageOperation._id);
+    });
+
+    test('Missing property(toPath) for PageOperation should throw error', async() => {
+      // page
+      const _path1 = '/resume_rename_7';
+      const _page1 = await Page.findOne({ path: _path1 });
+      expect(_page1).toBeTruthy();
+
+      // page operation
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId3, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(pageOperation).toBeTruthy();
+
+      const promise = resumeRenameSubOperation(_page1);
+      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume page operation(${pageOperation._id})`));
+
+      // cleanup
+      await PageOperation.findByIdAndDelete(pageOperation._id);
+    });
+
+    test(`it should succeed but 2 extra descendantCount should be added
+    if the page operation was interrupted right after increasing ancestor's descendantCount in renameSubOperation`, async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_11'; // out of renaming scope
+      const _path1 = '/resume_rename_11/resume_rename_12'; // out of renaming scope
+      const _path2 = '/resume_rename_11/resume_rename_12/resume_rename_13'; // renamed already
+      const _path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14'; // renamed already
+
+      // paths after renaming
+      const path0 = '/resume_rename_11';
+      const path1 = '/resume_rename_11/resume_rename_12';
+      const path2 = '/resume_rename_11/resume_rename_12/resume_rename_13';
+      const path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      const _page3 = await Page.findOne({ path: _path3 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+
+      // descendantCount
+      expect(_page0.descendantCount).toBe(3);
+      expect(_page1.descendantCount).toBe(2);
+      expect(_page2.descendantCount).toBe(1);
+      expect(_page3.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_11/resume_rename_13';
+      const toPath = '/resume_rename_11/resume_rename_12/resume_rename_13';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId5, fromPath, toPath, 'page._id': _page2._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page2);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      const page3 = await Page.findById(_page3._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page3.path).toBe(path3);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      // 2 extra descendants should be added to page1
+      expect(page0.descendantCount).toBe(3);
+      expect(page1.descendantCount).toBe(3); // originally 2, +1 in Main, -1 in Sub, +2 for new descendants
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+    });
+
+    test(`it should succeed but 2 extra descendantCount should be subtracted from ex parent page
+    if the page operation was interrupted right after reducing ancestor's descendantCount in renameSubOperation`, async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_15'; // out of renaming scope
+      const _path1 = '/resume_rename_15/resume_rename_16'; // out of renaming scope
+      const _path2 = '/resume_rename_15/resume_rename_17'; // out of renaming scope
+      const _path3 = '/resume_rename_15/resume_rename_17/resume_rename_18'; // renamed already
+      const _path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19'; // renamed already
+
+      // paths after renaming
+      const path0 = '/resume_rename_15';
+      const path1 = '/resume_rename_15/resume_rename_16';
+      const path2 = '/resume_rename_15/resume_rename_17';
+      const path3 = '/resume_rename_15/resume_rename_17/resume_rename_18';
+      const path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      const _page3 = await Page.findOne({ path: _path3 });
+      const _page4 = await Page.findOne({ path: _path4 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_page4).toBeTruthy();
+
+      // descendantCount
+      expect(_page0.descendantCount).toBe(2);
+      expect(_page1.descendantCount).toBe(0);
+      expect(_page2.descendantCount).toBe(1);
+      expect(_page3.descendantCount).toBe(1);
+      expect(_page4.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_15/resume_rename_16/resume_rename_18';
+      const toPath = '/resume_rename_15/resume_rename_17/resume_rename_18';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId6, fromPath, toPath, 'page._id': _page3._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page3);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      const page3 = await Page.findById(_page3._id);
+      const page4 = await Page.findById(_page4._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page3.path).toBe(path3);
+      expect(page4.path).toBe(path4);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      // 2 extra descendants should be subtracted from page1
+      expect(page0.descendantCount).toBe(2);
+      expect(page1.descendantCount).toBe(-2); // originally 0, -2 for old descendants
+      expect(page2.descendantCount).toBe(2); // originally 1, -1 in Sub, +2 for new descendants
+      expect(page3.descendantCount).toBe(1);
+      expect(page4.descendantCount).toBe(0);
+    });
+  });
+});

+ 267 - 0
packages/app/test/integration/service/v5.public-page.test.ts

@@ -2,6 +2,7 @@
 import { advanceTo } from 'jest-date-mock';
 import mongoose from 'mongoose';
 
+import { PageActionType, PageActionStage } from '../../../src/server/models/page-operation';
 import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
@@ -19,10 +20,14 @@ describe('PageService page operations with only public pages', () => {
   let Comment;
   let ShareLink;
   let PageRedirect;
+  let PageOperation;
   let xssSpy;
 
   let rootPage;
 
+  // page operation ids
+  let pageOpId1;
+
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -35,6 +40,7 @@ describe('PageService page operations with only public pages', () => {
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
     PageRedirect = mongoose.model('PageRedirect');
+    PageOperation = mongoose.model('PageOperation');
 
     /*
      * Common
@@ -128,6 +134,17 @@ describe('PageService page operations with only public pages', () => {
     const pageIdForRename21 = new mongoose.Types.ObjectId();
     const pageIdForRename22 = new mongoose.Types.ObjectId();
     const pageIdForRename23 = new mongoose.Types.ObjectId();
+    const pageIdForRename24 = new mongoose.Types.ObjectId();
+    const pageIdForRename25 = new mongoose.Types.ObjectId();
+    const pageIdForRename26 = new mongoose.Types.ObjectId();
+    const pageIdForRename27 = new mongoose.Types.ObjectId();
+    const pageIdForRename28 = new mongoose.Types.ObjectId();
+
+    const pageIdForRename29 = new mongoose.Types.ObjectId();
+    const pageIdForRename30 = new mongoose.Types.ObjectId();
+
+    pageOpId1 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId1 = new mongoose.Types.ObjectId();
 
     // Create Pages
     await Page.insertMany([
@@ -319,6 +336,101 @@ describe('PageService page operations with only public pages', () => {
         lastUpdateUser: dummyUser1._id,
         parent: pageIdForRename22,
       },
+      {
+        _id: pageIdForRename24,
+        path: '/v5_pageForRename24',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename25,
+        path: '/v5_pageForRename25',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename26,
+        path: '/v5_pageForRename26',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename27,
+        path: '/v5_pageForRename27',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdForRename28,
+        path: '/v5_pageForRename27/v5_pageForRename28',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename27,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename29,
+        path: '/v5_pageForRename29',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdForRename30,
+        path: '/v5_pageForRename29/v5_pageForRename30',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename29,
+        descendantCount: 0,
+      },
+    ]);
+
+    await PageOperation.insertMany([
+      {
+        _id: pageOpId1,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/v5_pageForRename30',
+        toPath: '/v5_pageForRename29/v5_pageForRename30',
+        page: {
+          _id: pageIdForRename30,
+          parent: rootPage._id,
+          descendantCount: 0,
+          isEmpty: false,
+          path: '/v5_pageForRename30',
+          revision: pageOpRevisionId1,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: null,
+      },
     ]);
 
     /*
@@ -1046,6 +1158,31 @@ describe('PageService page operations with only public pages', () => {
       return renamedPage;
     };
 
+    /**
+     * This function only execute renameMainOperation. renameSubOperation is basically omitted(only return null)
+     */
+    const renameMainOperation = async(page, newPagePath, user, options) => {
+      // create page operation from target page
+      const pageOp = await PageOperation.create({
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Main,
+        page,
+        user,
+        fromPath: page.path,
+        toPath: newPagePath,
+        options,
+      });
+
+      // mock return value
+      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
+      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+
+      // restores the original implementation
+      mockedRenameSubOperation.mockRestore();
+
+      return renamedPage;
+    };
+
     test('Should NOT rename top page', async() => {
       expect(rootPage).toBeTruthy();
       let isThrown = false;
@@ -1315,6 +1452,136 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPageChild.isEmpty).toBeTruthy();
       expect(renamedPageGrandchild.isEmpty).toBe(false);
     });
+
+    test('should add 1 descendantCount to parent page in MainOperation', async() => {
+      // paths before renaming
+      const _path0 = '/v5_pageForRename24'; // out of renaming scope
+      const _path1 = '/v5_pageForRename25'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/v5_pageForRename24';
+      const path1 = '/v5_pageForRename24/v5_pageForRename25';
+
+      // new path:  same as path1
+      const newPath = '/v5_pageForRename24/v5_pageForRename25';
+
+      // pages
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page0.descendantCount).toBe(0);
+      expect(_page1.descendantCount).toBe(0);
+
+      await renameMainOperation(_page1, newPath, dummyUser1, {});
+
+      const page0 = await Page.findById(_page0._id); // new parent
+      const page1 = await Page.findById(_page1._id); // renamed one
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1); // renamed
+      expect(page0.descendantCount).toBe(1); // originally 0, +1 in Main.
+      expect(page1.descendantCount).toBe(0);
+
+      // cleanup
+      await PageOperation.findOneAndDelete({ fromPath: _path1 });
+    });
+
+    test('should subtract 1 descendantCount from a new parent page in renameSubOperation', async() => {
+      // paths before renaming
+      const _path0 = '/v5_pageForRename29'; // out of renaming scope
+      const _path1 = '/v5_pageForRename29/v5_pageForRename30'; // already renamed
+
+      // paths after renaming
+      const path0 = '/v5_pageForRename29';
+      const path1 = '/v5_pageForRename29/v5_pageForRename30';
+
+      // new path:  same as path1
+      const newPath = '/v5_pageForRename29/v5_pageForRename30';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+
+      // page operation
+      const fromPath = '/v5_pageForRename30';
+      const toPath = newPath;
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId1, fromPath, toPath, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(pageOperation).toBeTruthy();
+
+      // descendantCount
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(0);
+
+      // renameSubOperation only
+      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id);
+
+      // page
+      const page0 = await Page.findById(_page0._id); // new parent
+      const page1 = await Page.findById(_page1._id); // renamed one
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1); // renamed
+
+      // descendantCount
+      expect(page0.descendantCount).toBe(0); // originally 1, -1 in Sub.
+      expect(page1.descendantCount).toBe(0);
+    });
+
+    test(`should add 1 descendantCount to the a parent page in rename(Main)Operation
+    and subtract 1 descendantCount from the the parent page in rename(Sub)Operation`, async() => {
+      // paths before renaming
+      const _path0 = '/v5_pageForRename26'; // out of renaming scope
+      const _path1 = '/v5_pageForRename27'; // not renamed yet
+      const _path2 = '/v5_pageForRename27/v5_pageForRename28'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/v5_pageForRename26';
+      const path1 = '/v5_pageForRename26/v5_pageForRename27';
+      const path2 = '/v5_pageForRename26/v5_pageForRename27/v5_pageForRename28';
+
+      // new path: same as path1
+      const newPath = '/v5_pageForRename26/v5_pageForRename27';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page0.descendantCount).toBe(0);
+      expect(_page1.descendantCount).toBe(1);
+      expect(_page2.descendantCount).toBe(0);
+
+      await renamePage(_page1, newPath, dummyUser1, {});
+
+      const page0 = await Page.findById(_page0._id); // new parent
+      const page1 = await Page.findById(_page1._id); // renamed
+      const page2 = await Page.findById(_page2._id); // renamed
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page0.descendantCount).toBe(2); // originally 0, +1 in Main, -1 in Sub, +2 for descendants.
+      expect(page1.descendantCount).toBe(1);
+      expect(page2.descendantCount).toBe(0);
+
+      // cleanup
+      await PageOperation.findOneAndDelete({ fromPath: _path1 });
+    });
   });
   describe('Duplicate', () => {
 

+ 20 - 0
packages/core/src/utils/page-path-utils.ts

@@ -267,3 +267,23 @@ export const isPathAreaOverlap = (pathToTest: string, pathToBeTested: string): b
 export const canMoveByPath = (fromPath: string, toPath: string): boolean => {
   return !isPathAreaOverlap(fromPath, toPath);
 };
+
+/**
+ * check if string has '/' in it
+ */
+export const hasSlash = (str: string): boolean => {
+  return str.includes('/');
+};
+
+/**
+ * Generate RegExp instance for one level lower path
+ */
+export const generateChildrenRegExp = (path: string): RegExp => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RegExp(/^\/[^/]+$/);
+
+  // https://regex101.com/r/mrDJrx/1
+  // ex. /parent/any_child OR /any_level1
+  return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
+};