فهرست منبع

Merge branch 'master' into imprv/88175-show-delete-modal-with-swr

* master: (48 commits)
  Fixed process
  Fixed & Removed resolved comments
  Fixed process
  Fixed type
  Improved process
  remove the display from pagetree when a page is deleted
  Improved readability
  show delete and duplicate
  Improved
  Added limitation
  Improved
  delete unnecessary #
  add todo
  Revert "🪣 🪣 🪣 🪣"
  imprv
  warning when not creatable
  check creatable firtst
  rename
  imprv
  allow empty strings
  ...
Mao 4 سال پیش
والد
کامیت
694d8b83c5

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

@@ -167,6 +167,8 @@
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "Link sharing is disabled",
+  "successfully_saved_the_page": "Successfully saved the page",
+  "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",

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

@@ -169,6 +169,8 @@
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
+  "successfully_saved_the_page": "ページが正常に保存されました",
+  "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",

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

@@ -175,6 +175,8 @@
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "你不允许分享该链接",
+  "successfully_saved_the_page": "成功地保存了该页面",
+  "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",

+ 1 - 1
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -107,7 +107,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   return (
     <div className={props.isShown ? 'd-block' : 'd-none'}>
       <input
-        value={inputText}
+        value={inputText || ''}
         ref={inputRef}
         type="text"
         className="form-control"

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

@@ -112,7 +112,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Duplicate */}
-        { isEnableActions && !pageInfo.isEmpty && (
+        { isEnableActions && (
           <DropdownItem onClick={duplicateItemClickedHandler}>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
@@ -131,7 +131,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
+        { isEnableActions && pageInfo.isMovable && (
           <>
             <DropdownItem divider />
             <DropdownItem

+ 31 - 5
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -8,13 +8,13 @@ import { useDrag, useDrop } from 'react-dnd';
 
 import nodePath from 'path';
 
-import { pathUtils } from '@growi/core';
+import { pathUtils, pagePathUtils } from '@growi/core';
 
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
+import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { IPageForPageDeleteModal } from '~/stores/modal';
-import { apiv3Put } from '~/client/util/apiv3-client';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
@@ -257,12 +257,38 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickDeleteMenuItem(pageToDelete);
   }, [page, onClickDeleteMenuItem]);
 
-  const onPressEnterForCreateHandler = (inputText: string) => {
+  const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
     const parentPath = pathUtils.addTrailingSlash(page.path as string);
     const newPagePath = `${parentPath}${inputText}`;
-    console.log(newPagePath);
-    // TODO: https://redmine.weseek.co.jp/issues/87943
+    const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
+
+    if (!isCreatable) {
+      toastWarning(t('you_can_not_create_page_with_this_name'));
+      return;
+    }
+
+    // TODO 88261: Get the isEnabledAttachTitleHeader by SWR
+    // const initBody = '';
+    // const { isEnabledAttachTitleHeader } = props.appContainer.getConfig();
+    // if (isEnabledAttachTitleHeader) {
+    //   initBody = pathUtils.attachTitleHeader(newPagePath);
+    // }
+
+    try {
+      await apiv3Post('/pages/', {
+        path: newPagePath,
+        body: '',
+        grant: page.grant,
+        grantUserGroupId: page.grantedGroup,
+        createFromPageTree: true,
+      });
+      mutateChildren();
+      toastSuccess(t('successfully_saved_the_page'));
+    }
+    catch (err) {
+      toastError(err);
+    }
   };
 
   const inputValidator = (title: string | null): AlertInfo | null => {

+ 4 - 1
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
@@ -95,6 +95,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { t } = useTranslation();
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
+  const { mutate: mutateChildren } = useSWRxPageChildren(targetPathOrId);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
@@ -122,6 +123,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
       return;
     }
 
+    mutateChildren();
+
     const path = pathOrPathsToDelete;
 
     if (isRecursively) {

+ 33 - 0
packages/app/src/server/models/obsolete-page.js

@@ -189,6 +189,39 @@ export class PageQueryBuilder {
     return this;
   }
 
+  async addConditionForParentNormalization(user) {
+    // determine UserGroup condition
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation');
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const grantConditions = [
+      { grant: null },
+      { grant: GRANT_PUBLIC },
+    ];
+
+    if (user != null) {
+      grantConditions.push(
+        { grant: GRANT_OWNER, grantedUsers: user._id },
+      );
+    }
+
+    if (userGroups != null && userGroups.length > 0) {
+      grantConditions.push(
+        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+      );
+    }
+
+    this.query = this.query
+      .and({
+        $or: grantConditions,
+      });
+
+    return this;
+  }
+
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const grantConditions = [
       { grant: null },

+ 17 - 2
packages/app/src/server/models/page.ts

@@ -167,7 +167,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  */
-schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?, deleteExPageIfEmpty = false): Promise<void> {
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
@@ -201,6 +201,12 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   };
 
   await this.bulkWrite([operationForNewTarget, operationsForChildren]);
+
+  const isExPageEmpty = exPage.isEmpty;
+  if (deleteExPageIfEmpty && isExPageEmpty) {
+    await this.deleteOne({ _id: exPage._id });
+    logger.warn('Deleted empty page since it was replaced with another page.');
+  }
 };
 
 /**
@@ -219,7 +225,7 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   /*
    * Fill parents if parent is null
    */
-  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
+  const ancestorPaths = collectAncestorPaths(path, [path]); // paths of parents need to be created
 
   // just create ancestors with empty pages
   await this.createEmptyPagesByPaths(ancestorPaths);
@@ -577,6 +583,15 @@ schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpt
   return pages;
 };
 
+schema.statics.normalizeDescendantCountById = async function(pageId) {
+  const children = await this.find({ parent: pageId });
+
+  const sumChildrenDescendantCount = children.map(d => d.descendantCount).reduce((c1, c2) => c1 + c2);
+  const sumChildPages = children.filter(p => !p.isEmpty).length;
+
+  return this.updateOne({ _id: pageId }, { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } }, { new: true });
+};
+
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike

+ 3 - 0
packages/app/src/server/models/revision.js

@@ -10,6 +10,9 @@ module.exports = function(crowi) {
   const mongoose = require('mongoose');
   const mongoosePaginate = require('mongoose-paginate-v2');
 
+  // allow empty strings
+  mongoose.Schema.Types.String.checkRequired(v => v != null);
+
   const ObjectId = mongoose.Schema.Types.ObjectId;
   const revisionSchema = new mongoose.Schema({
     // OBSOLETE path: { type: String, required: true, index: true }

+ 8 - 4
packages/app/src/server/routes/apiv3/pages.js

@@ -159,8 +159,8 @@ module.exports = (crowi) => {
 
   const validator = {
     createPage: [
-      body('body').exists().not().isEmpty({ ignore_whitespace: true })
-        .withMessage('body is required'),
+      body('body').exists()
+        .withMessage('body is re quired but an empty string is allowed'),
       body('path').exists().not().isEmpty({ ignore_whitespace: true })
         .withMessage('path is required'),
       body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
@@ -168,6 +168,7 @@ module.exports = (crowi) => {
       body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
       body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
       body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+      body('createFromPageTree').optional().isBoolean().withMessage('createFromPageTree must be boolean'),
     ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
@@ -244,6 +245,9 @@ module.exports = (crowi) => {
    *                    type: array
    *                    items:
    *                      $ref: '#/components/schemas/Tag'
+   *                  createFromPageTree:
+   *                    type: boolean
+   *                    description: Whether the page was created from the page tree or not
    *                required:
    *                  - body
    *                  - path
@@ -797,7 +801,7 @@ module.exports = (crowi) => {
     }
     else {
       try {
-        await crowi.pageService.normalizeParentByPageIds(pageIds);
+        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
       }
       catch (err) {
         return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
@@ -810,7 +814,7 @@ module.exports = (crowi) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-      const migratablePagesCount = req.user != null ? await crowi.pageService.v5MigratablePrivatePagesCount(req.user) : null;
+      const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly
       return res.apiv3({ isV5Compatible, migratablePagesCount });
     }
     catch (err) {

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -900,7 +900,7 @@ module.exports = function(crowi, app) {
    * - If revision_id is not specified => force update by the new contents.
    */
   api.update = async function(req, res) {
-    const pageBody = req.body.body || null;
+    const pageBody = body ?? null;
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;

+ 16 - 8
packages/app/src/server/service/page-grant.ts

@@ -233,7 +233,7 @@ class PageGrantService {
       .addConditionToSortPagesByDescPath()
       .query
       .exec();
-    const testAncestor = ancestors[0];
+    const testAncestor = ancestors[0]; // TODO: consider when duplicate testAncestors exist
     if (testAncestor == null) {
       throw Error('testAncestor must exist');
     }
@@ -320,7 +320,9 @@ class PageGrantService {
 
   /**
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
-   * Only v5 schema pages will be used to compare.
+   * Only v5 schema pages will be used to compare by default (Set includeNotMigratedPages to true to include v4 schema pages as well).
+   * When comparing, it will use path regex to collect pages instead of using parent attribute of the Page model. This is reasonable since
+   * using the path attribute is safer than using the parent attribute in this case. 2022.02.13 -- Taichi Masuyama
    * @returns Promise<boolean>
    */
   async isGrantNormalized(
@@ -344,7 +346,13 @@ class PageGrantService {
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
   }
 
-  async separateNormalizedAndNonNormalizedPages(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+  /**
+   * Separate normalizable pages and NOT normalizable pages by PageService.prototype.isGrantNormalized method.
+   * normalizable pages = Pages which are able to run normalizeParentRecursively method (grant & userGroup rule is correct)
+   * @param pageIds pageIds to be tested
+   * @returns a tuple with the first element of normalizable pages and the second element of NOT normalizable pages
+   */
+  async separateNormalizableAndNotNormalizablePages(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
     if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
       throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
@@ -354,8 +362,8 @@ class PageGrantService {
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
 
-    const normalizedPages: (PageDocument & { _id: any })[] = [];
-    const nonNormalizedPages: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
+    const normalizable: (PageDocument & { _id: any })[] = [];
+    const nonNormalizable: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
 
     const builder = new PageQueryBuilder(Page.find());
     builder.addConditionToListByPageIdsArray(pageIds);
@@ -369,14 +377,14 @@ class PageGrantService {
 
       const isNormalized = await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages);
       if (isNormalized) {
-        normalizedPages.push(page);
+        normalizable.push(page);
       }
       else {
-        nonNormalizedPages.push(page);
+        nonNormalizable.push(page);
       }
     }
 
-    return [normalizedPages, nonNormalizedPages];
+    return [normalizable, nonNormalizable];
   }
 
 }

+ 99 - 44
packages/app/src/server/service/page.ts

@@ -9,7 +9,7 @@ import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import {
-  CreateMethod, generateGrantCondition, PageCreateOptions, PageModel,
+  CreateMethod, generateGrantCondition, PageCreateOptions, PageDocument, PageModel,
 } from '~/server/models/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import ActivityDefine from '../util/activityDefine';
@@ -1065,7 +1065,7 @@ class PageService {
       // replace with an empty page
       const shouldReplace = await Page.exists({ parent: page._id });
       if (shouldReplace) {
-        await Page.replaceTargetWithPage(page);
+        await Page.replaceTargetWithPage(page, null, true);
       }
 
       // update descendantCount of ancestors'
@@ -1077,10 +1077,7 @@ class PageService {
 
     let deletedPage;
     // update Revisions
-    if (page.isEmpty) {
-      await Page.remove({ _id: page._id });
-    }
-    else {
+    if (!page.isEmpty) {
       await Revision.updateRevisionListByPageId(page._id, { pageId: page._id });
       deletedPage = await Page.findByIdAndUpdate(page._id, {
         $set: {
@@ -1808,10 +1805,18 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
 
-  async normalizeParentByPageIds(pageIds: ObjectIdLike[]): Promise<void> {
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user): Promise<void> {
     for await (const pageId of pageIds) {
       try {
-        await this.normalizeParentByPageId(pageId);
+        const normalizedPage = await this.normalizeParentByPageId(pageId, user);
+
+        if (normalizedPage == null) {
+          logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
+        }
+        else {
+          // update descendantCount of ancestors'
+          await this.updateDescendantCountOfAncestors(pageId, normalizedPage.descendantCount, false);
+        }
       }
       catch (err) {
         // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
@@ -1819,17 +1824,23 @@ class PageService {
     }
   }
 
-  private async normalizeParentByPageId(pageId: ObjectIdLike) {
+  private async normalizeParentByPageId(pageId: ObjectIdLike, user) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const target = await Page.findById(pageId);
+    const target = await Page.findByIdAndViewerToEdit(pageId, user);
     if (target == null) {
-      throw Error('target does not exist');
+      throw Error('target does not exist or forbidden');
     }
 
     const {
       path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
     } = target;
 
+    // check if any page exists at target path already
+    const existingPage = await Page.findOne({ path });
+    if (existingPage != null && !existingPage.isEmpty) {
+      throw Error('Page already exists. Please rename the page to continue.');
+    }
+
     /*
      * UserGroup & Owner validation
      */
@@ -1852,62 +1863,73 @@ class PageService {
       throw Error('Restricted pages can not be migrated');
     }
 
-    // getParentAndFillAncestors
-    const parent = await Page.getParentAndFillAncestors(target.path);
+    let updatedPage;
+
+    // replace if empty page exists
+    if (existingPage != null && existingPage.isEmpty) {
+      await Page.replaceTargetWithPage(existingPage, target, true);
+      updatedPage = await Page.findById(pageId);
+    }
+    else {
+      // getParentAndFillAncestors
+      const parent = await Page.getParentAndFillAncestors(target.path);
+      updatedPage = await Page.findOneAndUpdate({ _id: pageId }, { parent: parent._id }, { new: true });
+    }
 
-    return Page.updateOne({ _id: pageId }, { parent: parent._id });
+    return updatedPage;
   }
 
+  // TODO: this should be resumable
   async normalizeParentRecursivelyByPageIds(pageIds, user) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
     if (pageIds == null || pageIds.length === 0) {
       logger.error('pageIds is null or 0 length.');
       return;
     }
 
-    let normalizedIds;
-    let notNormalizedPaths;
+    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
+    }
+
+    let normalizablePages;
+    let nonNormalizablePages;
     try {
-      [normalizedIds, notNormalizedPaths] = await this.crowi.pageGrantService.separateNormalizedAndNonNormalizedPages(pageIds);
+      [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(pageIds);
     }
     catch (err) {
       throw err;
     }
 
-    if (normalizedIds.length === 0) {
+    if (normalizablePages.length === 0) {
       // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
       return;
     }
 
-    if (notNormalizedPaths.length !== 0) {
-      // TODO: iterate notNormalizedPaths and send socket error to client so that the user can know which path failed to migrate
+    if (nonNormalizablePages.length !== 0) {
+      // TODO: iterate nonNormalizablePages and send socket error to client so that the user can know which path failed to migrate
       // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
     }
 
-    /*
-     * generate regexps
-     */
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    let pages;
-    try {
-      pages = await Page.findByPageIdsToEdit(pageIds, user, false);
-    }
-    catch (err) {
-      logger.error('Failed to find pages by ids', err);
-      throw err;
-    }
-
-    // prepare no duplicated area paths
-    let paths = pages.map(p => p.path);
-    paths = omitDuplicateAreaPathFromPaths(paths);
-
-    const regexps = paths.map(path => new RegExp(`^${escapeStringRegexp(path)}`));
+    const pagesToNormalize = omitDuplicateAreaPageFromPages(normalizablePages);
+    const pageIdsToNormalize = pagesToNormalize.map(p => p._id);
+    const pathsToNormalize = Array.from(new Set(pagesToNormalize.map(p => p.path)));
 
     // TODO: insertMany PageOperationBlock
 
-    // migrate recursively
+    // for updating descendantCount
+    const pageIdToExDescendantCountMap = new Map<ObjectIdLike, number>();
+
+    // MAIN PROCESS migrate recursively
+    const regexps = pathsToNormalize.map(p => new RegExp(`^${escapeStringRegexp(p)}`, 'i'));
     try {
       await this.normalizeParentRecursively(null, regexps);
+
+      // find pages to save descendantCount of normalized pages (only pages in pageIds parameter of this method)
+      const pagesBeforeUpdatingDescendantCount = await Page.findByIdsAndViewer(pageIdsToNormalize, user);
+      pagesBeforeUpdatingDescendantCount.forEach((p) => {
+        pageIdToExDescendantCountMap.set(p._id.toString(), p.descendantCount);
+      });
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -1915,6 +1937,28 @@ class PageService {
 
       throw err;
     }
+
+    // POST MAIN PROCESS update descendantCount
+    try {
+      // update descendantCount of self and descendant pages first
+      for await (const path of pathsToNormalize) {
+        await this.updateDescendantCountOfSelfAndDescendants(path);
+      }
+
+      // find pages again to get updated descendantCount
+      // then calculate inc
+      const pagesAfterUpdatingDescendantCount = await Page.findByIdsAndViewer(pageIdsToNormalize, user);
+      for await (const page of pagesAfterUpdatingDescendantCount) {
+        const exDescendantCount = pageIdToExDescendantCountMap.get(page._id.toString()) || 0;
+        const newDescendantCount = page.descendantCount;
+        const inc = newDescendantCount - exDescendantCount;
+        await this.updateDescendantCountOfAncestors(page._id, inc, false);
+      }
+    }
+    catch (err) {
+      logger.error('Failed to update descendantCount after normalizing parent:', err);
+      throw Error(`Failed to update descendantCount after normalizing parent: ${err}`);
+    }
   }
 
   async _isPagePathIndexUnique() {
@@ -2194,12 +2238,23 @@ class PageService {
     }
   }
 
-  async v5MigratablePrivatePagesCount(user) {
+  async countPagesCanNormalizeParentByUser(user): Promise<number> {
     if (user == null) {
       throw Error('user is required');
     }
-    const Page = this.crowi.model('Page');
-    return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.count(), false);
+    builder.addConditionAsNotMigrated();
+    builder.addConditionAsNonRootPage();
+    builder.addConditionToExcludeTrashed();
+    await builder.addConditionForParentNormalization(user);
+
+    const nMigratablePages = await builder.query.exec();
+
+    return nMigratablePages;
   }
 
   /**
@@ -2207,7 +2262,7 @@ class PageService {
    * - page that has the same path as the provided path
    * - pages that are descendants of the above page
    */
-  async updateDescendantCountOfSelfAndDescendants(path) {
+  async updateDescendantCountOfSelfAndDescendants(path: string): Promise<void> {
     const BATCH_SIZE = 200;
     const Page = this.crowi.model('Page');
 

+ 15 - 8
packages/app/src/server/service/user-group.ts

@@ -58,30 +58,36 @@ class UserGroupService {
       throw Error('Parent group does not exist.');
     }
 
+    /*
+     * check if able to update parent or not
+     */
 
-    // throw if parent was in its descendants
+    // throw if parent was in self and its descendants
     const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    const descendants = descendantsWithTarget.filter(d => d._id.equals(userGroup._id));
-    if (isIncludesObjectId(descendants, parent._id)) {
+    if (isIncludesObjectId(descendantsWithTarget, parent._id)) {
       throw Error('It is not allowed to choose parent from descendant groups.');
     }
 
     // find users for comparison
     const [targetGroupUsers, parentGroupUsers] = await Promise.all(
-      [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent?._id)], // TODO 85062: consider when parent is null to update the group as the root
+      [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent._id)],
     );
-
     const usersBelongsToTargetButNotParent = targetGroupUsers.filter(user => !parentGroupUsers.includes(user));
+
+    // save if no users exist in both target and parent groups
+    if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {
+      userGroup.parent = parent._id;
+      return userGroup.save();
+    }
+
     // add the target group's users to all ancestors
     if (forceUpdateParents) {
       const ancestorGroups = await UserGroup.findGroupsWithAncestorsRecursively(parent);
       const ancestorGroupIds = ancestorGroups.map(group => group._id);
 
       await UserGroupRelation.createByGroupIdsAndUserIds(ancestorGroupIds, usersBelongsToTargetButNotParent);
-
-      userGroup.parent = parent?._id; // TODO 85062: consider when parent is null to update the group as the root
     }
-    // validate related users
+    // throw if any of users in the target group is NOT included in the parent group
     else {
       const isUpdatable = usersBelongsToTargetButNotParent.length === 0;
       if (!isUpdatable) {
@@ -89,6 +95,7 @@ class UserGroupService {
       }
     }
 
+    userGroup.parent = parent._id;
     return userGroup.save();
   }
 

+ 4 - 0
packages/app/src/server/util/swigFunctions.js

@@ -171,6 +171,10 @@ module.exports = function(crowi, req, locals) {
     });
   };
 
+  locals.attachTitleHeader = function(path) {
+    return pathUtils.attachTitleHeader(path);
+  };
+
   locals.css = {
     grant(pageData) {
       if (!pageData) {

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

@@ -17,9 +17,9 @@
 
     {% if getConfig('crowi', 'customize:isEnabledAttachTitleHeader') %}
     {% if template %}
-    <script type="text/template" id="raw-text-original"># {{ path | path2name | preventXss }}&NewLine;{{ template }}</script>
+    <script type="text/template" id="raw-text-original">{{ attachTitleHeader(path | path2name | preventXss) }}&NewLine;{{ template }}</script>
     {% else %}
-    <script type="text/template" id="raw-text-original"># {{ path | path2name | preventXss }}</script>
+    <script type="text/template" id="raw-text-original">{{ attachTitleHeader(path | path2name | preventXss) }}</script>
     {% endif %}
     {% else %}
     {% if template %}

+ 5 - 3
packages/core/src/utils/page-path-utils.ts

@@ -169,8 +169,9 @@ export const collectAncestorPaths = (path: string, ancestorPaths: string[] = [])
  * @returns omitted paths
  */
 export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
-  return paths.filter((path) => {
-    const isDuplicate = paths.filter(p => (new RegExp(`^${p}\\/.+`, 'i')).test(path)).length > 0;
+  const uniquePaths = Array.from(new Set(paths));
+  return uniquePaths.filter((path) => {
+    const isDuplicate = uniquePaths.filter(p => (new RegExp(`^${p}\\/.+`, 'i')).test(path)).length > 0;
 
     return !isDuplicate;
   });
@@ -178,12 +179,13 @@ export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
 
 /**
  * return pages with path without duplicate area of regexp /^${path}\/.+/i
+ * if the pages' path are the same, it will NOT omit any of them since the other attributes will not be the same
  * @param paths paths to be tested
  * @returns omitted paths
  */
 export const omitDuplicateAreaPageFromPages = (pages: any[]): any[] => {
   return pages.filter((page) => {
-    const isDuplicate = pages.filter(p => (new RegExp(`^${p.path}\\/.+`, 'i')).test(page.path)).length > 0;
+    const isDuplicate = pages.some(p => (new RegExp(`^${p.path}\\/.+`, 'i')).test(page.path));
 
     return !isDuplicate;
   });

+ 11 - 0
packages/core/src/utils/path-utils.js

@@ -106,3 +106,14 @@ export function normalizePath(path) {
   }
   return `/${match[3]}`;
 }
+
+
+/**
+ *
+ * @param {string} path
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function attachTitleHeader(path) {
+  return `# ${path}`;
+}