page-operation.ts 7.0 KB

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