page-operation.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import { pagePathUtils } from '@growi/core/dist/utils';
  2. import type { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
  3. import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
  4. import type { PageOperationDocument } from '~/server/models/page-operation';
  5. import PageOperation from '~/server/models/page-operation';
  6. import loggerFactory from '~/utils/logger';
  7. import type Crowi from '../crowi';
  8. import type { ObjectIdLike } from '../interfaces/mongoose-utils';
  9. import { collectAncestorPaths } from '../util/collect-ancestor-paths';
  10. const logger = loggerFactory('growi:services:page-operation');
  11. const {
  12. isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage,
  13. } = pagePathUtils;
  14. const AUTO_UPDATE_INTERVAL_SEC = 5;
  15. const {
  16. Create, Update,
  17. Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
  18. } = PageActionType;
  19. class PageOperationService {
  20. crowi: Crowi;
  21. constructor(crowi: Crowi) {
  22. this.crowi = crowi;
  23. }
  24. async init(): Promise<void> {
  25. // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
  26. const types = [Create, Update, Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
  27. await PageOperation.deleteByActionTypes(types);
  28. await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
  29. }
  30. /**
  31. * Execute functions that should be run after the express server is ready.
  32. */
  33. async afterExpressServerReady(): Promise<void> {
  34. try {
  35. const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
  36. .sort({ createdAt: 'asc' });
  37. // execute rename operation
  38. await this.executeAllRenameOperationBySystem(pageOps);
  39. }
  40. catch (err) {
  41. logger.error(err);
  42. }
  43. }
  44. /**
  45. * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
  46. */
  47. private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
  48. if (pageOps.length === 0) return;
  49. const Page = this.crowi.model('Page');
  50. for await (const pageOp of pageOps) {
  51. const renamedPage = await Page.findById(pageOp.page._id);
  52. if (renamedPage == null) {
  53. logger.warn('operating page is not found');
  54. continue;
  55. }
  56. // rename
  57. await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
  58. }
  59. }
  60. /**
  61. * Check if the operation is operatable
  62. * @param isRecursively Boolean that determines whether the operation is recursive or not
  63. * @param fromPathToOp The path to operate from
  64. * @param toPathToOp The path to operate to
  65. * @returns boolean
  66. */
  67. async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
  68. const pageOperations = await PageOperation.find();
  69. if (pageOperations.length === 0) {
  70. return true;
  71. }
  72. const fromPaths = pageOperations.map(op => op.fromPath).filter((p): p is string => p != null);
  73. const toPaths = pageOperations.map(op => op.toPath).filter((p): p is string => p != null);
  74. if (isRecursively) {
  75. if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
  76. const fromFlag = fromPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
  77. if (fromFlag) return false;
  78. const toFlag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
  79. if (toFlag) return false;
  80. }
  81. if (toPathToOp != null && !isTrashPage(toPathToOp)) {
  82. const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
  83. if (fromFlag) return false;
  84. const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
  85. if (toFlag) return false;
  86. }
  87. }
  88. else {
  89. if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
  90. const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
  91. if (fromFlag) return false;
  92. const toFlag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
  93. if (toFlag) return false;
  94. }
  95. if (toPathToOp != null && !isTrashPage(toPathToOp)) {
  96. const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
  97. if (fromFlag) return false;
  98. const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
  99. if (toFlag) return false;
  100. }
  101. }
  102. return true;
  103. }
  104. /**
  105. * Generate object that connects page id with processData of PageOperation.
  106. * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
  107. */
  108. generateProcessInfo(pageOps: PageOperationDocument[]): IPageOperationProcessInfo {
  109. const processInfo: IPageOperationProcessInfo = {};
  110. pageOps.forEach((pageOp) => {
  111. const pageId = pageOp.page._id.toString();
  112. const actionType = pageOp.actionType;
  113. const isProcessable = pageOp.isProcessable();
  114. // processData for processInfo
  115. const mainProcessableInfo = pageOp.actionStage === PageActionStage.Main ? { isProcessable } : undefined;
  116. const subProcessableInfo = pageOp.actionStage === PageActionStage.Sub ? { isProcessable } : undefined;
  117. const processData: IPageOperationProcessData = {
  118. [actionType]: {
  119. [PageActionStage.Main]: mainProcessableInfo,
  120. [PageActionStage.Sub]: subProcessableInfo,
  121. },
  122. };
  123. // Merge processData if other processData exist
  124. if (processInfo[pageId] != null) {
  125. const otherProcessData = processInfo[pageId];
  126. processInfo[pageId] = Object.assign(otherProcessData, processData);
  127. return;
  128. }
  129. // add new process data to processInfo
  130. processInfo[pageId] = processData;
  131. });
  132. return processInfo;
  133. }
  134. /**
  135. * Set interval to update unprocessableExpiryDate every AUTO_UPDATE_INTERVAL_SEC seconds.
  136. * This is used to prevent the same page operation from being processed multiple times at once
  137. */
  138. autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout {
  139. // https://github.com/Microsoft/TypeScript/issues/30128#issuecomment-651877225
  140. const timerObj = global.setInterval(async() => {
  141. await PageOperation.extendExpiryDate(operationId);
  142. }, AUTO_UPDATE_INTERVAL_SEC * 1000);
  143. return timerObj;
  144. }
  145. clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void {
  146. clearInterval(timerObj);
  147. }
  148. /**
  149. * Get ancestor's paths using fromPath and toPath. Merge same paths if any.
  150. */
  151. getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[] {
  152. const fromAncestorsPaths = collectAncestorPaths(fromPath);
  153. const toAncestorsPaths = collectAncestorPaths(toPath);
  154. // merge duplicate paths and return paths of ancestors
  155. return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
  156. }
  157. async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
  158. const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
  159. return PageOperation.findOne(filter);
  160. }
  161. }
  162. export default PageOperationService;