page.ts 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492
  1. import { GroupType, type HasObjectId, type IPage } from '@growi/core';
  2. import type {
  3. IPagePopulatedToShowRevision,
  4. IUserHasId,
  5. } from '@growi/core/dist/interfaces';
  6. import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
  7. import { hasSlash, isTopPage } from '@growi/core/dist/utils/page-path-utils';
  8. import {
  9. addTrailingSlash,
  10. normalizePath,
  11. } from '@growi/core/dist/utils/path-utils';
  12. import assert from 'assert';
  13. import type mongoose from 'mongoose';
  14. import type {
  15. AnyObject,
  16. Document,
  17. HydratedDocument,
  18. Model,
  19. Types,
  20. } from 'mongoose';
  21. import { Schema } from 'mongoose';
  22. import mongoosePaginate from 'mongoose-paginate-v2';
  23. import uniqueValidator from 'mongoose-unique-validator';
  24. import nodePath from 'path';
  25. import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
  26. import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
  27. import type {
  28. IOptionsForCreate,
  29. IPagePathWithDescendantCount,
  30. } from '~/interfaces/page';
  31. import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
  32. import loggerFactory from '../../utils/logger';
  33. import type Crowi from '../crowi';
  34. import { collectAncestorPaths } from '../util/collect-ancestor-paths';
  35. import { getOrCreateModel } from '../util/mongoose-utils';
  36. import {
  37. extractToAncestorsPaths,
  38. getPageSchema,
  39. populateDataToShowRevision,
  40. } from './obsolete-page';
  41. import { USER_FIELDS_EXCEPT_CONFIDENTIAL } from './user/conts';
  42. import type { UserGroupDocument } from './user-group';
  43. import UserGroupRelation from './user-group-relation';
  44. type ObjectId = mongoose.Types.ObjectId;
  45. const logger = loggerFactory('growi:models:page');
  46. /*
  47. * define schema
  48. */
  49. const GRANT_PUBLIC = 1;
  50. const GRANT_RESTRICTED = 2;
  51. const GRANT_SPECIFIED = 3; // DEPRECATED
  52. const GRANT_OWNER = 4;
  53. const GRANT_USER_GROUP = 5;
  54. const PAGE_GRANT_ERROR = 1;
  55. const STATUS_PUBLISHED = 'published';
  56. const STATUS_DELETED = 'deleted';
  57. export interface PageDocument extends IPage, Document<Types.ObjectId> {
  58. [x: string]: any; // for obsolete methods
  59. getLatestRevisionBodyLength(): Promise<number | null | undefined>;
  60. calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>;
  61. populateDataToShowRevision(
  62. shouldExcludeBody?: boolean,
  63. ): Promise<IPagePopulatedToShowRevision & PageDocument>;
  64. }
  65. type TargetAndAncestorsResult = {
  66. targetAndAncestors: PageDocument[];
  67. rootPage: PageDocument;
  68. };
  69. type PaginatedPages = {
  70. pages: PageDocument[];
  71. totalCount: number;
  72. limit: number;
  73. offset: number;
  74. };
  75. export type FindRecentUpdatedPagesOption = {
  76. offset: number;
  77. limit: number;
  78. includeWipPage: boolean;
  79. includeTrashed: boolean;
  80. isRegExpEscapedFromPath: boolean;
  81. sort: 'updatedAt';
  82. desc: number;
  83. hideRestrictedByOwner: boolean;
  84. hideRestrictedByGroup: boolean;
  85. disableUserPages: boolean;
  86. };
  87. export type CreateMethod = (
  88. path: string,
  89. body: string,
  90. user,
  91. options: IOptionsForCreate,
  92. ) => Promise<HydratedDocument<PageDocument>>;
  93. type FindByPathAndViewerMethod = (
  94. this: PageModel,
  95. id: string | ObjectId,
  96. user,
  97. userGroups?,
  98. includeEmpty?: boolean,
  99. ) => Promise<HydratedDocument<PageDocument> | null>;
  100. type CountByPathAndViewerMethod = (
  101. this: PageModel,
  102. id: string | ObjectId,
  103. user,
  104. userGroups?,
  105. includeEmpty?: boolean,
  106. ) => Promise<number>;
  107. export interface PageModel extends Model<PageDocument> {
  108. [x: string]: any; // for obsolete static methods
  109. createEmptyPage(
  110. path: string,
  111. parent,
  112. descendantCount?: number,
  113. ): Promise<HydratedDocument<PageDocument>>;
  114. findByIdAndViewer: FindByPathAndViewerMethod;
  115. countByIdAndViewer: CountByPathAndViewerMethod;
  116. findByIdsAndViewer(
  117. pageIds: ObjectIdLike[],
  118. user,
  119. userGroups?,
  120. includeEmpty?: boolean,
  121. includeAnyoneWithTheLink?: boolean,
  122. ): Promise<HydratedDocument<PageDocument>[]>;
  123. findByPath(
  124. path: string,
  125. includeEmpty?: boolean,
  126. ): Promise<HydratedDocument<PageDocument> | null>;
  127. findByPathAndViewer(
  128. path: string | null,
  129. user,
  130. userGroups?,
  131. useFindOne?: true,
  132. includeEmpty?: boolean,
  133. ): Promise<HydratedDocument<PageDocument> | null>;
  134. findByPathAndViewer(
  135. path: string | null,
  136. user,
  137. userGroups?,
  138. useFindOne?: false,
  139. includeEmpty?: boolean,
  140. ): Promise<HydratedDocument<PageDocument>[]>;
  141. descendantCountByPaths(
  142. paths: string[],
  143. user: IUserHasId,
  144. userGroups?,
  145. includeEmpty?: boolean,
  146. includeAnyoneWithTheLink?: boolean,
  147. ): Promise<IPagePathWithDescendantCount[]>;
  148. findParentByPath(
  149. path: string | null,
  150. ): Promise<HydratedDocument<PageDocument> | null>;
  151. findTargetAndAncestorsByPathOrId(
  152. pathOrId: string,
  153. ): Promise<TargetAndAncestorsResult>;
  154. findRecentUpdatedPages(
  155. path: string,
  156. user,
  157. option: FindRecentUpdatedPagesOption,
  158. includeEmpty?: boolean,
  159. ): Promise<PaginatedPages>;
  160. generateGrantCondition(
  161. user,
  162. userGroups: ObjectIdLike[] | null,
  163. includeAnyoneWithTheLink?: boolean,
  164. showPagesRestrictedByOwner?: boolean,
  165. showPagesRestrictedByGroup?: boolean,
  166. ): { $or: any[] };
  167. findNonEmptyClosestAncestor(
  168. path: string,
  169. ): Promise<HydratedDocument<PageDocument> | null>;
  170. findNotEmptyParentByPathRecursively(
  171. path: string,
  172. ): Promise<HydratedDocument<PageDocument> | null>;
  173. removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>;
  174. findTemplate(path: string): Promise<{
  175. templateBody?: string;
  176. templateTags?: string[];
  177. }>;
  178. removeGroupsToDeleteFromPages(
  179. pages: PageDocument[],
  180. groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
  181. ): Promise<void>;
  182. PageQueryBuilder: typeof PageQueryBuilder;
  183. GRANT_PUBLIC;
  184. GRANT_RESTRICTED;
  185. GRANT_SPECIFIED;
  186. GRANT_OWNER;
  187. GRANT_USER_GROUP;
  188. PAGE_GRANT_ERROR;
  189. STATUS_PUBLISHED;
  190. STATUS_DELETED;
  191. }
  192. const schema = new Schema<PageDocument, PageModel>(
  193. {
  194. parent: {
  195. type: Schema.Types.ObjectId,
  196. ref: 'Page',
  197. index: true,
  198. default: null,
  199. },
  200. descendantCount: { type: Number, default: 0 },
  201. isEmpty: { type: Boolean, default: false },
  202. path: {
  203. type: String,
  204. required: true,
  205. index: true,
  206. },
  207. revision: { type: Schema.Types.ObjectId, ref: 'Revision' },
  208. latestRevisionBodyLength: { type: Number },
  209. status: { type: String, default: STATUS_PUBLISHED, index: true },
  210. grant: { type: Number, default: GRANT_PUBLIC, index: true },
  211. grantedUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  212. grantedGroups: {
  213. type: [
  214. {
  215. type: {
  216. type: String,
  217. enum: Object.values(GroupType),
  218. required: true,
  219. default: 'UserGroup',
  220. },
  221. item: {
  222. type: Schema.Types.ObjectId,
  223. refPath: 'grantedGroups.type',
  224. required: true,
  225. index: true,
  226. },
  227. },
  228. ],
  229. validate: [
  230. (arr) => {
  231. if (arr == null) return true;
  232. const uniqueItemValues = new Set(arr.map((e) => e.item));
  233. return arr.length === uniqueItemValues.size;
  234. },
  235. 'grantedGroups contains non unique item',
  236. ],
  237. default: [],
  238. required: true,
  239. },
  240. creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
  241. lastUpdateUser: { type: Schema.Types.ObjectId, ref: 'User' },
  242. liker: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  243. seenUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  244. commentCount: { type: Number, default: 0 },
  245. expandContentWidth: { type: Boolean },
  246. wip: { type: Boolean },
  247. ttlTimestamp: { type: Date },
  248. updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
  249. deleteUser: { type: Schema.Types.ObjectId, ref: 'User' },
  250. deletedAt: { type: Date },
  251. },
  252. {
  253. timestamps: { createdAt: true, updatedAt: false },
  254. toJSON: { getters: true },
  255. toObject: { getters: true },
  256. },
  257. );
  258. // indexes
  259. schema.index({ createdAt: 1 });
  260. schema.index({ updatedAt: 1 });
  261. // apply plugins
  262. schema.plugin(mongoosePaginate);
  263. schema.plugin(uniqueValidator);
  264. export class PageQueryBuilder {
  265. query: any;
  266. constructor(query, includeEmpty = false) {
  267. this.query = query;
  268. if (!includeEmpty) {
  269. this.query = this.query.and({
  270. $or: [
  271. { isEmpty: false },
  272. { isEmpty: null }, // for v4 compatibility
  273. ],
  274. });
  275. }
  276. }
  277. /**
  278. * Used for filtering the pages at specified paths not to include unintentional pages.
  279. * @param pathsToFilter The paths to have additional filters as to be applicable
  280. * @returns PageQueryBuilder
  281. */
  282. addConditionToFilterByApplicableAncestors(
  283. pathsToFilter: string[],
  284. ): PageQueryBuilder {
  285. this.query = this.query.and({
  286. $or: [
  287. { path: '/' },
  288. {
  289. path: { $in: pathsToFilter },
  290. grant: GRANT_PUBLIC,
  291. status: STATUS_PUBLISHED,
  292. },
  293. {
  294. path: { $in: pathsToFilter },
  295. parent: { $ne: null },
  296. status: STATUS_PUBLISHED,
  297. },
  298. { path: { $nin: pathsToFilter }, status: STATUS_PUBLISHED },
  299. ],
  300. });
  301. return this;
  302. }
  303. addConditionToExcludeTrashed(): PageQueryBuilder {
  304. this.query = this.query.and({
  305. $or: [{ status: null }, { status: STATUS_PUBLISHED }],
  306. });
  307. return this;
  308. }
  309. addConditionToExcludeWipPage(): PageQueryBuilder {
  310. this.query = this.query.and({
  311. $or: [{ wip: undefined }, { wip: false }],
  312. });
  313. return this;
  314. }
  315. /**
  316. * generate the query to find the pages '{path}/*' and '{path}' self.
  317. * If top page, return without doing anything.
  318. */
  319. addConditionToListWithDescendants(path: string, option?): PageQueryBuilder {
  320. // No request is set for the top page
  321. if (isTopPage(path)) {
  322. return this;
  323. }
  324. const pathNormalized = normalizePath(path);
  325. const pathWithTrailingSlash = addTrailingSlash(path);
  326. const startsPattern = RegExp.escape(pathWithTrailingSlash);
  327. this.query = this.query.and({
  328. $or: [
  329. { path: pathNormalized },
  330. { path: new RegExp(`^${startsPattern}`) },
  331. ],
  332. });
  333. return this;
  334. }
  335. /**
  336. * generate the query to find the pages '{path}/*' (exclude '{path}' self).
  337. */
  338. addConditionToListOnlyDescendants(path: string): PageQueryBuilder {
  339. // exclude the target page
  340. this.query = this.query.and({ path: { $ne: path } });
  341. if (isTopPage(path)) {
  342. return this;
  343. }
  344. const pathWithTrailingSlash = addTrailingSlash(path);
  345. const startsPattern = RegExp.escape(pathWithTrailingSlash);
  346. this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
  347. return this;
  348. }
  349. addConditionToListOnlyAncestors(path: string): PageQueryBuilder {
  350. const pathNormalized = normalizePath(path);
  351. const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
  352. this.query = this.query
  353. // exclude the target page
  354. .and({ path: { $ne: path } })
  355. .and({ path: { $in: ancestorsPaths } });
  356. return this;
  357. }
  358. /**
  359. * generate the query to find pages that start with `path`
  360. *
  361. * In normal case, returns '{path}/*' and '{path}' self.
  362. * If top page, return without doing anything.
  363. *
  364. * *option*
  365. * Left for backward compatibility
  366. */
  367. addConditionToListByStartWith(str: string): PageQueryBuilder {
  368. const path = normalizePath(str);
  369. // No request is set for the top page
  370. if (isTopPage(path)) {
  371. return this;
  372. }
  373. const startsPattern = RegExp.escape(path);
  374. this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
  375. return this;
  376. }
  377. addConditionToListByNotStartWith(str: string): PageQueryBuilder {
  378. const path = normalizePath(str);
  379. // No request is set for the top page
  380. if (isTopPage(path)) {
  381. return this;
  382. }
  383. const startsPattern = RegExp.escape(str);
  384. this.query = this.query.and({
  385. path: new RegExp(`^(?!${startsPattern}).*$`),
  386. });
  387. return this;
  388. }
  389. addConditionToListByNotMatchPathAndChildren(str: string): PageQueryBuilder {
  390. const path = normalizePath(str);
  391. if (isTopPage(path)) {
  392. return this;
  393. }
  394. const startsPattern = RegExp.escape(path);
  395. this.query = this.query.and({
  396. path: { $not: new RegExp(`^${startsPattern}(/|$)`) },
  397. });
  398. return this;
  399. }
  400. addConditionToListByMatch(str: string): PageQueryBuilder {
  401. // No request is set for "/"
  402. if (str === '/') {
  403. return this;
  404. }
  405. const match = RegExp.escape(str);
  406. this.query = this.query.and({ path: new RegExp(`^(?=.*${match}).*$`) });
  407. return this;
  408. }
  409. addConditionToListByNotMatch(str: string): PageQueryBuilder {
  410. // No request is set for "/"
  411. if (str === '/') {
  412. return this;
  413. }
  414. const match = RegExp.escape(str);
  415. this.query = this.query.and({ path: new RegExp(`^(?!.*${match}).*$`) });
  416. return this;
  417. }
  418. async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
  419. // determine UserGroup condition
  420. const userGroups =
  421. user != null
  422. ? [
  423. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  424. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  425. user,
  426. )),
  427. ]
  428. : null;
  429. const grantConditions: any[] = [{ grant: null }, { grant: GRANT_PUBLIC }];
  430. if (user != null) {
  431. grantConditions.push({ grant: GRANT_OWNER, grantedUsers: user._id });
  432. }
  433. if (userGroups != null && userGroups.length > 0) {
  434. grantConditions.push({
  435. grant: GRANT_USER_GROUP,
  436. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  437. });
  438. }
  439. this.query = this.query.and({
  440. $or: grantConditions,
  441. });
  442. return this;
  443. }
  444. async addConditionAsMigratablePages(user): Promise<PageQueryBuilder> {
  445. this.query = this.query.and({
  446. $or: [
  447. { grant: { $ne: GRANT_RESTRICTED } },
  448. { grant: { $ne: GRANT_SPECIFIED } },
  449. ],
  450. });
  451. this.addConditionAsRootOrNotOnTree();
  452. this.addConditionAsNonRootPage();
  453. this.addConditionToExcludeTrashed();
  454. await this.addConditionForParentNormalization(user);
  455. return this;
  456. }
  457. // add viewer condition to PageQueryBuilder instance
  458. async addViewerCondition(
  459. user,
  460. userGroups = null,
  461. includeAnyoneWithTheLink = false,
  462. showPagesRestrictedByOwner = false,
  463. showPagesRestrictedByGroup = false,
  464. ): Promise<PageQueryBuilder> {
  465. const relatedUserGroups =
  466. user != null && userGroups == null
  467. ? [
  468. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  469. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  470. user,
  471. )),
  472. ]
  473. : userGroups;
  474. this.addConditionToFilteringByViewer(
  475. user,
  476. relatedUserGroups,
  477. includeAnyoneWithTheLink,
  478. showPagesRestrictedByOwner,
  479. showPagesRestrictedByGroup,
  480. );
  481. return this;
  482. }
  483. addConditionToFilteringByViewer(
  484. user,
  485. userGroups: ObjectIdLike[] | null,
  486. includeAnyoneWithTheLink = false,
  487. showPagesRestrictedByOwner = false,
  488. showPagesRestrictedByGroup = false,
  489. ): PageQueryBuilder {
  490. const condition = generateGrantCondition(
  491. user,
  492. userGroups,
  493. includeAnyoneWithTheLink,
  494. showPagesRestrictedByOwner,
  495. showPagesRestrictedByGroup,
  496. );
  497. this.query = this.query.and(condition);
  498. return this;
  499. }
  500. addConditionForSystemDeletion(): PageQueryBuilder {
  501. const condition = generateGrantConditionForSystemDeletion();
  502. this.query = this.query.and(condition);
  503. return this;
  504. }
  505. addConditionToPagenate(offset, limit, sortOpt?): PageQueryBuilder {
  506. this.query = this.query.sort(sortOpt).skip(offset).limit(limit);
  507. return this;
  508. }
  509. addConditionAsNonRootPage(): PageQueryBuilder {
  510. this.query = this.query.and({ path: { $ne: '/' } });
  511. return this;
  512. }
  513. addConditionAsRootOrNotOnTree(): PageQueryBuilder {
  514. this.query = this.query.and({ parent: null });
  515. return this;
  516. }
  517. addConditionAsOnTree(): PageQueryBuilder {
  518. this.query = this.query.and({
  519. $or: [{ parent: { $ne: null } }, { path: '/' }],
  520. });
  521. return this;
  522. }
  523. /*
  524. * Add this condition when get any ancestor pages including the target's parent
  525. */
  526. addConditionToSortPagesByDescPath(): PageQueryBuilder {
  527. this.query = this.query.sort('-path');
  528. return this;
  529. }
  530. addConditionToSortPagesByAscPath(): PageQueryBuilder {
  531. this.query = this.query.sort('path');
  532. return this;
  533. }
  534. addConditionToMinimizeDataForRendering(): PageQueryBuilder {
  535. this.query = this.query.select(
  536. '_id path isEmpty grant revision descendantCount',
  537. );
  538. return this;
  539. }
  540. addConditionToListByPathsArray(paths): PageQueryBuilder {
  541. this.query = this.query.and({
  542. path: {
  543. $in: paths,
  544. },
  545. });
  546. return this;
  547. }
  548. addConditionToListByPageIdsArray(pageIds): PageQueryBuilder {
  549. this.query = this.query.and({
  550. _id: {
  551. $in: pageIds,
  552. },
  553. });
  554. return this;
  555. }
  556. addConditionToExcludeByPageIdsArray(pageIds): PageQueryBuilder {
  557. this.query = this.query.and({
  558. _id: {
  559. $nin: pageIds,
  560. },
  561. });
  562. return this;
  563. }
  564. populateDataToList(userPublicFields): PageQueryBuilder {
  565. // biome-ignore lint/plugin: populating is the purpose of this method
  566. this.query = this.query.populate({
  567. path: 'lastUpdateUser',
  568. select: userPublicFields,
  569. });
  570. return this;
  571. }
  572. populateDataToShowRevision(userPublicFields): PageQueryBuilder {
  573. this.query = populateDataToShowRevision(this.query, userPublicFields);
  574. return this;
  575. }
  576. addConditionToFilteringByParentId(parentId): PageQueryBuilder {
  577. this.query = this.query.and({ parent: parentId });
  578. return this;
  579. }
  580. }
  581. schema.statics.createEmptyPage = async function (
  582. path: string,
  583. parent: any,
  584. descendantCount = 0,
  585. ): Promise<HydratedDocument<PageDocument>> {
  586. if (parent == null) {
  587. throw Error('parent must not be null');
  588. }
  589. const page = new this();
  590. page.path = path;
  591. page.isEmpty = true;
  592. page.parent = parent;
  593. page.descendantCount = descendantCount;
  594. return page.save();
  595. };
  596. const findByIdAndViewer: FindByPathAndViewerMethod = async function (
  597. this,
  598. id,
  599. user,
  600. userGroups = null,
  601. includeEmpty = false,
  602. ) {
  603. const baseQuery = this.findOne({ _id: id });
  604. const relatedUserGroups =
  605. user != null && userGroups == null
  606. ? [
  607. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  608. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  609. user,
  610. )),
  611. ]
  612. : userGroups;
  613. const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
  614. queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
  615. return queryBuilder.query.exec();
  616. };
  617. schema.statics.findByIdAndViewer = findByIdAndViewer;
  618. const countByIdAndViewer: CountByPathAndViewerMethod = async function (
  619. this,
  620. id,
  621. user,
  622. userGroups = null,
  623. includeEmpty = false,
  624. ) {
  625. const baseQuery = this.countDocuments({ _id: id });
  626. const relatedUserGroups =
  627. user != null && userGroups == null
  628. ? [
  629. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  630. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  631. user,
  632. )),
  633. ]
  634. : userGroups;
  635. const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
  636. queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
  637. return queryBuilder.query.exec();
  638. };
  639. schema.statics.countByIdAndViewer = countByIdAndViewer;
  640. /**
  641. * Replace an existing page with an empty page.
  642. * It updates the children's parent to the new empty page's _id.
  643. * @param exPage a page document to be replaced
  644. * @returns Promise<void>
  645. */
  646. schema.statics.replaceTargetWithPage = async function (
  647. exPage,
  648. pageToReplaceWith?,
  649. deleteExPageIfEmpty = false,
  650. ) {
  651. // find parent
  652. const parent = await this.findOne({ _id: exPage.parent });
  653. if (parent == null) {
  654. throw Error('parent to update does not exist. Prepare parent first.');
  655. }
  656. // create empty page at path
  657. const newTarget =
  658. pageToReplaceWith == null
  659. ? await this.createEmptyPage(exPage.path, parent, exPage.descendantCount)
  660. : pageToReplaceWith;
  661. // find children by ex-page _id
  662. const children = await this.find({ parent: exPage._id });
  663. // bulkWrite
  664. const operationForNewTarget = {
  665. updateOne: {
  666. filter: { _id: newTarget._id },
  667. update: {
  668. parent: parent._id,
  669. },
  670. },
  671. };
  672. const operationsForChildren = {
  673. updateMany: {
  674. filter: {
  675. _id: { $in: children.map((d) => d._id) },
  676. },
  677. update: {
  678. parent: newTarget._id,
  679. },
  680. },
  681. };
  682. await this.bulkWrite([operationForNewTarget, operationsForChildren]);
  683. const isExPageEmpty = exPage.isEmpty;
  684. if (deleteExPageIfEmpty && isExPageEmpty) {
  685. await this.deleteOne({ _id: exPage._id });
  686. logger.warn('Deleted empty page since it was replaced with another page.');
  687. }
  688. return this.findById(newTarget._id);
  689. };
  690. /*
  691. * Find pages by ID and viewer.
  692. */
  693. schema.statics.findByIdsAndViewer = async function (
  694. pageIds: string[],
  695. user,
  696. userGroups?,
  697. includeEmpty?: boolean,
  698. includeAnyoneWithTheLink?: boolean,
  699. ): Promise<PageDocument[]> {
  700. const baseQuery = this.find({ _id: { $in: pageIds } });
  701. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  702. await queryBuilder.addViewerCondition(
  703. user,
  704. userGroups,
  705. includeAnyoneWithTheLink,
  706. );
  707. return queryBuilder.query.exec();
  708. };
  709. /*
  710. * Find a page by path and viewer. Pass true to useFindOne to use findOne method.
  711. */
  712. schema.statics.findByPathAndViewer = async function (
  713. path: string | null,
  714. user,
  715. userGroups = null,
  716. useFindOne = false,
  717. includeEmpty = false,
  718. ): Promise<((PageDocument | PageDocument[]) & HasObjectId) | null> {
  719. if (path == null) {
  720. throw new Error('path is required.');
  721. }
  722. const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
  723. const includeAnyoneWithTheLink = useFindOne;
  724. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  725. await queryBuilder.addViewerCondition(
  726. user,
  727. userGroups,
  728. includeAnyoneWithTheLink,
  729. );
  730. return queryBuilder.query.exec();
  731. };
  732. schema.statics.descendantCountByPaths = async function (
  733. paths: string[],
  734. user: IUserHasId,
  735. userGroups = null,
  736. includeEmpty = false,
  737. includeAnyoneWithTheLink = false,
  738. ): Promise<IPagePathWithDescendantCount[]> {
  739. if (paths.length === 0) {
  740. throw new Error('paths are required');
  741. }
  742. const baseQuery = this.find({ path: { $in: paths } });
  743. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  744. await queryBuilder.addViewerCondition(
  745. user,
  746. userGroups,
  747. includeAnyoneWithTheLink,
  748. );
  749. const conditions = queryBuilder.query._conditions;
  750. const aggregationPipeline = [
  751. {
  752. $match: conditions,
  753. },
  754. {
  755. $project: {
  756. _id: 0,
  757. path: 1,
  758. descendantCount: 1,
  759. },
  760. },
  761. {
  762. $group: {
  763. _id: '$path',
  764. descendantCount: { $first: '$descendantCount' },
  765. },
  766. },
  767. {
  768. $project: {
  769. _id: 0,
  770. path: '$_id',
  771. descendantCount: 1,
  772. },
  773. },
  774. ];
  775. const pages =
  776. await this.aggregate<IPagePathWithDescendantCount>(aggregationPipeline);
  777. return pages;
  778. };
  779. schema.statics.countByPathAndViewer = async function (
  780. path: string | null,
  781. user,
  782. userGroups = null,
  783. includeEmpty = false,
  784. ): Promise<number> {
  785. if (path == null) {
  786. throw new Error('path is required.');
  787. }
  788. const baseQuery = this.count({ path });
  789. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  790. await queryBuilder.addViewerCondition(user, userGroups);
  791. return queryBuilder.query.exec();
  792. };
  793. schema.statics.findRecentUpdatedPages = async function (
  794. path: string,
  795. user,
  796. options: FindRecentUpdatedPagesOption,
  797. includeEmpty = false,
  798. ): Promise<PaginatedPages> {
  799. const sortOpt = {};
  800. sortOpt[options.sort] = options.desc;
  801. if (path == null) {
  802. throw new Error('path is required.');
  803. }
  804. const baseQuery = this.find({});
  805. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  806. if (options.disableUserPages) {
  807. queryBuilder.addConditionToListByNotMatchPathAndChildren('/user');
  808. }
  809. if (!options.includeTrashed) {
  810. queryBuilder.addConditionToExcludeTrashed();
  811. }
  812. if (!options.includeWipPage) {
  813. queryBuilder.addConditionToExcludeWipPage();
  814. }
  815. queryBuilder.addConditionToListWithDescendants(path, options);
  816. queryBuilder.populateDataToList(USER_FIELDS_EXCEPT_CONFIDENTIAL);
  817. await queryBuilder.addViewerCondition(
  818. user,
  819. undefined,
  820. undefined,
  821. !options.hideRestrictedByOwner,
  822. !options.hideRestrictedByGroup,
  823. );
  824. const pages = await this.paginate(queryBuilder.query.clone(), {
  825. lean: true,
  826. sort: sortOpt,
  827. offset: options.offset,
  828. limit: options.limit,
  829. });
  830. const results = {
  831. pages: pages.docs,
  832. totalCount: pages.totalDocs,
  833. offset: options.offset,
  834. limit: options.limit,
  835. };
  836. return results;
  837. };
  838. /*
  839. * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  840. * The result will include the target as well
  841. */
  842. schema.statics.findTargetAndAncestorsByPathOrId = async function (
  843. pathOrId: string,
  844. user,
  845. userGroups,
  846. ): Promise<TargetAndAncestorsResult> {
  847. let path: string;
  848. if (!hasSlash(pathOrId)) {
  849. const _id = pathOrId;
  850. const page = await this.findOne({ _id });
  851. path = page == null ? '/' : page.path;
  852. } else {
  853. path = pathOrId;
  854. }
  855. const ancestorPaths = collectAncestorPaths(path);
  856. ancestorPaths.push(path); // include target
  857. // Do not populate
  858. const queryBuilder = new PageQueryBuilder(this.find(), true);
  859. await queryBuilder.addViewerCondition(user, userGroups);
  860. const _targetAndAncestors: PageDocument[] = await queryBuilder
  861. .addConditionAsOnTree()
  862. .addConditionToListByPathsArray(ancestorPaths)
  863. .addConditionToMinimizeDataForRendering()
  864. .addConditionToSortPagesByDescPath()
  865. .query.lean()
  866. .exec();
  867. // no same path pages
  868. const ancestorsMap = new Map<string, PageDocument>();
  869. _targetAndAncestors.forEach((page) => {
  870. ancestorsMap.set(page.path, page);
  871. });
  872. const targetAndAncestors = Array.from(ancestorsMap.values());
  873. const rootPage = targetAndAncestors[targetAndAncestors.length - 1];
  874. return { targetAndAncestors, rootPage };
  875. };
  876. /**
  877. * Create empty pages at paths at which no pages exist
  878. * @param paths Page paths
  879. * @param aggrPipelineForExistingPages AggregationPipeline object to find existing pages at paths
  880. */
  881. schema.statics.createEmptyPagesByPaths = async function (
  882. paths: string[],
  883. aggrPipelineForExistingPages: any[],
  884. ): Promise<void> {
  885. const existingPages = await this.aggregate(aggrPipelineForExistingPages);
  886. const existingPagePaths = existingPages.map((page) => page.path);
  887. const notExistingPagePaths = paths.filter(
  888. (path) => !existingPagePaths.includes(path),
  889. );
  890. await this.insertMany(
  891. notExistingPagePaths.map((path) => ({ path, isEmpty: true })),
  892. );
  893. };
  894. /**
  895. * Find a parent page by path
  896. */
  897. schema.statics.findParentByPath = async function (
  898. path: string,
  899. ): Promise<HydratedDocument<PageDocument> | null> {
  900. const parentPath = nodePath.dirname(path);
  901. const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
  902. const pagesCanBeParent = await builder.addConditionAsOnTree().query.exec();
  903. if (pagesCanBeParent.length >= 1) {
  904. return pagesCanBeParent[0]; // the earliest page will be the result
  905. }
  906. return null;
  907. };
  908. /*
  909. * Utils from obsolete-page.js
  910. */
  911. export async function pushRevision(pageData, newRevision, user) {
  912. await newRevision.save();
  913. pageData.revision = newRevision;
  914. pageData.latestRevisionBodyLength = newRevision.body.length;
  915. pageData.lastUpdateUser = user?._id ?? user;
  916. pageData.updatedAt = Date.now();
  917. return pageData.save();
  918. }
  919. /**
  920. * add/subtract descendantCount of pages with provided paths by increment.
  921. * increment can be negative number
  922. */
  923. schema.statics.incrementDescendantCountOfPageIds = async function (
  924. pageIds: ObjectIdLike[],
  925. increment: number,
  926. ): Promise<void> {
  927. await this.updateMany(
  928. { _id: { $in: pageIds } },
  929. { $inc: { descendantCount: increment } },
  930. );
  931. };
  932. /**
  933. * recount descendantCount of a page with the provided id and return it
  934. */
  935. schema.statics.recountDescendantCount = async function (
  936. id: ObjectIdLike,
  937. ): Promise<number> {
  938. const res = await this.aggregate([
  939. {
  940. $match: {
  941. parent: id,
  942. },
  943. },
  944. {
  945. $project: {
  946. parent: 1,
  947. isEmpty: 1,
  948. descendantCount: 1,
  949. },
  950. },
  951. {
  952. $group: {
  953. _id: '$parent',
  954. sumOfDescendantCount: {
  955. $sum: '$descendantCount',
  956. },
  957. sumOfDocsCount: {
  958. $sum: {
  959. // biome-ignore lint/suspicious/noThenProperty: ignore
  960. $cond: { if: { $eq: ['$isEmpty', true] }, then: 0, else: 1 }, // exclude isEmpty true page from sumOfDocsCount
  961. },
  962. },
  963. },
  964. },
  965. {
  966. $set: {
  967. descendantCount: {
  968. $sum: ['$sumOfDescendantCount', '$sumOfDocsCount'],
  969. },
  970. },
  971. },
  972. ]);
  973. return res.length === 0 ? 0 : res[0].descendantCount;
  974. };
  975. schema.statics.findAncestorsUsingParentRecursively = async function (
  976. pageId: ObjectIdLike,
  977. shouldIncludeTarget: boolean,
  978. ) {
  979. const self = this;
  980. const target = await this.findById(pageId);
  981. if (target == null) {
  982. throw Error('Target not found');
  983. }
  984. async function findAncestorsRecursively(
  985. target,
  986. ancestors = shouldIncludeTarget ? [target] : [],
  987. ) {
  988. const parent = await self.findOne({ _id: target.parent });
  989. if (parent == null) {
  990. return ancestors;
  991. }
  992. return findAncestorsRecursively(parent, [...ancestors, parent]);
  993. }
  994. return findAncestorsRecursively(target);
  995. };
  996. // TODO: write test code
  997. /**
  998. * Recursively removes empty pages at leaf position.
  999. * @param pageId ObjectIdLike
  1000. * @returns Promise<void>
  1001. */
  1002. schema.statics.removeLeafEmptyPagesRecursively = async function (
  1003. pageId: ObjectIdLike,
  1004. ): Promise<void> {
  1005. const self = this;
  1006. const initialPage = await this.findById(pageId);
  1007. if (initialPage == null) {
  1008. return;
  1009. }
  1010. if (!initialPage.isEmpty) {
  1011. return;
  1012. }
  1013. async function generatePageIdsToRemove(
  1014. childPage,
  1015. page,
  1016. pageIds: ObjectIdLike[] = [],
  1017. ): Promise<ObjectIdLike[]> {
  1018. if (!page.isEmpty) {
  1019. return pageIds;
  1020. }
  1021. const isChildrenOtherThanTargetExist = await self.exists({
  1022. _id: { $ne: childPage?._id },
  1023. parent: page._id,
  1024. });
  1025. if (isChildrenOtherThanTargetExist) {
  1026. return pageIds;
  1027. }
  1028. pageIds.push(page._id);
  1029. const nextPage = await self.findById(page.parent);
  1030. if (nextPage == null) {
  1031. return pageIds;
  1032. }
  1033. return generatePageIdsToRemove(page, nextPage, pageIds);
  1034. }
  1035. const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
  1036. await this.deleteMany({ _id: { $in: pageIdsToRemove } });
  1037. };
  1038. schema.statics.normalizeDescendantCountById = async function (pageId) {
  1039. const children = await this.find({ parent: pageId });
  1040. const sumChildrenDescendantCount = children
  1041. .map((d) => d.descendantCount)
  1042. .reduce((c1, c2) => c1 + c2);
  1043. const sumChildPages = children.filter((p) => !p.isEmpty).length;
  1044. return this.updateOne(
  1045. { _id: pageId },
  1046. { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } },
  1047. { new: true },
  1048. );
  1049. };
  1050. schema.statics.takeOffFromTree = async function (pageId: ObjectIdLike) {
  1051. return this.findByIdAndUpdate(pageId, { $set: { parent: null } });
  1052. };
  1053. schema.statics.removeEmptyPages = async function (
  1054. pageIdsToNotRemove: ObjectIdLike[],
  1055. paths: string[],
  1056. ): Promise<void> {
  1057. await this.deleteMany({
  1058. _id: {
  1059. $nin: pageIdsToNotRemove,
  1060. },
  1061. path: {
  1062. $in: paths,
  1063. },
  1064. isEmpty: true,
  1065. });
  1066. };
  1067. /**
  1068. * Find a not empty parent recursively.
  1069. * @param {string} path
  1070. * @returns {Promise<PageDocument | null>}
  1071. */
  1072. schema.statics.findNotEmptyParentByPathRecursively = async function (
  1073. path: string,
  1074. ): Promise<PageDocument | null> {
  1075. const parent = await this.findParentByPath(path);
  1076. if (parent == null) {
  1077. return null;
  1078. }
  1079. const recursive = async (page: PageDocument): Promise<PageDocument> => {
  1080. if (!page.isEmpty) {
  1081. return page;
  1082. }
  1083. const next = await this.findById(page.parent);
  1084. if (next == null || isTopPage(next.path)) {
  1085. return page;
  1086. }
  1087. return recursive(next);
  1088. };
  1089. const notEmptyParent = await recursive(parent);
  1090. return notEmptyParent;
  1091. };
  1092. schema.statics.findParent = async function (
  1093. pageId,
  1094. ): Promise<PageDocument | null> {
  1095. return this.findOne({ _id: pageId });
  1096. };
  1097. schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
  1098. export function generateGrantCondition(
  1099. user,
  1100. userGroups: ObjectIdLike[] | null,
  1101. includeAnyoneWithTheLink = false,
  1102. showPagesRestrictedByOwner = false,
  1103. showPagesRestrictedByGroup = false,
  1104. ): { $or: any[] } {
  1105. const grantConditions: AnyObject[] = [
  1106. { grant: null },
  1107. { grant: GRANT_PUBLIC },
  1108. ];
  1109. if (includeAnyoneWithTheLink) {
  1110. grantConditions.push({ grant: GRANT_RESTRICTED });
  1111. }
  1112. if (showPagesRestrictedByOwner) {
  1113. grantConditions.push({ grant: GRANT_SPECIFIED }, { grant: GRANT_OWNER });
  1114. } else if (user != null) {
  1115. grantConditions.push(
  1116. { grant: GRANT_SPECIFIED, grantedUsers: user._id },
  1117. { grant: GRANT_OWNER, grantedUsers: user._id },
  1118. );
  1119. }
  1120. if (showPagesRestrictedByGroup) {
  1121. grantConditions.push({ grant: GRANT_USER_GROUP });
  1122. } else if (userGroups != null && userGroups.length > 0) {
  1123. grantConditions.push({
  1124. grant: GRANT_USER_GROUP,
  1125. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  1126. });
  1127. }
  1128. return {
  1129. $or: grantConditions,
  1130. };
  1131. }
  1132. schema.statics.generateGrantCondition = generateGrantCondition;
  1133. function generateGrantConditionForSystemDeletion(): { $or: any[] } {
  1134. const grantCondition: AnyObject[] = [
  1135. { grant: null },
  1136. { grant: GRANT_PUBLIC },
  1137. { grant: GRANT_RESTRICTED },
  1138. { grant: GRANT_SPECIFIED },
  1139. { grant: GRANT_OWNER },
  1140. { grant: GRANT_USER_GROUP },
  1141. ];
  1142. return {
  1143. $or: grantCondition,
  1144. };
  1145. }
  1146. schema.statics.generateGrantConditionForSystemDeletion =
  1147. generateGrantConditionForSystemDeletion;
  1148. // find ancestor page with isEmpty: false. If parameter path is '/', return null
  1149. schema.statics.findNonEmptyClosestAncestor = async function (
  1150. path: string,
  1151. ): Promise<PageDocument | null> {
  1152. if (path === '/') {
  1153. return null;
  1154. }
  1155. const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
  1156. const ancestors = await builderForAncestors
  1157. .addConditionToListOnlyAncestors(path) // only ancestor paths
  1158. .addConditionToSortPagesByDescPath() // sort by path in Desc. Long to Short.
  1159. .query.exec();
  1160. return ancestors[0] ?? null;
  1161. };
  1162. schema.statics.removeGroupsToDeleteFromPages = async function (
  1163. pages: PageDocument[],
  1164. groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
  1165. ) {
  1166. const groupsToDeleteIds = groupsToDelete.map((group) => group._id.toString());
  1167. const pageGroups = pages.reduce(
  1168. (
  1169. acc: { canPublicize: PageDocument[]; cannotPublicize: PageDocument[] },
  1170. page,
  1171. ) => {
  1172. const canPublicize = page.grantedGroups.every((group) =>
  1173. groupsToDeleteIds.includes(getIdForRef(group.item).toString()),
  1174. );
  1175. acc[canPublicize ? 'canPublicize' : 'cannotPublicize'].push(page);
  1176. return acc;
  1177. },
  1178. { canPublicize: [], cannotPublicize: [] },
  1179. );
  1180. // Only publicize pages that can only be accessed by the groups to be deleted
  1181. const publicizeQueries = pageGroups.canPublicize.map((page) => {
  1182. return {
  1183. updateOne: {
  1184. filter: { _id: page._id },
  1185. update: {
  1186. grantedGroups: [],
  1187. grant: this.GRANT_PUBLIC,
  1188. },
  1189. },
  1190. };
  1191. });
  1192. // Remove the groups to be deleted from the grantedGroups of the pages that can be accessed by other groups
  1193. const removeFromGrantedGroupsQueries = pageGroups.cannotPublicize.map(
  1194. (page) => {
  1195. return {
  1196. updateOne: {
  1197. filter: { _id: page._id },
  1198. update: {
  1199. $set: {
  1200. grantedGroups: page.grantedGroups.filter(
  1201. (group) =>
  1202. !groupsToDeleteIds.includes(
  1203. getIdForRef(group.item).toString(),
  1204. ),
  1205. ),
  1206. },
  1207. },
  1208. },
  1209. };
  1210. },
  1211. );
  1212. await this.bulkWrite([
  1213. ...publicizeQueries,
  1214. ...removeFromGrantedGroupsQueries,
  1215. ]);
  1216. };
  1217. /*
  1218. * get latest revision body length
  1219. */
  1220. schema.methods.getLatestRevisionBodyLength = async function (
  1221. this: PageDocument,
  1222. ): Promise<number | null | undefined> {
  1223. if (!this.isLatestRevision() || this.revision == null) {
  1224. return null;
  1225. }
  1226. if (this.latestRevisionBodyLength == null) {
  1227. await this.calculateAndUpdateLatestRevisionBodyLength();
  1228. }
  1229. return this.latestRevisionBodyLength;
  1230. };
  1231. /*
  1232. * calculate and update latestRevisionBodyLength
  1233. */
  1234. schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function (
  1235. this: PageDocument,
  1236. ): Promise<void> {
  1237. if (!this.isLatestRevision() || this.revision == null) {
  1238. logger.error('revision field is required.');
  1239. return;
  1240. }
  1241. // biome-ignore lint/plugin: allow populate for backward compatibility
  1242. const populatedPageDocument = await this.populate<PageDocument>(
  1243. 'revision',
  1244. 'body',
  1245. );
  1246. assert(populatedPageDocument.revision != null);
  1247. assert(isPopulated(populatedPageDocument.revision));
  1248. this.latestRevisionBodyLength = populatedPageDocument.revision.body.length;
  1249. await this.save();
  1250. };
  1251. schema.methods.publish = function () {
  1252. this.wip = undefined;
  1253. this.ttlTimestamp = undefined;
  1254. };
  1255. schema.methods.unpublish = function () {
  1256. this.wip = true;
  1257. this.ttlTimestamp = undefined;
  1258. };
  1259. schema.methods.makeWip = function (disableTtl: boolean) {
  1260. this.wip = true;
  1261. if (!disableTtl) {
  1262. this.ttlTimestamp = new Date();
  1263. }
  1264. };
  1265. /*
  1266. * Merge obsolete page model methods and define new methods which depend on crowi instance
  1267. */
  1268. export default function PageModel(crowi: Crowi | null): any {
  1269. // add old page schema methods
  1270. const pageSchema = getPageSchema(crowi);
  1271. schema.methods = { ...pageSchema.methods, ...schema.methods };
  1272. schema.statics = { ...pageSchema.statics, ...schema.statics };
  1273. return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
  1274. }