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

Merge pull request #5378 from weseek/feat/page-operation-block

feat: Block page operation
Haku Mizuki 4 лет назад
Родитель
Сommit
4c73d1dd5f

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

@@ -22,6 +22,7 @@ import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
 import PageService from '../service/page';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
+import PageOperationService from '../service/page-operation';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
@@ -679,6 +680,9 @@ Crowi.prototype.setupPageService = async function() {
   if (this.pageGrantService == null) {
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
     this.pageGrantService = new PageGrantService(this);
   }
   }
+  if (this.pageOperationService == null) {
+    this.pageOperationService = new PageOperationService(this);
+  }
 };
 };
 
 
 Crowi.prototype.setupInAppNotificationService = async function() {
 Crowi.prototype.setupInAppNotificationService = async function() {

+ 20 - 3
packages/app/src/server/models/page-operation.ts

@@ -1,5 +1,5 @@
 import mongoose, {
 import mongoose, {
-  Schema, Model, Document,
+  Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
 } from 'mongoose';
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
 
 
@@ -43,8 +43,11 @@ export interface IPageOperation {
 
 
 export interface PageOperationDocument extends IPageOperation, Document {}
 export interface PageOperationDocument extends IPageOperation, Document {}
 
 
+export type PageOperationDocumentHasId = PageOperationDocument & { _id: ObjectIdLike };
+
 export interface PageOperationModel extends Model<PageOperationDocument> {
 export interface PageOperationModel extends Model<PageOperationDocument> {
-  [x:string]: any // TODO: improve type
+  findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
+  findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
 }
 }
 
 
 const pageSchemaForResuming = new Schema<IPageForResuming>({
 const pageSchemaForResuming = new Schema<IPageForResuming>({
@@ -92,10 +95,24 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   incForUpdatingDescendantCount: { type: Number },
   incForUpdatingDescendantCount: { type: Number },
 });
 });
 
 
-schema.statics.findByIdAndUpdatePageActionStage = async function(pageOpId: ObjectIdLike, stage: PageActionStage) {
+schema.statics.findByIdAndUpdatePageActionStage = async function(
+    pageOpId: ObjectIdLike, stage: PageActionStage,
+): Promise<PageOperationDocumentHasId | null> {
+
   return this.findByIdAndUpdate(pageOpId, {
   return this.findByIdAndUpdate(pageOpId, {
     $set: { actionStage: stage },
     $set: { actionStage: stage },
   }, { new: true });
   }, { new: true });
 };
 };
 
 
+schema.statics.findMainOps = async function(
+    filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions,
+): Promise<PageOperationDocumentHasId[]> {
+
+  return this.find(
+    { ...filter, actionStage: PageActionStage.Main },
+    projection,
+    options,
+  );
+};
+
 export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);
 export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);

+ 23 - 18
packages/app/src/server/models/page.ts

@@ -478,6 +478,9 @@ schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise
 schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
 schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
   const self = this;
   const self = this;
   const target = await this.findById(pageId);
   const target = await this.findById(pageId);
+  if (target == null) {
+    throw Error('Target not found');
+  }
 
 
   async function findAncestorsRecursively(target, ancestors = shouldIncludeTarget ? [target] : []) {
   async function findAncestorsRecursively(target, ancestors = shouldIncludeTarget ? [target] : []) {
     const parent = await self.findOne({ _id: target.parent });
     const parent = await self.findOne({ _id: target.parent });
@@ -500,41 +503,38 @@ schema.statics.findAncestorsUsingParentRecursively = async function(pageId: Obje
 schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectIdLike): Promise<void> {
 schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectIdLike): Promise<void> {
   const self = this;
   const self = this;
 
 
-  const initialLeafPage = await this.findById(pageId);
+  const initialPage = await this.findById(pageId);
 
 
-  if (initialLeafPage == null) {
+  if (initialPage == null) {
     return;
     return;
   }
   }
 
 
-  if (!initialLeafPage.isEmpty) {
+  if (!initialPage.isEmpty) {
     return;
     return;
   }
   }
 
 
-  async function generatePageIdsToRemove(page, pageIds: ObjectIdLike[]): Promise<ObjectIdLike[]> {
-    const nextPage = await self.findById(page.parent);
-
-    if (nextPage == null) {
+  async function generatePageIdsToRemove(childPage, page, pageIds: ObjectIdLike[] = []): Promise<ObjectIdLike[]> {
+    if (!page.isEmpty) {
       return pageIds;
       return pageIds;
     }
     }
 
 
-    // delete leaf empty pages
-    const isNextPageEmpty = nextPage.isEmpty;
-
-    if (!isNextPageEmpty) {
+    const isChildrenOtherThanTargetExist = await self.exists({ _id: { $ne: childPage?._id }, parent: page._id });
+    if (isChildrenOtherThanTargetExist) {
       return pageIds;
       return pageIds;
     }
     }
 
 
-    const isSiblingsExist = await self.exists({ parent: nextPage.parent, _id: { $ne: nextPage._id } });
-    if (isSiblingsExist) {
+    pageIds.push(page._id);
+
+    const nextPage = await self.findById(page.parent);
+
+    if (nextPage == null) {
       return pageIds;
       return pageIds;
     }
     }
 
 
-    return generatePageIdsToRemove(nextPage, [...pageIds, nextPage._id]);
+    return generatePageIdsToRemove(page, nextPage, pageIds);
   }
   }
 
 
-  const initialPageIdsToRemove = [initialLeafPage._id];
-
-  const pageIdsToRemove = await generatePageIdsToRemove(initialLeafPage, initialPageIdsToRemove);
+  const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
 
 
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
 };
 };
@@ -574,7 +574,7 @@ export default (crowi: Crowi): any => {
   }
   }
 
 
   schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
   schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
+    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
       throw Error('Crowi is not setup');
       throw Error('Crowi is not setup');
     }
     }
 
 
@@ -584,6 +584,11 @@ export default (crowi: Crowi): any => {
       return this.createV4(path, body, user, options);
       return this.createV4(path, body, user, options);
     }
     }
 
 
+    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      throw Error(`Cannot operate create to path "${path}" right now.`);
+    }
+
     const Page = this;
     const Page = this;
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
     const {
     const {

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

@@ -0,0 +1,101 @@
+import { pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
+
+import PageOperation from '~/server/models/page-operation';
+
+const { addTrailingSlash } = pathUtils;
+const { isTrashPage } = pagePathUtils;
+
+class PageOperationService {
+
+  crowi: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    // TODO: Remove this code when resuming feature is implemented
+    PageOperation.deleteMany();
+  }
+
+  /**
+   * Check if the operation is operatable by comparing paths with all Main PageOperation documents
+   * @param fromPath The path to operate from
+   * @param toPath The path to operate to
+   * @param actionType The action type of the operation
+   * @returns Promise<boolean>
+   */
+  async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
+    const mainOps = await PageOperation.findMainOps();
+
+    if (mainOps.length === 0) {
+      return true;
+    }
+
+    const toPaths = mainOps.map(op => op.toPath).filter((p): p is string => p != null);
+
+    if (isRecursively) {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => this.isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+    else {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+
+    return true;
+  }
+
+  private isEitherOfPathAreaOverlap(path1: string, path2: string): boolean {
+    if (path1 === path2) {
+      return true;
+    }
+
+    const path1WithSlash = addTrailingSlash(path1);
+    const path2WithSlash = addTrailingSlash(path2);
+
+    const path1Area = new RegExp(`^${escapeStringRegexp(path1WithSlash)}`);
+    const path2Area = new RegExp(`^${escapeStringRegexp(path2WithSlash)}`);
+
+    if (path1Area.test(path2) || path2Area.test(path1)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  private isPathAreaOverlap(pathToTest: string, pathToBeTested: string): boolean {
+    if (pathToTest === pathToBeTested) {
+      return true;
+    }
+
+    const pathWithSlash = addTrailingSlash(pathToTest);
+
+    const pathAreaToTest = new RegExp(`^${escapeStringRegexp(pathWithSlash)}`);
+    if (pathAreaToTest.test(pathToBeTested)) {
+      return true;
+    }
+
+    return false;
+  }
+
+}
+
+export default PageOperationService;

+ 101 - 54
packages/app/src/server/service/page.ts

@@ -1,4 +1,4 @@
-import { pagePathUtils } from '@growi/core';
+import { pagePathUtils, pathUtils } from '@growi/core';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
@@ -32,6 +32,8 @@ const {
   collectAncestorPaths, isMovablePage,
   collectAncestorPaths, isMovablePage,
 } = pagePathUtils;
 } = pagePathUtils;
 
 
+const { addTrailingSlash } = pathUtils;
+
 const BULK_REINDEX_SIZE = 100;
 const BULK_REINDEX_SIZE = 100;
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 
@@ -313,20 +315,6 @@ class PageService {
     return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
     return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
   }
   }
 
 
-  /**
-   * Remove all empty pages at leaf position by page whose parent will change or which will be deleted.
-   * @param page Page whose parent will change or which will be deleted
-   */
-  async removeLeafEmptyPages(page): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    // delete leaf empty pages
-    const isSiblingsOrChildrenExist = await Page.exists({ parent: { $in: [page.parent, page._id] }, _id: { $ne: page._id } });
-    if (!isSiblingsOrChildrenExist) {
-      await Page.removeLeafEmptyPagesRecursively(page.parent);
-    }
-  }
-
   /**
   /**
    * Generate read stream to operate descendants of the specified page path
    * Generate read stream to operate descendants of the specified page path
    * @param {string} targetPagePath
    * @param {string} targetPagePath
@@ -370,6 +358,15 @@ class PageService {
       return this.renamePageV4(page, newPagePath, user, options);
       return this.renamePageV4(page, newPagePath, user, options);
     }
     }
 
 
+    if (await Page.exists({ path: newPagePath })) {
+      throw Error(`Page already exists at ${newPagePath}`);
+    }
+
+    const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, newPagePath);
+    if (!canOperate) {
+      throw Error(`Cannot operate rename to path "${newPagePath}" right now.`);
+    }
+
     /*
     /*
      * Resumable Operation
      * Resumable Operation
      */
      */
@@ -449,6 +446,10 @@ class PageService {
       update.updatedAt = new Date();
       update.updatedAt = new Date();
     }
     }
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+
+    // remove empty pages at leaf position
+    await Page.removeLeafEmptyPagesRecursively(page.parent);
+
     this.pageEvent.emit('rename', page, user);
     this.pageEvent.emit('rename', page, user);
 
 
     // Set to Sub
     // Set to Sub
@@ -726,7 +727,7 @@ class PageService {
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
       .pipe(writeStream);
 
 
-    await streamToPromise(readStream);
+    await streamToPromise(writeStream);
   }
   }
 
 
   /*
   /*
@@ -744,7 +745,7 @@ class PageService {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
 
 
-    if (isRecursively && page.isEmpty) {
+    if (!isRecursively && page.isEmpty) {
       throw Error('Page not found.');
       throw Error('Page not found.');
     }
     }
 
 
@@ -756,6 +757,11 @@ class PageService {
       return this.duplicateV4(page, newPagePath, user, isRecursively);
       return this.duplicateV4(page, newPagePath, user, isRecursively);
     }
     }
 
 
+    const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPagePath);
+    if (!canOperate) {
+      throw Error(`Cannot operate duplicate to path "${newPagePath}" right now.`);
+    }
+
     // 2. UserGroup & Owner validation
     // 2. UserGroup & Owner validation
     // use the parent's grant when target page is an empty page
     // use the parent's grant when target page is an empty page
     let grant;
     let grant;
@@ -792,6 +798,9 @@ class PageService {
       }
       }
     }
     }
 
 
+    // copy & populate (reason why copy: SubOperation only allows non-populated page document)
+    const copyPage = { ...page };
+
     // 3. Duplicate target
     // 3. Duplicate target
     const options: PageCreateOptions = {
     const options: PageCreateOptions = {
       grant: page.grant,
       grant: page.grant,
@@ -803,11 +812,9 @@ class PageService {
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     }
     else {
     else {
-      // copy & populate (reason why copy: SubOperation only allows non-populated page document)
-      const copyPage = { ...page };
-      await copyPage.populate({ path: 'revision', model: 'Revision', select: 'body' });
+      await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
       duplicatedTarget = await (Page.create as CreateMethod)(
       duplicatedTarget = await (Page.create as CreateMethod)(
-        newPagePath, copyPage.revision.body, user, options,
+        newPagePath, page.revision.body, user, options,
       );
       );
     }
     }
 
 
@@ -829,7 +836,7 @@ class PageService {
         pageOp = await PageOperation.create({
         pageOp = await PageOperation.create({
           actionType: PageActionType.Duplicate,
           actionType: PageActionType.Duplicate,
           actionStage: PageActionStage.Main,
           actionStage: PageActionStage.Main,
-          page,
+          page: copyPage,
           user,
           user,
           fromPath: page.path,
           fromPath: page.path,
           toPath: newPagePath,
           toPath: newPagePath,
@@ -1175,6 +1182,13 @@ class PageService {
       throw new Error('Page is not deletable.');
       throw new Error('Page is not deletable.');
     }
     }
 
 
+    const newPath = Page.getDeletedPageName(page.path);
+
+    const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPath);
+    if (!canOperate) {
+      throw Error(`Cannot operate delete to path "${newPath}" right now.`);
+    }
+
     // Replace with an empty page
     // Replace with an empty page
     const isChildrenExist = await Page.exists({ parent: page._id });
     const isChildrenExist = await Page.exists({ parent: page._id });
     const shouldReplace = !isRecursively && isChildrenExist;
     const shouldReplace = !isRecursively && isChildrenExist;
@@ -1202,11 +1216,9 @@ class PageService {
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
     }
     }
     // 2. Delete leaf empty pages
     // 2. Delete leaf empty pages
-    const parent = await Page.findById(page.parent);
-    await this.removeLeafEmptyPages(parent);
+    await Page.removeLeafEmptyPagesRecursively(page.parent);
 
 
     if (isRecursively) {
     if (isRecursively) {
-      const newPath = Page.getDeletedPageName(page.path);
       let pageOp;
       let pageOp;
       try {
       try {
         pageOp = await PageOperation.create({
         pageOp = await PageOperation.create({
@@ -1457,8 +1469,8 @@ class PageService {
       PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
       PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
       ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
       ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
       Revision.deleteMany({ pageId: { $in: pageIds } }),
       Revision.deleteMany({ pageId: { $in: pageIds } }),
-      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { _id: { $in: pageIds } }] }),
-      PageRedirect.deleteMany({ $or: [{ toPath: { $in: pagePaths } }] }),
+      Page.deleteMany({ _id: { $in: pageIds } }),
+      PageRedirect.deleteMany({ $or: [{ fromPath: { $in: pagePaths } }, { toPath: { $in: pagePaths } }] }),
       attachmentService.removeAllAttachments(attachments),
       attachmentService.removeAllAttachments(attachments),
     ]);
     ]);
   }
   }
@@ -1497,13 +1509,16 @@ class PageService {
       return this.deleteCompletelyV4(page, user, options, isRecursively, preventEmitting);
       return this.deleteCompletelyV4(page, user, options, isRecursively, preventEmitting);
     }
     }
 
 
+    const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, null);
+    if (!canOperate) {
+      throw Error(`Cannot operate deleteCompletely from path "${page.path}" right now.`);
+    }
+
     const ids = [page._id];
     const ids = [page._id];
     const paths = [page.path];
     const paths = [page.path];
 
 
     logger.debug('Deleting completely', paths);
     logger.debug('Deleting completely', paths);
 
 
-    await this.deleteCompletelyOperation(ids, paths);
-
     // replace with an empty page
     // replace with an empty page
     const shouldReplace = !isRecursively && await Page.exists({ parent: page._id });
     const shouldReplace = !isRecursively && await Page.exists({ parent: page._id });
     if (shouldReplace) {
     if (shouldReplace) {
@@ -1522,8 +1537,7 @@ class PageService {
     await this.deleteCompletelyOperation(ids, paths);
     await this.deleteCompletelyOperation(ids, paths);
 
 
     // delete leaf empty pages
     // delete leaf empty pages
-    const parent = await Page.findById(page.parent);
-    await this.removeLeafEmptyPages(parent);
+    await Page.removeLeafEmptyPagesRecursively(page.parent);
 
 
     if (!page.isEmpty && !preventEmitting) {
     if (!page.isEmpty && !preventEmitting) {
       this.pageEvent.emit('deleteCompletely', page, user);
       this.pageEvent.emit('deleteCompletely', page, user);
@@ -1630,7 +1644,7 @@ class PageService {
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
       .pipe(writeStream);
 
 
-    await streamToPromise(readStream);
+    await streamToPromise(writeStream);
 
 
     return nDeletedNonEmptyPages;
     return nDeletedNonEmptyPages;
   }
   }
@@ -1708,6 +1722,12 @@ class PageService {
     }
     }
 
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     const newPath = Page.getRevertDeletedPageName(page.path);
+
+    const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPath);
+    if (!canOperate) {
+      throw Error(`Cannot operate revert from path "${page.path}" right now.`);
+    }
+
     const includeEmpty = true;
     const includeEmpty = true;
     const originPage = await Page.findByPath(newPath, includeEmpty);
     const originPage = await Page.findByPath(newPath, includeEmpty);
 
 
@@ -1782,10 +1802,10 @@ class PageService {
     /*
     /*
      * Sub Operation
      * Sub Operation
      */
      */
-    await this.revertRecursivelySubOperation(page, newPath, pageOp._id);
+    await this.revertRecursivelySubOperation(newPath, pageOp._id);
   }
   }
 
 
-  async revertRecursivelySubOperation(page, newPath: string, pageOpId: ObjectIdLike): Promise<void> {
+  async revertRecursivelySubOperation(newPath: string, pageOpId: ObjectIdLike): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const newTarget = await Page.findOne({ path: newPath }); // only one page will be found since duplicating to existing path is forbidden
     const newTarget = await Page.findOne({ path: newPath }); // only one page will be found since duplicating to existing path is forbidden
@@ -1795,23 +1815,11 @@ class PageService {
     }
     }
 
 
     // update descendantCount of ancestors'
     // update descendantCount of ancestors'
-    await this.updateDescendantCountOfAncestors(page.parent, newTarget.descendantCount + 1, true);
+    await this.updateDescendantCountOfAncestors(newTarget.parent as ObjectIdLike, newTarget.descendantCount + 1, true);
 
 
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
-  async resumableRevertDeletedDescendants(page, user, options, shouldUseV4Process) {
-    const revertedDescendantCount = await this.revertDeletedDescendantsWithStream(page, user, options, shouldUseV4Process);
-
-    // update descendantCount of ancestors'
-    if (page.parent != null) {
-      await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
-
-      // delete leaf empty pages
-      await this.removeLeafEmptyPages(page);
-    }
-  }
-
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const PageTagRelation = this.crowi.model('PageTagRelation');
@@ -2060,8 +2068,9 @@ class PageService {
   }
   }
 
 
   async normalizeParentByPageIds(pageIds: ObjectIdLike[], user, isRecursively: boolean): Promise<void> {
   async normalizeParentByPageIds(pageIds: ObjectIdLike[], user, isRecursively: boolean): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
     if (isRecursively) {
     if (isRecursively) {
-      const Page = mongoose.model('Page') as unknown as PageModel;
       const pages = await Page.findByPageIdsToEdit(pageIds, user, false);
       const pages = await Page.findByPageIdsToEdit(pageIds, user, false);
 
 
       // DO NOT await !!
       // DO NOT await !!
@@ -2071,7 +2080,17 @@ class PageService {
     }
     }
 
 
     for await (const pageId of pageIds) {
     for await (const pageId of pageIds) {
+      const page = await Page.findById(pageId);
+      if (page == null) {
+        continue;
+      }
+
       try {
       try {
+        const canOperate = await this.crowi.pageOperationService.canOperate(false, page.path, page.path);
+        if (!canOperate) {
+          throw Error(`Cannot operate normalizeParent to path "${page.path}" right now.`);
+        }
+
         const normalizedPage = await this.normalizeParentByPageId(pageId, user);
         const normalizedPage = await this.normalizeParentByPageId(pageId, user);
 
 
         if (normalizedPage == null) {
         if (normalizedPage == null) {
@@ -2177,11 +2196,31 @@ class PageService {
      * Main Operation (s)
      * Main Operation (s)
      */
      */
     for await (const page of normalizablePages) {
     for await (const page of normalizablePages) {
-      await this.normalizeParentRecursivelyMainOperation(page, user);
+      const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, page.path);
+      if (!canOperate) {
+        throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
+      }
+
+      let pageOp;
+      try {
+        pageOp = await PageOperation.create({
+          actionType: PageActionType.NormalizeParent,
+          actionStage: PageActionStage.Main,
+          page,
+          user,
+          fromPath: page.path,
+          toPath: page.path,
+        });
+      }
+      catch (err) {
+        logger.error('Failed to create PageOperation document.', err);
+        throw err;
+      }
+      await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
     }
     }
   }
   }
 
 
-  async normalizeParentRecursivelyMainOperation(page, user): Promise<void> {
+  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
     // TODO: insertOne PageOperationBlock
     // TODO: insertOne PageOperationBlock
 
 
     try {
     try {
@@ -2194,10 +2233,16 @@ class PageService {
       throw err;
       throw err;
     }
     }
 
 
-    await this.normalizeParentRecursivelySubOperation(page, user);
+    // Set to Sub
+    const pageOp = await PageOperation.findByIdAndUpdatePageActionStage(pageOpId, PageActionStage.Sub);
+    if (pageOp == null) {
+      throw Error('PageOperation document not found');
+    }
+
+    await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id);
   }
   }
 
 
-  async normalizeParentRecursivelySubOperation(page, user): Promise<void> {
+  async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     try {
     try {
@@ -2217,6 +2262,8 @@ class PageService {
       logger.error('Failed to update descendantCount after normalizing parent:', err);
       logger.error('Failed to update descendantCount after normalizing parent:', err);
       throw Error(`Failed to update descendantCount after normalizing parent: ${err}`);
       throw Error(`Failed to update descendantCount after normalizing parent: ${err}`);
     }
     }
+
+    await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
   async _isPagePathIndexUnique() {
   async _isPagePathIndexUnique() {
@@ -2319,8 +2366,8 @@ class PageService {
   }
   }
 
 
   async normalizeParentRecursively(paths: string[], publicOnly = false): Promise<void> {
   async normalizeParentRecursively(paths: string[], publicOnly = false): Promise<void> {
-    const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p));
-    const regexps = paths.map(p => new RegExp(`^${escapeStringRegexp(p)}`, 'i'));
+    const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, [p]));
+    const regexps = paths.map(p => new RegExp(`^${escapeStringRegexp(addTrailingSlash(p))}`, 'i'));
 
 
     return this._normalizeParentRecursively(regexps, ancestorPaths, publicOnly);
     return this._normalizeParentRecursively(regexps, ancestorPaths, publicOnly);
   }
   }

+ 1 - 4
packages/app/test/integration/service/page.test.js

@@ -635,10 +635,7 @@ describe('PageService', () => {
       expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyPageSpy).toHaveBeenCalledWith({
-        $or: [{ path: { $in: [parentForDeleteCompletely.path] } },
-              { _id: { $in: [parentForDeleteCompletely._id] } }],
-      });
+      expect(deleteManyPageSpy).toHaveBeenCalledWith({ _id: { $in: [parentForDeleteCompletely._id] } });
       expect(removeAllAttachmentsSpy).toHaveBeenCalled();
       expect(removeAllAttachmentsSpy).toHaveBeenCalled();
     });
     });
 
 

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

@@ -1294,7 +1294,7 @@ describe('PageService page operations with only public pages', () => {
       mockedCreateAndSendNotifications.mockRestore();
       mockedCreateAndSendNotifications.mockRestore();
 
 
       if (isRecursively) {
       if (isRecursively) {
-        await crowi.pageService.resumableDeleteCompletelyDescendants(...argsForDeleteCompletelyRecursivelyMainOperation);
+        await crowi.pageService.deleteCompletelyRecursivelyMainOperation(...argsForDeleteCompletelyRecursivelyMainOperation);
       }
       }
 
 
       return;
       return;
@@ -1385,9 +1385,9 @@ describe('PageService page operations with only public pages', () => {
       expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
       expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
 
 
       await deleteCompletely(childPage, dummyUser1, {}, false);
       await deleteCompletely(childPage, dummyUser1, {}, false);
-      const parentPageAfterDelete = await Page.findOne({ path: parentPage.path });
-      const childPageAfterDelete = await Page.findOne({ path: childPage.path });
-      const grandchildPageAfterDelete = await Page.findOne({ path: grandchildPage.path });
+      const parentPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
+      const childPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
+      const grandchildPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
       const childOfDeletedPage = await Page.findOne({ parent: childPageAfterDelete._id });
       const childOfDeletedPage = await Page.findOne({ parent: childPageAfterDelete._id });
 
 
       expectAllToBeTruthy([parentPageAfterDelete, childPageAfterDelete, grandchildPageAfterDelete]);
       expectAllToBeTruthy([parentPageAfterDelete, childPageAfterDelete, grandchildPageAfterDelete]);
@@ -1403,15 +1403,15 @@ describe('PageService page operations with only public pages', () => {
   describe('revert', () => {
   describe('revert', () => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
       // mock return value
       // mock return value
-      const mockedResumableRevertDeletedDescendants = jest.spyOn(crowi.pageService, 'resumableRevertDeletedDescendants').mockReturnValue(null);
+      const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
       const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
       const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
 
 
-      const argsForResumableRevertDeletedDescendants = mockedResumableRevertDeletedDescendants.mock.calls[0];
+      const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
 
       // restores the original implementation
       // restores the original implementation
-      mockedResumableRevertDeletedDescendants.mockRestore();
+      mockedRevertRecursivelyMainOperation.mockRestore();
       if (isRecursively) {
       if (isRecursively) {
-        await crowi.pageService.resumableRevertDeletedDescendants(...argsForResumableRevertDeletedDescendants);
+        await crowi.pageService.revertRecursivelyMainOperation(...argsForRecursivelyMainOperation);
       }
       }
 
 
       return revertedPage;
       return revertedPage;