Taichi Masuyama 4 سال پیش
والد
کامیت
841ed150fc

+ 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 },

+ 16 - 1
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.');
+  }
 };
 
 /**
@@ -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

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

@@ -797,7 +797,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 +810,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) {

+ 82 - 32
packages/app/src/server/service/page.ts

@@ -1808,10 +1808,21 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
 
-  async normalizeParentByPageIds(pageIds: ObjectIdLike[]): Promise<void> {
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
     for await (const pageId of pageIds) {
       try {
-        await this.normalizeParentByPageId(pageId);
+        await this.normalizeParentByPageId(pageId, user);
+
+        const normalizedPage = await Page.findById(pageId);
+        if (typeof normalizedPage?.descendantCount !== 'number') {
+          logger.error(`Failed to update descendantCount of page "${normalizedPage?.path}"`);
+        }
+        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 +1830,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
      */
@@ -1855,52 +1872,57 @@ class PageService {
     // getParentAndFillAncestors
     const parent = await Page.getParentAndFillAncestors(target.path);
 
-    return Page.updateOne({ _id: pageId }, { parent: parent._id });
+    const updatedPage = await Page.updateOne({ _id: pageId }, { parent: parent._id }, { new: true });
+
+    // replace if empty page exists
+    if (existingPage != null && existingPage.isEmpty) {
+      await Page.replaceTargetWithPage(existingPage, updatedPage, true);
+    }
   }
 
+  // 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;
     }
 
-    const [normalizedIds, notNormalizedPaths] = await this.crowi.pageGrantService.separateNormalizedAndNonNormalizedPages(pageIds);
+    const [normalizedPages, nonNormalizedPages] = await this.crowi.pageGrantService.separateNormalizedAndNonNormalizedPages(pageIds);
 
-    if (normalizedIds.length === 0) {
+    if (normalizedPages.length === 0) {
       // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
       return;
     }
 
-    if (notNormalizedPaths.length !== 0) {
+    if (nonNormalizedPages.length !== 0) {
       // TODO: iterate notNormalizedPaths and send socket error to client so that the user can know which path failed to migrate
       // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
     }
 
-    /*
-     * generate regexps
-     */
-    const 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)}`));
+    let pathsToNormalize = normalizedPages.map(p => p.path);
+    pathsToNormalize = omitDuplicateAreaPathFromPaths(pathsToNormalize);
 
     // TODO: insertMany PageOperationBlock
 
+    const pageIdsToUpdateDescendantCount = nonNormalizedPages
+      .map((p): ObjectIdLike | undefined => (pathsToNormalize.includes(p.path) ? p._id : null))
+      .filter(id => id != null);
+
+    // for updating descendantCount
+    const pageIdToExDescendantCount = new Map<ObjectIdLike, number>();
+
     // migrate recursively
     try {
-      await this.normalizeParentRecursively(null, regexps);
+      for await (const path of pathsToNormalize) {
+        await this.normalizeParentRecursively(null, [new RegExp(`^${escapeStringRegexp(path)}`, 'i')]);
+      }
+      const pagesBeforeUpdatingDescendantCount = await Page.findByIdsAndViewer(pageIds, user);
+      pagesBeforeUpdatingDescendantCount.forEach((p) => {
+        pageIdToExDescendantCount.set(p._id.toString(), p.descendantCount);
+      });
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -1908,6 +1930,23 @@ class PageService {
 
       throw err;
     }
+
+    // update descendantCount
+    try {
+      for await (const path of pathsToNormalize) {
+        await this.updateDescendantCountOfSelfAndDescendants(path);
+      }
+
+      const pagesAfterUpdatingDescendantCount = await Page.findByIdsAndViewer(pageIdsToUpdateDescendantCount, user);
+      for await (const page of pagesAfterUpdatingDescendantCount) {
+        const inc = (page.descendantCount) - (pageIdToExDescendantCount.get(page._id.toString()) || 0);
+        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() {
@@ -2187,12 +2226,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;
   }
 
   /**
@@ -2200,7 +2250,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');
 

+ 3 - 2
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;
   });